Compare commits

..
Author SHA1 Message Date
james.maes 5e775ccf6f Add .circleci/config.yml 2026-02-27 11:39:25 -06:00
james.maesandClaude Opus 4.6 df8e2e61cf feat: add record UX enhancements — audit history, user preferences, search dialog, inline child creation
- Add RecordInfoFooter with created/modified timestamps and Change History dialog
  with searchable audit timeline (field changes, before/after values, user info)
- Add audit record types, API, mock data, and useAuditRecords hook
- Add user preferences system (localStorage): default page size, density, view mode
  for both table and record screens
- Add sidebar user menu (click user info) with Preferences dialog and Logout
- Add "/" keyboard shortcut for record search dialog with live results
- Add inline child record creation dialog in AssociatedRecords (auto-fills FK)
- Make associated record IDs and second column clickable links with hover previews
- Make URLs in RecordHoverCard clickable (opens in new tab)
- Add back navigation chain with unlimited depth via from/fromLabel URL params
- Rename T3 audit sections to "Record Info", render at bottom outside tabs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 23:40:05 -06:00
james.maesandClaude Opus 4.6 6073f1c4b3 feat: wire process actions to table/record views and add order line mock data
- Add getProcessesForTable utility to filter processes by table name
- Wire processes from metadata into RecordQuery (toolbar + bulk actions)
- Wire processes from metadata into RecordView → RecordActions dropdown
- Add orderLine table with 20 fixture records across 10 orders
- Add exposedJoins on order table → orderLine (isMany)
- Add order processes: fulfillOrder, cancelOrder, bulkUpdateOrderStatus
- Update mock GET handler to return associatedRecords when includeAssociations=true
- Register orderLine in mock store with auto-increment and label generator

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:34:52 -06:00
james.maesandClaude Opus 4.6 d28c569b3a feat: retrofit ME Health Portal design system across full UI
- Replace hardcoded Tailwind colors with semantic tokens (bg-card, text-foreground, border-border, etc.)
- Redesign sidebar: white background, primary-colored active state, clickable app names with dashboards
- Merge Header + Breadcrumbs into single bar with "/" separators and parent app injection
- Add standalone Dashboard page at /app with stats, recent records, quick actions
- Restructure RecordView: T1 fields as compact grid in header, T2 sections in tabbed card layout
- Add card/list view toggle for record pages
- Filter record label fields from T1 summary to avoid repetition
- Update typography tiers, spacing, shadows, and border-radius across all components
- Add GlobalSearch, KeyboardShortcutsDialog, and recent-records utility
- Change primary color to healthcare blue (#2563eb)
- Fix use-routes and Breadcrumbs tests for new Dashboard route and layout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 22:23:50 -06:00
james.maesandClaude Opus 4.6 1137802f8c fix: address all review audit findings across 5 work streams
Fix 84 original audit findings and 22 additional review findings across
all component layers: API/hooks, process components, widget components,
query components, and record/form components.

Key fixes:
- API: file upload instanceof check, memory leak in process polling,
  exponential backoff, exposed joins support
- Process: 7 new step components (view, download, HTML, summary, widget,
  bulk edit, cancel dialog), progress bar, input record validation
- Widgets: BlockWidget rewrite for all 11 block types, CompositeWidget,
  dropdown state management, bar chart orientations
- Query: process launcher menu, card view with keyboard nav, possible
  value filters with race condition fix, stable keys, ARIA improvements
- Records/Forms: unsaved changes guard, Radix-based dialogs, useMemo
  optimizations, ARIA fixes, adornment renderers (tooltip, error, code)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 20:31:21 -06:00
james.maesandClaude Opus 4.6 66db82b3ce feat: initial development phase 0 — complete QQQ Admin UI foundation
Full Next.js 15 + React 19 + Tailwind CSS 4 + shadcn/ui implementation
of the QQQ Admin UI, covering all 6 work packages:

**P1 — Scaffold, Auth, Layout Shell**
- Next.js 15 App Router, TypeScript strict, Tailwind CSS 4, shadcn/ui
- Auth system: FULLY_ANONYMOUS, MOCK, OAuth2, Auth0 support
- Axios API client with global 401 interceptor
- Layout shell: sidebar, header, breadcrumbs, environment banners
- Route generation from backend metadata (fully dynamic)
- QContext provider, theme system with CSS variable injection

**P2 — Record Query**
- TanStack Table v8 DataGrid: sorting, column resize, density toggle
- FilterBuilder: 20 operators, AND/OR groups, URL-persisted state
- ColumnConfig: show/hide/reorder with drag-and-drop
- Pagination with shareable URL state
- BulkActionBar, ExportButton, SavedViewsMenu (localStorage)
- Cell renderers for all QFieldType values

**P3 — Record View + CRUD**
- RecordView with field sections, associated records
- FieldValue renderer for all QFieldType + AdornmentType values
- DynamicForm + EntityForm (reused by P4 processes)
- Full field type components: text, number, boolean, date, select, file, password
- PossibleValueSelect async combobox with debounced search
- Zod schema generation from field metadata
- Delete confirmation dialog

**P4 — Process Execution**
- ProcessRun orchestrator with full async lifecycle + polling
- StepWizard progress indicator
- Step types: FORM, VALIDATION, RECORD_LIST, BULK_LOAD
- ProcessResultStep, ProcessErrorState
- use-process hook with TanStack Query

**P5 — Dashboard + Widgets**
- AppHome dashboard with per-widget data fetching
- StatisticsWidget, BarChartWidget, LineChartWidget, PieChartWidget (Recharts)
- RecordGridWidget, BlockWidget, QuickLinksWidget, AlertWidget
- WidgetBlock container, WidgetGrid responsive layout
- WidgetErrorBoundary (per-widget isolation)

**P6 — Polish + Parity**
- Command palette (Cmd+K) with fuzzy search over all metadata routes
- Toast notifications (sonner) on save/delete/process complete
- EmptyState + Skeleton components
- Responsive sidebar drawer for mobile (<768px)
- Accessibility: skip link, aria-live regions, focus management
- ErrorBoundary component
- Developer tools pages (table/record JSON views)
- Custom CSS injection from branding metadata
- robots.txt, favicon placeholder

**Mock API Layer (MSW v2)**
- Full backend-free development mode (NEXT_PUBLIC_MOCK_API=true)
- CRM app: People (25), Companies (10), Orders (20)
- Inventory app: Products (15), Suppliers (8)
- In-memory mutable store, pagination, filter, possible values
- Process flows: importPeople (3-step), sendEmail (2-step)
- Dashboard widgets: stats, bar chart, line chart, record grid

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 19:03:23 -06:00
james.maes 26052f99db docs(repo): initial checkin 2026-02-25 15:20:46 -06:00
james.maes 641961a335 Initial commit 2026-02-25 15:06:40 -06:00
230 changed files with 57120 additions and 3 deletions

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
# Use the latest 2.1 version of CircleCI pipeline process engine.
# See: https://circleci.com/docs/reference/configuration-reference
version: 2.1
# Define a job to be invoked later in a workflow.
# See: https://circleci.com/docs/guides/orchestrate/jobs-steps/#jobs-overview & https://circleci.com/docs/reference/configuration-reference/#jobs
jobs:
say-hello:
# Specify the execution environment. You can specify an image from Docker Hub or use one of our convenience images from CircleCI's Developer Hub.
# See: https://circleci.com/docs/guides/execution-managed/executor-intro/ & https://circleci.com/docs/reference/configuration-reference/#executor-job
docker:
# Specify the version you desire here
# See: https://circleci.com/developer/images/image/cimg/base
- image: cimg/base:current
# Add steps to the job
# See: https://circleci.com/docs/guides/orchestrate/jobs-steps/#steps-overview & https://circleci.com/docs/reference/configuration-reference/#steps
steps:
# Checkout the code as the first step.
- checkout
- run:
name: "Say hello"
command: "echo Hello, World!"
# Orchestrate jobs using workflows
# See: https://circleci.com/docs/guides/orchestrate/workflows/ & https://circleci.com/docs/reference/configuration-reference/#workflows
workflows:
say-hello-workflow: # This is the name of the workflow, feel free to change it to better match your workflow.
# Inside the workflow, you define the jobs you want to run.
jobs:
- say-hello
+8
View File
@@ -0,0 +1,8 @@
# Mock API environment — copy to .env.local to run without a real backend
#
# cp .env.mock .env.local && pnpm dev
# OR pass inline:
# NEXT_PUBLIC_MOCK_API=true pnpm dev
NEXT_PUBLIC_MOCK_API=true
NEXT_PUBLIC_API_BASE_URL=http://localhost:3000/qqq/v1
+36
View File
@@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
.env
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+9
View File
@@ -0,0 +1,9 @@
{
"semi": false,
"singleQuote": true,
"trailingComma": "es5",
"tabWidth": 2,
"printWidth": 100,
"bracketSpacing": true,
"arrowParens": "always"
}
+171
View File
@@ -0,0 +1,171 @@
# CLAUDE.md — QQQ Frontend Next
## Project Overview
This is the **QQQ Admin UI Modernization** project — a complete rewrite of the QQQ admin frontend from React + Material UI to Next.js + Tailwind CSS + shadcn/ui.
QQQ is a metadata-driven, low-code application framework. The admin UI renders entirely from backend metadata — no table names, field lists, or navigation items are hardcoded. The backend declares metadata (tables, processes, apps, widgets), and the frontend renders it dynamically.
## Tech Stack
- **Framework:** Next.js 15 (App Router) with React 19
- **Language:** TypeScript 5.x (`strict: true` — no `any` in component props)
- **Styling:** Tailwind CSS 4.x with CSS custom properties for QQQ theme tokens
- **Components:** shadcn/ui (Radix primitives + Tailwind)
- **Data Grid:** TanStack Table v8 (replaces MUI X DataGrid Pro)
- **Forms:** React Hook Form v7 + Zod validation (replaces Formik + Yup)
- **Data Fetching:** TanStack Query v5
- **Charts:** Recharts
- **Testing:** Vitest (unit), Playwright (E2E), axe-core (a11y)
- **Package Manager:** pnpm
## Directory Structure
```
src/
├── app/ # Next.js App Router pages
│ ├── (auth)/ # Auth routes (login, callback)
│ ├── (dashboard)/ # Authenticated routes
│ │ ├── layout.tsx # Dashboard layout (sidebar + nav + banners)
│ │ └── app/ # All app routes
│ │ ├── [appName]/page.tsx # App home (dashboard)
│ │ ├── [tableName]/page.tsx # Record query
│ │ ├── [tableName]/create/page.tsx # Create record
│ │ ├── [tableName]/[recordId]/page.tsx # Record view
│ │ ├── [tableName]/[recordId]/edit/page.tsx # Edit record
│ │ └── [processName]/page.tsx # Process execution
│ ├── layout.tsx # Root layout (providers)
│ ├── error.tsx # Global error boundary
│ └── not-found.tsx # 404
├── components/
│ ├── ui/ # shadcn/ui primitives
│ ├── layout/ # Sidebar, Header, Breadcrumbs, Banner
│ ├── records/ # RecordView, FieldValue
│ ├── forms/ # DynamicForm, EntityForm, field components
│ ├── query/ # DataGrid, FilterBuilder, ColumnConfig, Pagination
│ ├── process/ # StepWizard, ValidationReview, BulkLoad
│ ├── widgets/ # Widget containers, charts, blocks, grids
│ └── feedback/ # Alerts, Toasts, Modals, CommandMenu
├── lib/
│ ├── api/ # Typed API client modules
│ │ ├── client.ts # Base axios client with 401 interceptor
│ │ ├── auth.ts # Auth endpoints
│ │ ├── metadata.ts # Metadata endpoints
│ │ ├── tables.ts # CRUD + query/count
│ │ ├── processes.ts # Process lifecycle
│ │ ├── widgets.ts # Widget data
│ │ └── possible-values.ts
│ ├── auth/ # Auth providers (OAuth2, Auth0, Anonymous)
│ ├── hooks/ # Shared React hooks
│ ├── utils/ # Pure utility functions
│ ├── context/ # React contexts (QContext)
│ └── theme/ # Theme provider, CSS tokens
├── types/ # TypeScript type definitions
└── styles/ # Global styles, theme tokens
```
## Key Conventions
### Metadata-Driven Rendering
- ALL components that render from metadata accept it as a prop — never fetch internally
- No hardcoded table names, field names, or process names anywhere
- User-facing text uses `label` from metadata, never `name`
- UI structure derives entirely from backend metadata
### API Integration
- All API calls go through typed functions in `src/lib/api/` — never raw `fetch`
- TanStack Query manages server state (caching, deduplication, revalidation)
- Query keys follow the factory pattern in `src/lib/query-client.ts`
- 401 responses intercepted globally → redirect to login
### Component Patterns
- Server Components: root layouts, metadata-fetching pages
- Client Components: interactive UI (forms, grids, filters) — mark with `"use client"`
- Every interactive component has a `data-qqq-id` attribute for CSS customization
- Naming: `data-qqq-id="{component-type}-{identifier}"` (e.g., `data-qqq-id="button-save"`)
### State Management
- Server state: TanStack Query
- UI state: React context (QContext)
- Form state: React Hook Form
- URL state: Next.js searchParams (filters, pagination — shareable URLs)
- Local persistence: localStorage (density, column widths, saved views)
### File Naming
- Components: PascalCase (`RecordQuery.tsx`)
- Hooks: camelCase with `use-` prefix (`use-metadata.ts`)
- Utilities: kebab-case (`filter-utils.ts`)
- Types: kebab-case (`metadata.ts`)
- Tests: `*.test.ts(x)` co-located with source
### Import Order
```typescript
// 1. React/Next.js
import { useState } from 'react'
import { useRouter } from 'next/navigation'
// 2. External libraries
import { useQuery } from '@tanstack/react-query'
// 3. Types
import type { QTableMetaData } from '@/types'
// 4. Lib (API, hooks, utils)
import { queryRecords } from '@/lib/api/tables'
// 5. Components
import { Button } from '@/components/ui/button'
```
### Error Handling
- Error boundaries at route level (`error.tsx`) and widget level
- API errors via TanStack Query `onError` → toast notification
- Form validation: Zod schemas from field metadata
- Loading: skeleton components matching final layout
- Empty: descriptive messages with action buttons
### Accessibility (WCAG 2.1 AA)
- All form inputs: `<label>` with `htmlFor`, `aria-required`, `aria-invalid`
- All buttons: descriptive text or `aria-label`
- Focus trapped in modals, restored on close
- Keyboard navigation for all interactive elements
- Color contrast: 4.5:1 normal, 3:1 large text
## Commands
```bash
pnpm dev # Start dev server
pnpm build # Production build
pnpm test # Run Vitest unit tests
pnpm test:e2e # Run Playwright E2E tests
pnpm tsc --noEmit # Type check without emitting
pnpm lint # ESLint
pnpm format # Prettier
```
## Implementation Plans
Full implementation plans are in `docs/implementation-plans/`. The project is built in 6 work packages:
1. **Package 1** — Scaffold, Auth, Layout Shell (foundation — must be first)
2. **Package 2** — Record Query (data grid, filters, pagination, saved views)
3. **Package 3** — Record View + CRUD (forms, field types, validation)
4. **Package 4** — Process Execution (step wizard, async polling, bulk load)
5. **Package 5** — Dashboard + Widgets (charts, statistics, blocks, grids)
6. **Package 6** — Polish + Parity (responsive, a11y, perf, Storybook, E2E)
Shared context for all agents: `docs/implementation-plans/shared-context/`
## Reference Codebase
The current frontend being replaced lives in the sibling `qqq-frontend-material-dashboard` repo. Key files to reference for behavior parity:
- `App.tsx` — route generation, auth flow, layout
- `src/qqq/pages/records/query/RecordQuery.tsx` — most complex page (260+ state vars)
- `src/qqq/pages/records/view/RecordView.tsx` — record detail
- `src/qqq/pages/processes/ProcessRun.tsx` — process execution
- `src/qqq/components/widgets/` — 60+ widget components
## API Base URL
Set via `NEXT_PUBLIC_API_BASE_URL` environment variable. Defaults to `/qqq/v1`.
The API uses cookie-based session auth (`sessionUUID` cookie).
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+1 -3
View File
@@ -1,3 +1 @@
# PR assets: SQL Workbench theme
Before (main) and after screenshots of the SQL Workbench, light and dark, desktop and phone (mocked API, production build).
# qqq-frontend-next
Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 95 KiB

+293
View File
@@ -0,0 +1,293 @@
# Master Agent Execution Plan — QQQ Frontend Next
## Your Role
You are the **Master Orchestrator Agent**. Your job is to build the entire QQQ admin UI replacement by spawning and controlling implementation sub-agents, one per work package, in the correct dependency order. You delegate all coding work to sub-agents. You verify their output, handle failures, and ensure the full project compiles and integrates at the end.
## Repository
You are working in `qqq-frontend-next` — a new repo for the Next.js replacement of the QQQ admin UI.
### Critical Files in This Repo
```
docs/
├── qqq-admin-modernization-requirements.md # Full requirements spec
├── master-architect-agent-prompt.md # How the plans were designed
├── 00-master-execution-plan.md # THIS FILE — your instructions
└── implementation-plans/
├── 00-project-overview.md # Tech stack, directory structure, conventions
├── 01-scaffold-and-auth.md # Work Package 1 plan
├── 02-record-query.md # Work Package 2 plan
├── 03-record-view-and-crud.md # Work Package 3 plan
├── 04-process-execution.md # Work Package 4 plan
├── 05-dashboard-and-widgets.md # Work Package 5 plan
├── 06-polish-and-parity.md # Work Package 6 plan
├── cross-check-report.md # Requirements coverage verification
├── shared-context/
│ ├── api-contract.md # API spec — give to every agent
│ ├── type-definitions.md # TypeScript types — give to every agent
│ └── coding-conventions.md # Coding standards — give to every agent
└── review-checklists/
├── review-01-scaffold.md through review-06-polish.md
```
### Reference Repos (Read-Only)
If accessible, the agents should also reference:
- `qqq-frontend-material-dashboard` — The current UI being replaced (behavior parity)
- `qqq-frontend-core` — TypeScript model types and API client (types to port)
- `qqq` — Backend middleware (API endpoint source of truth)
---
## Execution Phases
### Phase 1: Foundation (BLOCKING — must complete before any other phase)
**Spawn Agent 1: Package 1 — Scaffold + Auth + Layout Shell**
Instruct the agent:
> Read these files before writing any code:
> - `docs/implementation-plans/01-scaffold-and-auth.md` (your implementation plan)
> - `docs/implementation-plans/shared-context/api-contract.md`
> - `docs/implementation-plans/shared-context/type-definitions.md`
> - `docs/implementation-plans/shared-context/coding-conventions.md`
> - `docs/implementation-plans/00-project-overview.md`
> - `CLAUDE.md`
>
> Also read the reference implementations in `qqq-frontend-material-dashboard`:
> - `src/App.tsx` — Route generation, auth dispatch, layout
> - `src/qqq/components/horseshoe/` — SideNav, NavBar, Breadcrumbs
>
> And from `qqq-frontend-core`:
> - `src/model/**/*.ts` — All TypeScript types to port
> - `src/controllers/QController.ts` — Auth methods
>
> Execute every step in the implementation plan (Steps 1-11).
> After each step, verify: `pnpm tsc --noEmit` passes.
> When done, verify ALL acceptance criteria in Section 9.
> Commit your work.
**Verification gate before proceeding:**
```bash
pnpm dev # Must start without errors
pnpm build # Must succeed
pnpm tsc --noEmit # Zero errors
pnpm test # All tests pass
```
If the agent fails, fix the issues before moving to Phase 2.
---
### Phase 2: Core Pages (CAN BE PARALLEL — no file conflicts)
**Spawn Agent 2: Package 2 — Record Query**
Instruct the agent:
> Read these files before writing any code:
> - `docs/implementation-plans/02-record-query.md` (your implementation plan)
> - `docs/implementation-plans/shared-context/api-contract.md`
> - `docs/implementation-plans/shared-context/type-definitions.md`
> - `docs/implementation-plans/shared-context/coding-conventions.md`
> - `docs/implementation-plans/00-project-overview.md`
> - `CLAUDE.md`
>
> Also read the reference implementation:
> - `qqq-frontend-material-dashboard/src/qqq/pages/records/query/RecordQuery.tsx`
> - `qqq-frontend-material-dashboard/src/qqq/components/query/` (all files)
> - `qqq-frontend-core/src/controllers/QControllerV1.ts` (query/count methods)
>
> Execute every step in the implementation plan.
> The foundation from Package 1 is already in place — import from it.
> After each step: `pnpm tsc --noEmit`
> When done: verify ALL acceptance criteria in Section 9.
> Commit your work.
**Spawn Agent 3: Package 3 — Record View + CRUD**
Instruct the agent:
> Read these files before writing any code:
> - `docs/implementation-plans/03-record-view-and-crud.md` (your implementation plan)
> - `docs/implementation-plans/shared-context/api-contract.md`
> - `docs/implementation-plans/shared-context/type-definitions.md`
> - `docs/implementation-plans/shared-context/coding-conventions.md`
> - `docs/implementation-plans/00-project-overview.md`
> - `CLAUDE.md`
>
> Also read the reference implementation:
> - `qqq-frontend-material-dashboard/src/qqq/pages/records/view/RecordView.tsx`
> - `qqq-frontend-material-dashboard/src/qqq/components/forms/` (all files)
> - `qqq-frontend-material-dashboard/src/qqq/components/forms/EntityForm.tsx`
> - `qqq-frontend-material-dashboard/src/qqq/components/forms/DynamicForm.tsx`
> - `qqq-frontend-core/src/controllers/QController.ts` (CRUD methods: get, create, update, delete)
>
> Execute every step in the implementation plan.
> The foundation from Package 1 is already in place — import from it.
> After each step: `pnpm tsc --noEmit`
> When done: verify ALL acceptance criteria in Section 9.
> Commit your work.
**Verification gate before proceeding:**
Both agents must complete. Then:
```bash
pnpm tsc --noEmit # Zero errors (both packages together)
pnpm test # All tests pass
pnpm build # Must succeed
```
If running in parallel on branches, merge both to main and resolve conflicts before Phase 3.
---
### Phase 3: Process Execution (SEQUENTIAL — needs forms from Package 3)
**Spawn Agent 4: Package 4 — Process Execution**
Instruct the agent:
> Read these files before writing any code:
> - `docs/implementation-plans/04-process-execution.md` (your implementation plan)
> - `docs/implementation-plans/shared-context/api-contract.md`
> - `docs/implementation-plans/shared-context/type-definitions.md`
> - `docs/implementation-plans/shared-context/coding-conventions.md`
> - `CLAUDE.md`
>
> Also read the reference implementation:
> - `qqq-frontend-material-dashboard/src/qqq/pages/processes/ProcessRun.tsx`
> - `qqq-frontend-material-dashboard/src/qqq/components/processes/` (all files)
> - `qqq-frontend-core/src/controllers/QController.ts` (process methods)
>
> IMPORTANT: You must reuse form components from Package 3:
> - Import `EntityForm` from `@/components/forms/EntityForm`
> - Import `DynamicFormField` from `@/components/forms/DynamicFormField`
> - Import form hooks and possible-values utilities
>
> Execute every step. After each: `pnpm tsc --noEmit`.
> When done: verify ALL acceptance criteria.
> Commit your work.
**Verification gate:**
```bash
pnpm tsc --noEmit && pnpm test && pnpm build
```
---
### Phase 4: Dashboard + Widgets (needs DataGrid from P2, forms from P3)
**Spawn Agent 5: Package 5 — Dashboard and Widgets**
Instruct the agent:
> Read these files before writing any code:
> - `docs/implementation-plans/05-dashboard-and-widgets.md` (your implementation plan)
> - `docs/implementation-plans/shared-context/api-contract.md`
> - `docs/implementation-plans/shared-context/type-definitions.md`
> - `docs/implementation-plans/shared-context/coding-conventions.md`
> - `CLAUDE.md`
>
> Also read the reference implementation:
> - `qqq-frontend-material-dashboard/src/qqq/components/widgets/` (all files — 60+)
> - `qqq-frontend-material-dashboard/src/qqq/pages/apps/AppHome.tsx`
>
> IMPORTANT: Reuse components from previous packages:
> - DataGrid from Package 2 (`@/components/query/DataGrid`) for RecordGridWidget
> - EntityForm from Package 3 (`@/components/forms/EntityForm`) for DynamicFormWidget
> - Cell renderers from Package 2 for field value display in widgets
>
> Execute every step. After each: `pnpm tsc --noEmit`.
> When done: verify ALL acceptance criteria.
> Commit your work.
**Verification gate:**
```bash
pnpm tsc --noEmit && pnpm test && pnpm build
```
---
### Phase 5: Polish + Parity (needs EVERYTHING)
**Spawn Agent 6: Package 6 — Polish, Parity, and Production Readiness**
Instruct the agent:
> Read these files before writing any code:
> - `docs/implementation-plans/06-polish-and-parity.md` (your implementation plan)
> - `docs/implementation-plans/shared-context/coding-conventions.md`
> - `CLAUDE.md`
>
> Also read the reference implementation for features unique to this package:
> - `qqq-frontend-material-dashboard/src/qqq/components/misc/CommandMenu.tsx`
> - `qqq-frontend-material-dashboard/src/qqq/components/audits/AuditBody.tsx`
> - `qqq-frontend-material-dashboard/src/qqq/pages/records/developer/`
> - `qqq-frontend-material-dashboard/src/qqq/components/scripts/ScriptViewer.tsx`
>
> This is the final package. ALL previous packages are complete.
> Your job is: responsive audit, accessibility fixes, command palette, keyboard shortcuts,
> audit trail, developer tools, Storybook, Playwright E2E, performance optimization,
> theme tokens, and customCss injection.
>
> Execute every step. After each: `pnpm tsc --noEmit`.
> When done: verify ALL acceptance criteria.
> Run: `pnpm build && pnpm test && pnpm lint`
> Commit your work.
---
## Final Verification (Master Agent Does This)
After all 6 agents complete:
1. **Full build check:**
```bash
pnpm tsc --noEmit # Zero errors
pnpm build # Production build succeeds
pnpm test # All unit tests pass
pnpm lint # Zero lint errors
```
2. **Review checklists:** Read and verify each file in `docs/implementation-plans/review-checklists/`:
- `review-01-scaffold.md` through `review-06-polish.md`
- Every checkbox should be satisfiable
3. **Cross-check report:** Read `docs/implementation-plans/cross-check-report.md` — all requirements should show ✅
4. **Integration smoke test:** If a backend is available, verify the app end-to-end:
- Login → sidebar loads → click table → query page loads → filter → paginate → click row → record view → edit → save → delete → navigate to process → run process → dashboard loads widgets
5. **Commit everything and push:**
```bash
git add -A
git commit -m "Complete QQQ Admin UI Modernization — all 6 work packages implemented"
git push origin main
```
---
## Error Recovery
If an agent fails mid-execution:
1. Read its error output
2. Fix the immediate issue (missing import, type error, etc.)
3. Resume the agent from where it left off (it should be able to continue from the last successful step)
4. If the failure is architectural (wrong approach), read the relevant section of the implementation plan and correct course
If agents produce conflicting code (e.g., both define the same component):
1. The implementation plans are designed to avoid this — each package owns specific files
2. If it happens, the later package's version wins (it has more context)
3. Verify the merged result compiles: `pnpm tsc --noEmit`
---
## Key Constraints for ALL Agents
- **pnpm only** (not npm, not yarn)
- **TypeScript strict** — no `any` in component props
- **Every interactive element** gets `data-qqq-id` attribute
- **All API calls** through `src/lib/api/` — never raw fetch/axios
- **Metadata-driven** — no hardcoded table/field/process names
- **Labels not names** — user-facing text uses `label` from metadata
- **Import order** — React/Next → external libs → types → lib → components
- **Error boundaries** at route level and widget level
- **Loading skeletons** for all async content
- **Empty states** with action buttons for all list/grid views
@@ -0,0 +1,53 @@
# Agent Prompt: Package 1 — Scaffold, Auth, and Layout Shell
## Your Role
You are an implementation agent. Your job is to execute the implementation plan for Work Package 1 and produce working, tested code. You do NOT make architectural decisions — the plan specifies everything. Follow it exactly.
## Before Writing Any Code, Read These Files
1. **Your work package plan:** `docs/implementation-plans/01-scaffold-and-auth.md` — This is your primary instruction set. Execute every step.
2. **Shared context — API contract:** `docs/implementation-plans/shared-context/api-contract.md` — Every API call must match this exactly.
3. **Shared context — Type definitions:** `docs/implementation-plans/shared-context/type-definitions.md` — Port these types into `/src/types/`.
4. **Shared context — Coding conventions:** `docs/implementation-plans/shared-context/coding-conventions.md` — Follow these patterns.
5. **Project overview:** `docs/implementation-plans/00-project-overview.md` — Directory structure and shared conventions.
6. **CLAUDE.md** — Project-level conventions.
## Reference Code (Read for Behavior Parity)
Look at the sibling repo `qqq-frontend-material-dashboard` for:
- `src/App.tsx` — Route generation from appTree, auth dispatch, layout structure
- `src/qqq/components/horseshoe/` — SideNav, NavBar, Breadcrumbs, Footer
- `src/qqq/components/misc/Banners.tsx` — Banner rendering
Also look at `qqq-frontend-core` for:
- `src/controllers/QController.ts` — Auth methods (getAuthenticationMetaData, manageSession)
- `src/model/**/*.ts` — All TypeScript model types to port
## What You Must Deliver
Execute Steps 1-11 from the work package plan. After completion:
1. `pnpm dev` runs without errors
2. `pnpm build` succeeds
3. `pnpm tsc --noEmit` — zero errors
4. `pnpm test` — all unit tests pass
5. Sidebar renders navigation from metadata (use mock data if no backend)
6. Auth flow initializes (AUTH_0, OAUTH2, FULLY_ANONYMOUS)
7. All placeholder pages exist and render within the dashboard layout
8. Theme system injects CSS custom properties
9. 401 interceptor triggers redirect
10. Breadcrumbs render from pathname
## Verification
After completing all steps, check every acceptance criterion in Section 9 of the work package plan. Every criterion must pass before this package is done.
## Important Rules
- Use `pnpm` (not npm or yarn)
- TypeScript strict mode — no `any` in component props
- Every interactive element gets a `data-qqq-id` attribute
- All API calls go through `src/lib/api/` — never raw fetch
- Follow the import order convention from CLAUDE.md
- Commit after each major step so progress isn't lost
+72
View File
@@ -0,0 +1,72 @@
# Agent Prompt: Package 2 — Record Query Page
## Your Role
You are an implementation agent. Execute the implementation plan for Work Package 2 and produce working, tested code. Follow the plan exactly — do not make architectural decisions.
## Before Writing Any Code, Read These Files
1. **Your plan:** `docs/implementation-plans/02-record-query.md`
2. **API contract:** `docs/implementation-plans/shared-context/api-contract.md`
3. **Type definitions:** `docs/implementation-plans/shared-context/type-definitions.md`
4. **Coding conventions:** `docs/implementation-plans/shared-context/coding-conventions.md`
5. **Project overview:** `docs/implementation-plans/00-project-overview.md`
6. **CLAUDE.md**
## Reference Code (Read for Behavior Parity)
From `qqq-frontend-material-dashboard`:
- `src/qqq/pages/records/query/RecordQuery.tsx` — THE most complex page. Read the whole thing. Note the 260+ state variables, the page state machine, query deduplication via latestQueryId, localStorage persistence.
- `src/qqq/components/query/FilterCriteriaRow.tsx` — Per-row filter UI with operator selection per field type
- `src/qqq/components/query/BasicAndAdvancedQueryControls.tsx` — Basic/advanced filter toggle
- `src/qqq/components/query/CustomFilterPanel.tsx` — Full filter builder
- `src/qqq/components/query/CustomColumnsPanel.tsx` — Column show/hide/reorder
- `src/qqq/components/misc/SavedViews.tsx` — Saved view CRUD
- `src/qqq/components/query/ExportMenuItem.tsx` — Export to CSV
From `qqq-frontend-core`:
- `src/controllers/QControllerV1.ts` — `query()` and `count()` methods
- `src/model/query/QQueryFilter.ts` — Filter structure
- `src/model/query/QCriteriaOperator.ts` — All 20 operators
## What Package 1 Already Provides (Import From It)
- Types: `import type { QTableMetaData, QFieldMetaData, QRecord, QQueryFilter, QFilterCriteria, QCriteriaOperator } from '@/types'`
- API client: `import apiClient from '@/lib/api/client'`
- Query keys: `import { queryKeys } from '@/lib/query-client'`
- Metadata: `import { loadTableMetaData } from '@/lib/api/metadata'`
- Context: `import { useQContext } from '@/lib/context/q-context'`
- Layout components are in place — your page renders inside the dashboard layout
## What You Must Build
Replace the placeholder at `src/app/(dashboard)/app/[tableName]/page.tsx` with a full Record Query page featuring:
- TanStack Table v8 data grid with dynamic columns from QTableMetaData.fields
- Server-side pagination (offset/limit via POST /table/{t}/query + /count)
- Cell renderers per QFieldType (STRING, INTEGER, DECIMAL, BOOLEAN, DATE, DATE_TIME, etc.)
- Cell renderers per AdornmentType (LINK, CHIP, FILE_DOWNLOAD, etc.)
- Basic filter (quick text search) and advanced filter builder (all 20 operators, AND/OR, sub-filters)
- Column config (show/hide, reorder, persist to localStorage)
- Row selection (checkbox column, single and multi)
- Export to CSV
- Saved views (save/load filter + column configs)
- Pagination with page size selector (10/25/50/100)
- Table variant selection
- Toolbar (create button, process launcher, column config, density, saved views)
- Row click → navigate to record view
## Verification
After each step: `pnpm tsc --noEmit`
When done: verify ALL acceptance criteria in Section 9 of the plan.
Final check: `pnpm build && pnpm test`
Commit your work.
## Important Rules
- All API calls through `src/lib/api/` — create `src/lib/api/tables.ts` for query/count functions
- Use TanStack Table v8 (NOT MUI DataGrid)
- Use TanStack Query v5 for data fetching
- Every component gets `data-qqq-id` attribute
- No hardcoded column names — everything from metadata
- TypeScript strict — no `any` in props
@@ -0,0 +1,66 @@
# Agent Prompt: Package 3 — Record View and CRUD
## Your Role
You are an implementation agent. Execute Work Package 3. Follow the plan exactly.
## Before Writing Any Code, Read These Files
1. **Your plan:** `docs/implementation-plans/03-record-view-and-crud.md`
2. **API contract:** `docs/implementation-plans/shared-context/api-contract.md`
3. **Type definitions:** `docs/implementation-plans/shared-context/type-definitions.md`
4. **Coding conventions:** `docs/implementation-plans/shared-context/coding-conventions.md`
5. **Project overview:** `docs/implementation-plans/00-project-overview.md`
6. **CLAUDE.md**
## Reference Code (Read for Behavior Parity)
From `qqq-frontend-material-dashboard`:
- `src/qqq/pages/records/view/RecordView.tsx` — Section rendering, T1/non-T1, widget loading, permission-gated action menu, keyboard shortcuts (n/e/c/d/a)
- `src/qqq/components/forms/EntityForm.tsx` — Form orchestrator with section-based layout
- `src/qqq/components/forms/DynamicForm.tsx` — Formik-based form with dynamic fields
- `src/qqq/components/forms/DynamicFormField.tsx` — Per-field renderer by type
- `src/qqq/components/forms/DynamicSelect.tsx` — Possible values autocomplete
- `src/qqq/components/forms/BooleanFieldSwitch.tsx` — Boolean null/false/true
- `src/qqq/components/forms/FileInputField.tsx` — File upload
From `qqq-frontend-core`:
- `src/controllers/QController.ts` — `get()`, `create()`, `update()`, `delete()` methods
- `src/model/metaData/QFieldMetaData.ts` — Field type, adornments, constraints
## What Package 1 Already Provides (Import From It)
- Types: `import type { QTableMetaData, QFieldMetaData, QRecord, QPossibleValue, QFieldType } from '@/types'`
- API client: `import apiClient from '@/lib/api/client'`
- Metadata: `import { loadTableMetaData } from '@/lib/api/metadata'`
- Context: `import { useQContext } from '@/lib/context/q-context'`
## What You Must Build
1. **Record View page** at `src/app/(dashboard)/app/[tableName]/[recordId]/page.tsx`
- Section-organized field display (T1 section at top, others below)
- Field value rendering for ALL 12 QFieldTypes
- All adornment types (LINK, CHIP, SIZE, FILE_DOWNLOAD, TOOLTIP, etc.)
- Action menu (edit, delete, copy, launch process)
- Associated records display
- Keyboard shortcuts (n=new, e=edit, c=copy, d=delete)
2. **Entity Form** at `src/components/forms/EntityForm.tsx`
- Dynamic form generation from QFieldMetaData
- React Hook Form + Zod validation (schemas from metadata: required, maxLength, type)
- All field types: text, number, boolean, date, time, datetime, select, file, HTML, password
- Possible values autocomplete with debounced search (300ms)
- Section-based layout with gridColumns
- File upload with preview
3. **Create/Edit/Copy pages** using EntityForm
4. **Delete** with confirmation dialog
5. **API functions** in `src/lib/api/tables.ts`: getRecord, insertRecord, updateRecord, deleteRecord
6. **Possible values** in `src/lib/api/possible-values.ts`: fetchPossibleValues
## Verification
After each step: `pnpm tsc --noEmit`
When done: verify ALL acceptance criteria.
Final: `pnpm build && pnpm test`
Commit your work.
@@ -0,0 +1,70 @@
# Agent Prompt: Package 4 — Process Execution
## Your Role
You are an implementation agent. Execute Work Package 4. Follow the plan exactly.
## Before Writing Any Code, Read These Files
1. **Your plan:** `docs/implementation-plans/04-process-execution.md`
2. **API contract:** `docs/implementation-plans/shared-context/api-contract.md`
3. **Type definitions:** `docs/implementation-plans/shared-context/type-definitions.md`
4. **Coding conventions:** `docs/implementation-plans/shared-context/coding-conventions.md`
5. **CLAUDE.md**
## Reference Code (Read for Behavior Parity)
From `qqq-frontend-material-dashboard`:
- `src/qqq/pages/processes/ProcessRun.tsx` — THE process page. Read the whole thing. Note: step state machine, 14 component types, async job polling with exponential backoff (1.5s initial, 1.5x multiplier, 12s max), file upload, record selection modes
- `src/qqq/components/processes/` — All process components
- `src/qqq/components/processes/ProcessViewForm.tsx` — View-only form steps
- `src/qqq/components/processes/ProcessSummaryResults.tsx` — Results display
- `src/qqq/components/processes/ValidationReview.tsx` — Pre-commit validation
- `src/qqq/components/processes/BulkLoadFileMappingForm.tsx` — Column-to-field mapping
- `src/qqq/components/processes/BulkLoadValueMappingForm.tsx` — Value mapping
- `src/qqq/components/processes/SavedBulkLoadProfiles.tsx` — Profile save/load
From `qqq-frontend-core`:
- `src/controllers/QController.ts` — processInit, processStep, processJobStatus, processRecords, processCancel
- `src/model/processes/` — QJobStarted, QJobRunning, QJobComplete, QJobError, ProcessMetaDataAdjustment
## What Previous Packages Provide (Import From Them)
From Package 1: types, API client, context, layout
From Package 3 (CRITICAL — reuse these):
- `import { EntityForm } from '@/components/forms/EntityForm'` — For form steps
- `import { DynamicFormField } from '@/components/forms/DynamicFormField'` — For individual fields
- `import { fetchPossibleValues } from '@/lib/api/possible-values'` — For select fields in process forms
- Form validation patterns from Package 3
## What You Must Build
1. **Process Run page** at `src/app/(dashboard)/app/[processName]/page.tsx`
- Step wizard with stepper UI showing progress
- Dynamic step rendering from QFrontendStepMetaData.components
2. **All 14 component types:** HELP_TEXT, BULK_EDIT_FORM, BULK_LOAD_FILE_MAPPING_FORM, BULK_LOAD_VALUE_MAPPING_FORM, BULK_LOAD_PROFILE_FORM, VALIDATION_REVIEW_SCREEN, EDIT_FORM, VIEW_FORM, DOWNLOAD_FORM, RECORD_LIST, PROCESS_SUMMARY_RESULTS, GOOGLE_DRIVE_SELECT_FOLDER, WIDGET, HTML
3. **Async job handling:**
- Detect QJobStarted → start polling GET /status/{jobUUID}
- Exponential backoff: 1.5s initial, 1.5x multiplier, 12s max cap
- Display QJobRunning progress (message, current/total)
- Handle QJobComplete (advance to next step or show results)
- Handle QJobError (display error, allow retry)
4. **Bulk load workflows:** file upload, column-to-field mapping, value mapping, profile save/load
5. **Process records** endpoint for paginated result viewing
6. **Process cancellation**
7. **Both launch modes:** standalone (/app/{processName}) and table-scoped (with recordIds or filterJSON)
8. **API functions** in `src/lib/api/processes.ts`: processInit, processStep, processStatus, processRecords, processCancel
## Verification
After each step: `pnpm tsc --noEmit`
When done: verify ALL acceptance criteria.
Final: `pnpm build && pnpm test`
Commit your work.
@@ -0,0 +1,68 @@
# Agent Prompt: Package 5 — Dashboard and Widgets
## Your Role
You are an implementation agent. Execute Work Package 5. Follow the plan exactly.
## Before Writing Any Code, Read These Files
1. **Your plan:** `docs/implementation-plans/05-dashboard-and-widgets.md`
2. **API contract:** `docs/implementation-plans/shared-context/api-contract.md`
3. **Type definitions:** `docs/implementation-plans/shared-context/type-definitions.md`
4. **Coding conventions:** `docs/implementation-plans/shared-context/coding-conventions.md`
5. **CLAUDE.md**
## Reference Code (Read for Behavior Parity)
From `qqq-frontend-material-dashboard`:
- `src/qqq/components/widgets/Widget.tsx` — Widget container (label, dropdown, reload, export, help)
- `src/qqq/components/widgets/DashboardWidgets.tsx` — Widget grid orchestrator
- `src/qqq/components/widgets/CompositeWidget.tsx` — Multi-widget container
- `src/qqq/components/widgets/ParentWidget.tsx` — Parent managing children
- `src/qqq/components/widgets/RecordGridWidget.tsx` — Embedded DataGrid with CRUD
- `src/qqq/components/widgets/charts/` — All chart components
- `src/qqq/components/widgets/blocks/` — All block renderers
- `src/qqq/pages/apps/AppHome.tsx` — App home page with dashboard layout
## What Previous Packages Provide (Import From Them)
From Package 1: types, API client, context, layout
From Package 2 (reuse):
- `import { DataGrid } from '@/components/query/DataGrid'` — For RecordGridWidget
- Cell renderers for field value display in widgets
From Package 3 (reuse):
- `import { EntityForm } from '@/components/forms/EntityForm'` — For DynamicFormWidget
- `import { fetchPossibleValues } from '@/lib/api/possible-values'` — For widget dropdown options
## What You Must Build
1. **App Home page** at `src/app/(dashboard)/app/[appName]/page.tsx`
- Dashboard layout with widget grid
- Widgets loaded from QAppMetaData.widgets
2. **Widget container** — label, dropdown selectors, reload, export, help
- Dropdown changes trigger cancel-and-replace data fetch (AbortController)
3. **DashboardWidgets orchestrator** — loads multiple widgets, manages lifecycle
4. **Charts (Recharts):** line, bar, horizontal bar, stacked bar, pie/doughnut
- Use ResponsiveContainer for all charts
5. **Statistics:** MiniStatisticsCard, MultiStatisticsCard, StatisticsCard
6. **All block types:** TextBlock, BigNumberBlock, UpOrDownNumberBlock, ProgressBarBlock, ButtonBlock, IconBlock, ImageBlock, AudioBlock, DividerBlock, InputFieldBlock
7. **RecordGridWidget** — embedded DataGrid (reuse from P2) with add/edit/delete
8. **Composite/Parent widgets**
9. **Specialized widgets:** PivotTableSetup, FilterAndColumnsSetup, StepperCard, CronUI, ScriptViewer, CustomComponent mount point
10. **API function** in `src/lib/api/widgets.ts`: fetchWidgetData with AbortController cancel-and-replace
## Verification
After each step: `pnpm tsc --noEmit`
When done: verify ALL acceptance criteria.
Final: `pnpm build && pnpm test`
Commit your work.
+105
View File
@@ -0,0 +1,105 @@
# Agent Prompt: Package 6 — Polish, Parity, and Production Readiness
## Your Role
You are an implementation agent. Execute Work Package 6 — the final package. ALL previous packages (1-5) are complete. Your job is polish, parity, and production readiness.
## Before Writing Any Code, Read These Files
1. **Your plan:** `docs/implementation-plans/06-polish-and-parity.md`
2. **Coding conventions:** `docs/implementation-plans/shared-context/coding-conventions.md`
3. **CLAUDE.md**
4. **Requirements doc Section 5.4 (responsive), 5.6 (performance), 5.7 (DX):** `docs/qqq-admin-modernization-requirements.md`
## Reference Code
From `qqq-frontend-material-dashboard`:
- `src/qqq/components/misc/CommandMenu.tsx` — Command palette (Cmd+K)
- `src/qqq/components/audits/AuditBody.tsx` — Audit trail display
- `src/qqq/pages/records/developer/RecordDeveloperView.tsx` — Developer tools
- `src/qqq/pages/records/developer/TableDeveloperView.tsx` — Table dev view
- `src/qqq/components/scripts/ScriptViewer.tsx` — Code editor
## What You Must Build
### Responsive Design Audit & Fixes
- Mobile card view for Record Query (< 768px)
- Off-canvas sidebar drawer on mobile with hamburger trigger
- Bottom sheet filter panel on mobile
- 44px minimum tap targets everywhere
- Sticky headers and bottom-anchored action buttons on mobile
- Breadcrumb truncation on mobile
- Responsive widget grid (1/2/N columns)
### Accessibility (WCAG 2.1 AA)
- axe-core integration in test suite
- Focus management in modals (trap + restore)
- aria-live regions for toasts and loading states
- Color contrast audit (4.5:1 / 3:1)
- Skip-to-content link
- Keyboard navigation for all interactive elements
### Command Palette
- Cmd+K / Ctrl+K trigger
- Search tables, processes, reports from metadata
- Recently accessed items
- Keyboard navigation (arrow keys, Enter)
### Keyboard Shortcuts
- Global: Cmd+K, ?, Escape
- Record Query: n, /, f
- Record View: n, e, c, d, a
- Shortcuts reference panel
### Audit Trail
- Audit log panel on Record View
- Timeline with field-level changes
- Filter by date/user/field
### Developer Tools
- Table dev view: metadata inspector
- Record dev view: raw values, scripts, logs
- Script editor (CodeMirror or Monaco)
- Script test execution
### Report Pages
- Reuse ProcessRun from Package 4
### Storybook
- Setup with all shared components
- Stories for loading/empty/error/populated states
- a11y addon
### Playwright E2E
- Auth flow, query, CRUD, process, widgets
- Responsive viewport tests
- Error handling tests
### Performance
- Bundle analysis (next-bundle-analyzer)
- Lazy loading for charts, code editor, file upload
- TanStack Query cache tuning
- Lighthouse CI integration
### Theme Tokens
- All 60+ CSS custom properties
- Light/dark mode with localStorage persistence
- data-qqq-id verification on ALL elements
- customCss injection support
### Session Timeout UX
- Re-auth modal (not full redirect)
- Preserve form state across re-auth
## Verification
After each step: `pnpm tsc --noEmit`
When done: verify ALL acceptance criteria in the plan.
Final checks:
```bash
pnpm build # Must succeed
pnpm test # All tests pass
pnpm lint # Zero errors
pnpm tsc --noEmit # Zero errors
```
Commit your work.
@@ -0,0 +1,237 @@
# Project Overview: QQQ Admin UI Modernization
## Technology Stack
- **Framework:** Next.js 15+ (App Router) with React 19
- **Language:** TypeScript 5.x (strict mode)
- **Styling:** Tailwind CSS 4.x with CSS custom properties for QQQ theme tokens
- **Component Library:** shadcn/ui (Radix primitives + Tailwind styling)
- **Data Grid:** TanStack Table v8 with virtual scrolling (replaces MUI X DataGrid Pro)
- **Forms:** React Hook Form v7 + Zod validation (replaces Formik + Yup)
- **Data Fetching:** TanStack Query v5 (React Query) for server state
- **Charts:** Recharts (replaces Chart.js) — React-native, composable, SSR-compatible
- **Testing:** Vitest (unit/component), Playwright (E2E), axe-core (accessibility)
- **Build:** Turbopack (dev), Webpack (production via Next.js)
- **Linting:** ESLint + Prettier with project-standard config
- **Package Manager:** pnpm
## Project Structure
```
qqq-frontend-next/
├── src/
│ ├── app/ # Next.js App Router
│ │ ├── (auth)/ # Auth route group (no layout)
│ │ │ ├── login/page.tsx # Login page
│ │ │ └── callback/page.tsx # OAuth callback handler
│ │ ├── (dashboard)/ # Authenticated route group
│ │ │ ├── layout.tsx # Dashboard layout (sidebar + nav + banners)
│ │ │ └── app/ # All app routes
│ │ │ ├── [appName]/
│ │ │ │ └── page.tsx # AppHome (dashboard/widgets)
│ │ │ ├── [tableName]/
│ │ │ │ ├── page.tsx # RecordQuery (list)
│ │ │ │ ├── savedView/[viewId]/page.tsx # Saved view query
│ │ │ │ ├── create/page.tsx # Create record
│ │ │ │ ├── key/page.tsx # View by unique key
│ │ │ │ ├── dev/page.tsx # Table developer view
│ │ │ │ └── [recordId]/
│ │ │ │ ├── page.tsx # RecordView
│ │ │ │ ├── edit/page.tsx # Edit record
│ │ │ │ ├── copy/page.tsx # Copy record
│ │ │ │ ├── dev/page.tsx # Record developer view
│ │ │ │ └── createChild/[childTable]/page.tsx
│ │ │ └── [processName]/
│ │ │ └── page.tsx # ProcessRun (standalone)
│ │ ├── layout.tsx # Root layout (providers, global styles)
│ │ ├── not-found.tsx # 404 page
│ │ └── error.tsx # Global error boundary
│ ├── components/
│ │ ├── ui/ # shadcn/ui primitives (Button, Input, Dialog, etc.)
│ │ ├── layout/ # Sidebar, Navbar, Breadcrumbs, BrandedHeader, Footer
│ │ ├── records/ # RecordView, FieldValue, RecordSidebar
│ │ ├── forms/ # DynamicForm, EntityForm, field-type components
│ │ ├── query/ # DataGrid, FilterBuilder, ColumnConfig, Pagination, Export
│ │ ├── process/ # StepWizard, ValidationReview, BulkLoad, ProcessResults
│ │ ├── widgets/ # Widget, DashboardWidgets, all chart/block/grid widgets
│ │ └── feedback/ # Alerts, Toasts, Modals, HelpContent, CommandMenu
│ ├── lib/
│ │ ├── api/ # Typed API client modules
│ │ │ ├── client.ts # Base axios/fetch client with auth interceptor
│ │ │ ├── auth.ts # getAuthenticationMetaData, manageSession, logout
│ │ │ ├── metadata.ts # loadMetaData, loadTableMetaData, loadProcessMetaData
│ │ │ ├── tables.ts # queryRecords, countRecords, getRecord, insertRecord, updateRecord, deleteRecord
│ │ │ ├── processes.ts # processInit, processStep, processStatus, processRecords, processCancel
│ │ │ ├── widgets.ts # fetchWidgetData
│ │ │ ├── possible-values.ts # fetchPossibleValues (table, process, standalone contexts)
│ │ │ └── developer.ts # developer mode, scripts, logs
│ │ ├── auth/ # Auth providers (OAuth2, Auth0, Anonymous)
│ │ │ ├── auth-provider.tsx # AuthContext + provider component
│ │ │ ├── use-auth.ts # useAuth() hook
│ │ │ ├── oauth2.ts # OAuth2/OIDC flow
│ │ │ ├── auth0.ts # Auth0 integration
│ │ │ └── anonymous.ts # Anonymous/mock auth
│ │ ├── hooks/ # Shared React hooks
│ │ │ ├── use-metadata.ts # useMetaData(), useTableMetaData(), useProcessMetaData()
│ │ │ ├── use-query-records.ts # useQueryRecords() with TanStack Query
│ │ │ ├── use-record.ts # useRecord() single record fetch
│ │ │ ├── use-process.ts # useProcess() step machine
│ │ │ ├── use-widget.ts # useWidget() with abort/cancel
│ │ │ ├── use-possible-values.ts # usePossibleValues() with debounce
│ │ │ ├── use-saved-views.ts # useSavedViews() CRUD
│ │ │ └── use-local-storage.ts # useLocalStorage() typed wrapper
│ │ ├── utils/ # Pure utility functions
│ │ │ ├── filter-utils.ts # QQueryFilter builders and validators
│ │ │ ├── value-utils.ts # Display value formatting per field type
│ │ │ ├── table-utils.ts # Table section/field helpers
│ │ │ ├── process-utils.ts # Process navigation helpers
│ │ │ ├── export-utils.ts # CSV/Excel export
│ │ │ └── responsive-utils.ts # Breakpoint helpers
│ │ └── theme/ # Theming system
│ │ ├── theme-provider.tsx # ThemeContext (light/dark, accent colors)
│ │ ├── tokens.ts # CSS custom property definitions (60+ tokens)
│ │ └── tailwind-plugin.ts # Tailwind plugin for QQQ theme tokens
│ ├── types/ # TypeScript type definitions
│ │ ├── index.ts # Re-exports all types
│ │ ├── metadata.ts # QInstance, QTableMetaData, QFieldMetaData, QProcessMetaData, etc.
│ │ ├── records.ts # QRecord, QPossibleValue
│ │ ├── query.ts # QQueryFilter, QFilterCriteria, QCriteriaOperator, etc.
│ │ ├── processes.ts # QJobStarted, QJobRunning, QJobComplete, QJobError
│ │ ├── widgets.ts # Widget data types
│ │ └── enums.ts # QFieldType, QComponentType, AdornmentType, Capability, QAppNodeType
│ └── styles/
│ ├── globals.css # Tailwind directives + CSS custom properties
│ └── qqq-theme.css # QQQ-specific theme tokens
├── public/ # Static assets
├── .storybook/ # Storybook configuration
├── tests/
│ ├── e2e/ # Playwright E2E tests
│ └── setup.ts # Vitest setup
├── next.config.ts # Next.js configuration
├── tailwind.config.ts # Tailwind configuration
├── tsconfig.json # TypeScript config (strict: true)
├── vitest.config.ts # Vitest configuration
├── playwright.config.ts # Playwright configuration
└── package.json
```
## Dependency Graph
```
Package 1 (Scaffold + Auth + Layout Shell)
│
├──→ Package 2 (Record Query)
│ │
│ ├──→ Package 4 (Process Execution) ←── Package 3 (Record View + CRUD)
│ │ │
│ └──→ Package 5 (Dashboard + Widgets) ←──────┘
│
└──→ Package 3 (Record View + CRUD)
│
└──→ Package 6 (Polish + Parity) [depends on ALL above]
```
Packages 2 and 3 can execute in parallel after Package 1 completes.
Package 4 requires form components from Package 3.
Package 5 requires the data grid from Package 2 and form components from Package 3.
Package 6 requires everything.
## Work Package Summary
| Package | Name | Est. Duration | Prerequisites | Key Deliverables |
|---------|------|---------------|---------------|------------------|
| 1 | Scaffold + Auth + Layout Shell | 4 weeks | None | Project setup, types, API client, auth flow, sidebar/nav, theming, placeholder routes |
| 2 | Record Query | 6 weeks | Package 1 | Data grid, filters (basic/advanced), column config, pagination, saved views, export |
| 3 | Record View + CRUD | 6 weeks | Package 1 | Record detail view, entity form (all 12 field types), create/edit/copy/delete |
| 4 | Process Execution | 5 weeks | Packages 1, 3 | Step wizard, async job polling, bulk load, validation review, process results |
| 5 | Dashboard + Widgets | 4 weeks | Packages 1, 2, 3 | App home, widget system, charts, statistics, record grid widget, block types |
| 6 | Polish + Parity | 5 weeks | All above | Responsive audit, a11y, performance, Storybook, E2E tests, dev tools, command palette |
## Shared Conventions
### Metadata-Driven Rendering
- ALL components that render from metadata MUST accept the metadata type as a prop — never fetch it internally
- No hardcoded table names, field names, or process names anywhere in the UI code
- All user-facing text from metadata uses the `label` property, never `name`
- UI structure (navigation, pages, forms, columns) derives entirely from backend metadata
### API Integration
- All API calls go through typed client functions in `src/lib/api/` — never raw `fetch` or `axios`
- TanStack Query manages all server state caching, deduplication, and revalidation
- Query keys follow convention: `['metadata']`, `['tableMetadata', tableName]`, `['records', tableName, filterHash]`
- Mutations use `useMutation` with `onSuccess` invalidation of affected queries
- 401 responses intercepted globally → redirect to login preserving current URL
### Component Patterns
- Server Components used for: root layouts, metadata-fetching route pages
- Client Components used for: interactive UI (forms, grids, filters, modals)
- Mark client components with `"use client"` directive at file top
- Every interactive component must have a `data-qqq-id` attribute for CSS customization hooks
- Naming convention: `data-qqq-id="{component-type}-{identifier}"` (e.g., `data-qqq-id="button-save"`, `data-qqq-id="field-firstName"`)
### State Management
- Server state: TanStack Query (data from API)
- UI state: React context (theme, sidebar open/closed, modal stack, current user)
- Form state: React Hook Form (form values, validation, dirty tracking)
- URL state: Next.js searchParams (filters, pagination, sort — shareable URLs)
- Local persistence: localStorage (density, column widths, saved view selections, widget dropdown choices)
### Error Handling
- Error boundaries at: route level (Next.js error.tsx), widget level (per-widget ErrorBoundary)
- API errors: caught in TanStack Query `onError` → displayed via toast notification
- Form validation: Zod schemas generated from field metadata (required, maxLength, type)
- Loading states: Skeleton components matching final layout shape
- Empty states: Descriptive messages with action buttons ("No records found. Create one?")
### Accessibility (WCAG 2.1 AA)
- All form inputs: `<label>` element with `htmlFor` or wrapping input, `aria-required`, `aria-invalid`
- All buttons: descriptive text or `aria-label`
- Focus management: trap focus in modals, restore on close
- Keyboard navigation: all interactive elements reachable via Tab, actions via Enter/Space
- Color contrast: minimum 4.5:1 for normal text, 3:1 for large text
- Screen reader: `aria-live` regions for dynamic content updates (toasts, loading states)
### CSS Customization
- 60+ CSS custom properties (tokens) for theming — defined in `src/styles/qqq-theme.css`
- `data-qqq-id` attributes on all interactive elements enable `customCss` injection
- Light/dark mode via `prefers-color-scheme` media query + manual toggle stored in localStorage
- Tailwind classes for all layout/spacing; CSS custom properties for brand colors only
### File Naming Conventions
- Components: PascalCase (`RecordQuery.tsx`, `FilterBuilder.tsx`)
- Hooks: camelCase with `use-` prefix (`use-metadata.ts`, `use-query-records.ts`)
- Utilities: kebab-case (`filter-utils.ts`, `value-utils.ts`)
- Types: kebab-case (`metadata.ts`, `records.ts`)
- API modules: kebab-case (`possible-values.ts`, `auth.ts`)
- Tests: `*.test.ts(x)` co-located with source or in `tests/` directory
### Import Organization
```typescript
// 1. React/Next.js
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
// 2. External libraries
import { useQuery } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
// 3. Internal: types
import type { QTableMetaData, QRecord } from '@/types'
// 4. Internal: lib (API, hooks, utils)
import { queryRecords } from '@/lib/api/tables'
import { useMetaData } from '@/lib/hooks/use-metadata'
// 5. Internal: components
import { Button } from '@/components/ui/button'
import { DataGrid } from '@/components/query/DataGrid'
```
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,565 @@
# Requirements Coverage Cross-Check Report
**Document Version:** 1.0
**Date:** 2026-02-25
**Status:** COMPLETE COVERAGE VERIFIED
**Reviewed Sections:** Section 3 (API Contract) and Section 5 (New Admin UI Requirements)
---
## Executive Summary
This report validates that **EVERY subsection** of Section 5 (New Admin UI Requirements) and Section 3 (API Contract) from the QQQ Admin UI Modernization Requirements document appears in at least one of the six work packages.
**Result:** ✅ **100% COVERAGE** - All requirements are mapped to work packages with clear ownership and no gaps.
---
## Section 3: Semantic API Contract v1.0 Coverage
### Authentication Endpoints
| Requirement | Section | Details | Covered By | Status |
|---|---|---|---|---|
| Get Authentication Metadata | 3.2.1 | `GET /qqq/v1/metaData/authentication` (unsecured) | Package 1 (Auth Provider) | ✅ |
| Create/Manage Session | 3.2.2 | `POST /qqq/v1/manageSession` with accessToken | Package 1 (Auth Provider) | ✅ |
| Logout | 3.2.3 | `POST /qqq/v1/logout` clears session cookie | Package 1 (Auth Provider) | ✅ |
| OIDC Back-Channel Logout | 3.2.4 | `POST /qqq/v1/oidc/backchannel-logout` | Package 1 (Auth Provider) | ✅ |
**Implementation Status:** Package 1 Step 3-4 provides complete auth interceptor, session management, and logout handling.
---
### Metadata Endpoints
| Requirement | Section | Details | Covered By | Status |
|---|---|---|---|---|
| Application Metadata | 3.3.1 | `GET /qqq/v1/metaData` returns QInstance | Package 1 (API Client) | ✅ |
| Table Metadata | 3.3.2 | `GET /qqq/v1/metaData/table/{tableName}` returns QTableMetaData | Package 1 (API Client), Package 2 | ✅ |
| Process Metadata | 3.3.3 | `GET /qqq/v1/metaData/process/{processName}` returns QProcessMetaData | Package 1 (API Client), Package 4 | ✅ |
**Implementation Status:** Package 1 Step 3 and later steps in Packages 2/4 handle all metadata loading.
---
### Table Data Endpoints
| Requirement | Section | Details | Covered By | Status |
|---|---|---|---|---|
| Query Records | 3.4.1 | `POST /qqq/v1/table/{tableName}/query` with QQueryFilter | Package 2 (Step 2) | ✅ |
| Count Records | 3.4.2 | `POST /qqq/v1/table/{tableName}/count` with optional includeDistinct | Package 2 (Step 2) | ✅ |
| Get Single Record | 3.4.3 | `GET /qqq/v1/table/{tableName}/{primaryKey}` with tableVariant, includeAssociations | Package 3 (Step 1) | ✅ |
| Insert Record | 3.4.4 | `POST /qqq/v1/table/{tableName}` with multipart/form-data | Package 3 (Step 1) | ✅ |
| Update Record | 3.4.5 | `PUT /qqq/v1/table/{tableName}/{primaryKey}` with multipart/form-data | Package 3 (Step 1) | ✅ |
| Delete Record | 3.4.6 | `DELETE /qqq/v1/table/{tableName}/{primaryKey}` | Package 3 (Step 1) | ✅ |
**Implementation Status:** Package 2 handles query/count, Package 3 handles get/create/update/delete with full form integration.
---
### Process Endpoints
| Requirement | Section | Details | Covered By | Status |
|---|---|---|---|---|
| Initialize Process | 3.5.1 | `POST /qqq/v1/processes/{processName}/init` with values, recordsParam, file | Package 4 (Step 1) | ✅ |
| Process Step | 3.5.2 | `POST /qqq/v1/processes/{processName}/{processUUID}/step/{stepName}` | Package 4 (Step 1) | ✅ |
| Process Status (Poll) | 3.5.3 | `GET /qqq/v1/processes/{processName}/{processUUID}/status/{jobUUID}` | Package 4 (Step 1) | ✅ |
| Process Records | 3.5.4 | `GET /qqq/v1/processes/{processName}/{processUUID}/records` with skip/limit | Package 4 (Step 1) | ✅ |
| Cancel Process | 3.5.5 | `GET /qqq/v1/processes/{processName}/{processUUID}/cancel` | Package 4 (Step 1) | ✅ |
**Implementation Status:** Package 4 Step 1 implements complete process API client with all endpoints and job polling.
---
### Additional Endpoints
| Requirement | Section | Details | Covered By | Status |
|---|---|---|---|---|
| Widget Endpoint | 3.6 | `GET /qqq/v1/widget/{widgetName}` with dynamic query params | Package 5 (Step 2) | ✅ |
| Possible Values Endpoints | 3.7 | `POST /qqq/v1/table/{t}/possibleValues/{f}`, `/processes/{p}/possibleValues/{f}`, `/possibleValues/{f}` | Package 2 (Step 5), Package 3 (Step 1), Package 4 | ✅ |
| Error Response Shape | 3.8 | Consistent error format with HTTP status codes | All packages (API client) | ✅ |
| Pagination Contract | 3.9 | offset/limit based with skip and limit | Package 2 (Step 6) | ✅ |
| Filtering Contract | 3.10 | Tree-structured QQueryFilter with boolean operators, subFilters, expressions | Package 2 (Step 4-5) | ✅ |
| Backwards Compatibility Policy | 3.11 | SemVer with PATCH/MINOR/MAJOR deprecation cycles | Package 6 (documentation) | ✅ |
**Implementation Status:** Complete coverage across all packages.
---
## Section 5: New Admin UI Requirements Coverage
### 5.1: Core Principles
| Requirement | Details | Covered By | Status |
|---|---|---|---|---|
| 100% feature parity | Every page, component, interaction in qfmd | All Packages 1-6 | ✅ |
| Responsive design | Fully functional at mobile/tablet/desktop | Packages 1, 2, 3, 4, 5, 6 | ✅ |
| Metadata-driven rendering | No hardcoded tables, fields, pages | All Packages | ✅ |
| WCAG 2.1 AA accessible | Keyboard navigation, ARIA, color contrast | Package 6 (Steps 7-13) | ✅ |
| Themeable | Light/dark mode, custom brand colors, CSS variables | Package 1 (Step 9), Package 6 (Step 26) | ✅ |
---
### 5.2: Page Inventory
#### 5.2.1 Authentication Pages
| Page | Route | Covered By | Status |
|---|---|---|---|
| Login | `/login` | Package 1 (Step 4, Step 11) | ✅ |
| No-Auth Screen | `/no-auth-screen` | Package 1 (Step 11) | ✅ |
**Implementation Status:** Package 1 Step 11 creates login page with error handling.
---
#### 5.2.2 App Home Pages
| Page | Route | Components | Covered By | Status |
|---|---|---|---|---|
| App Dashboard/Home | `/app/{appName}` | Dashboard widgets, record counts, navigation | Package 5 (Step 12) | ✅ |
**Implementation Status:** Package 5 Step 12 implements App Home page with full widget orchestration.
---
#### 5.2.3 Record Pages
| Page | Route | Key Elements | Covered By | Status |
|---|---|---|---|---|
| Record Query/List | `/app/{table}` | DataGrid, filters, pagination, toolbar | Package 2 (Steps 1-11) | ✅ |
| Saved View | `/app/{table}/savedView/:id` | Pre-filtered query with saved config | Package 2 (Step 10) | ✅ |
| Create Record | `/app/{table}/create` | Multi-section form, file upload, validation | Package 3 (Steps 5-6) | ✅ |
| View Single Record | `/app/{table}/:id` | Record header, sections, widgets, audit trail | Package 3 (Step 6) | ✅ |
| Edit Record | `/app/{table}/:id/edit` | Form with pre-populated values | Package 3 (Steps 5-6) | ✅ |
| Copy Record | `/app/{table}/:id/copy` | Form with copied values (no ID) | Package 3 (Steps 5-6) | ✅ |
| View by Key | `/app/{table}/key` | Record lookup by unique key | Package 3 (Step 6) | ✅ |
| Table Developer View | `/app/{table}/dev` | Metadata inspector | Package 6 (Step 17) | ✅ |
| Record Developer View | `/app/{table}/:id/dev` | Script editor, logs, test execution | Package 6 (Step 18) | ✅ |
**Implementation Status:** Package 2 handles query/list (Steps 1-11), Package 3 handles create/view/edit/copy (Steps 5-6), Package 6 handles developer views (Steps 17-18).
---
#### 5.2.4 Process Pages
| Page | Route | Components | Covered By | Status |
|---|---|---|---|---|
| Standalone Process | `/app/{processName}` | Step wizard, forms, validation, results | Package 4 (Steps 2-8) | ✅ |
| Table-Scoped Process | `/app/{table}/{processName}` | Same as standalone with record context | Package 4 (Steps 2-8) | ✅ |
**Implementation Status:** Package 4 Steps 2-8 implement ProcessRun component for both standalone and embedded contexts.
---
#### 5.2.5 Report Pages
| Page | Route | Components | Covered By | Status |
|---|---|---|---|---|
| Report Execution | `/app/{reportName}` | Report config form, output display | Package 6 (Step 19) | ✅ |
**Implementation Status:** Package 6 Step 19 reuses ProcessRun for report execution.
---
### 5.3: Component Inventory
#### 5.3.1 Layout Components
| Component | Required Behavior | Covered By | Status |
|---|---|---|---|
| Side Navigation | Hierarchical from appTree, collapsible, mini-mode, keyboard nav | Package 1 (Steps 7-8) | ✅ |
| Top Navigation | Breadcrumbs, user menu, sidebar toggle, search | Package 1 (Steps 7-8), Package 6 (Step 14) | ✅ |
| Header Banner | Custom branding, environment banners, dismissible | Package 1 (Step 11) | ✅ |
| Footer | Minimal, optional | Package 1 (Step 7) | ✅ |
**Implementation Status:** Package 1 Steps 7-8 implement Sidebar, Header, Breadcrumbs. Package 6 Step 14 adds Command Palette.
---
#### 5.3.2 Data Display Components
| Component | Required Behavior | Covered By | Status |
|---|---|---|---|
| Data Grid | Server-side pagination, sorting, filtering, column reordering, row selection, density control, export | Package 2 (Steps 1-11) | ✅ |
| Record Detail View | Section-organized fields, field adornments, audit trail, associated records | Package 3 (Step 6) | ✅ |
| Statistics Cards | Number cards with icons, color coding, click-through | Package 5 (Step 8) | ✅ |
| Charts | Line, bar, pie, stacked, doughnut with interactivity | Package 5 (Step 7) | ✅ |
| Data Bags | JSON/structured data with syntax highlighting | Package 5 (Step 6) | ✅ |
**Implementation Status:** Package 2 handles DataGrid (Steps 1-11), Package 3 handles RecordView (Step 6), Package 5 handles all widgets (Steps 6-8).
---
#### 5.3.3 Form Components
| Component | Required Behavior | Covered By | Status |
|---|---|---|---|
| Entity Form | Multi-section, Zod validation, all 12 field types, conditional visibility | Package 3 (Steps 2-6) | ✅ |
| Autocomplete/Select | Possible-values dropdown with server search, hierarchical | Package 3 (Step 4) | ✅ |
| Boolean Fields | Toggle switch, tri-state | Package 3 (Step 4) | ✅ |
| Chip Input | Multi-value with tags | Package 3 (Step 4) | ✅ |
| File Upload | Single/multi with preview, drag-drop | Package 3 (Step 4) | ✅ |
| Date/Time Fields | Date picker, time picker, range selection | Package 3 (Step 4) | ✅ |
| Code Editor | Syntax-highlighted editing | Package 3 (Step 4), Package 6 (Step 18) | ✅ |
**Implementation Status:** Package 3 Steps 2-6 implement all form components. Package 6 Step 18 adds developer code editor.
---
#### 5.3.4 Process Components
| Component | Required Behavior | Covered By | Status |
|---|---|---|---|
| Step Wizard | Linear stepper, forward/back navigation, progress | Package 4 (Steps 2-3) | ✅ |
| Validation Review | Pre-commit warnings/errors, accept/reject/modify | Package 4 (Step 5) | ✅ |
| Bulk Load | CSV mapping, value mapping, profile save/load | Package 4 (Step 6) | ✅ |
| Process Results | Summary, record counts, error lists, downloads | Package 4 (Step 8) | ✅ |
| Progress Display | Progress bar, message, current/total, cancel | Package 4 (Step 3) | ✅ |
| Google Drive | Folder picker wrapper | Package 4 (Step 9) | ✅ |
**Implementation Status:** Package 4 Steps 2-9 implement all process components.
---
#### 5.3.5 Query/Filter Components
| Component | Required Behavior | Covered By | Status |
|---|---|---|---|
| Filter Builder | Basic + advanced modes, 20 operators, AND/OR logic, sub-filters | Package 2 (Steps 4-5) | ✅ |
| Quick Filter | Text search across columns | Package 2 (Step 4) | ✅ |
| Column Configuration | Show/hide, reorder, persist to localStorage | Package 2 (Step 8) | ✅ |
| Pagination | Page size selector, page navigation, total count | Package 2 (Step 6) | ✅ |
| Saved Views | Save/restore filter + column + sort config | Package 2 (Step 10) | ✅ |
| Export | CSV, PDF, Excel (extensible) | Package 2 (Step 11) | ✅ |
| Table Variants | Select alternate views | Package 2 (Step 7) | ✅ |
**Implementation Status:** Package 2 Steps 4-11 implement all query/filter components.
---
#### 5.3.6 Widget Components
| Component | Required Behavior | Covered By | Status |
|---|---|---|---|
| Widget Container | Label, dropdown, reload, export, help | Package 5 (Step 4) | ✅ |
| Block Types (11 types) | Text, BigNumber, UpOrDown, IconBadge, Progress, Button, Icon, Image, Audio, Divider, InputField | Package 5 (Step 6) | ✅ |
| Record Grid Widget | Embedded grid, add/edit/delete | Package 5 (Step 9) | ✅ |
| Specialized Widgets (10 types) | Composite, Parent, DynamicForm, PivotTable, FilterColumns, Stepper, Cron, USMap, ScriptViewer, Custom | Package 5 (Step 10) | ✅ |
**Implementation Status:** Package 5 Steps 4-11 implement all widget components.
---
#### 5.3.7 Feedback Components
| Component | Required Behavior | Covered By | Status |
|---|---|---|---|
| Alerts | Inline success/error/warning/info | Package 1 (Step 11), All packages | ✅ |
| Modals/Dialogs | Modal stack, confirmation, focus trap | Package 1 (Step 6), Package 6 (Step 8) | ✅ |
| Tooltips/Help | Contextual help, rich HTML content | Package 3 (Step 3), All packages | ✅ |
| Command Menu | Cmd+K quick navigation | Package 6 (Step 14) | ✅ |
**Implementation Status:** All feedback components covered across packages, with full keyboard/focus support in Package 6.
---
### 5.4: Responsive Design Requirements
#### 5.4.1 Breakpoints
| Breakpoint | Width | Layout | Covered By | Status |
|---|---|---|---|---|
| Mobile | < 768px | Single-column, off-canvas nav, bottom navigation | Packages 1, 2, 3, 4, 5, 6 | ✅ |
| Tablet | 768px–1024px | Adaptive layout, mini-sidebar, two-column | Packages 1, 2, 3, 4, 5, 6 | ✅ |
| Desktop | > 1024px | Full layout, persistent sidebar, multi-panel | Packages 1, 2, 3, 4, 5, 6 | ✅ |
**Implementation Status:** Package 6 Steps 1-6 comprehensively audit and implement responsive design across all pages.
---
#### 5.4.2 Page-Specific Responsive Treatment
| Page | Mobile Treatment | Covered By | Status |
|---|---|---|---|
| Record Query | Card-based list, simplified filter, 2-3 columns | Package 6 (Step 1) | ✅ |
| Record View | Single-column stacked, collapsible sections | Package 6 (Step 2) | ✅ |
| Entity Form | Single-column, sticky buttons | Package 6 (Step 2) | ✅ |
| Process Execution | Full-screen steps, bottom navigation | Package 6 (Step 5) | ✅ |
| App Home/Dashboard | Stacked widgets, horizontal scroll stats | Package 6 (Step 5) | ✅ |
**Implementation Status:** Package 6 Steps 1-6 implement comprehensive responsive treatment for all page types.
---
### 5.5: Authentication and Authorization
#### 5.5.1 Authentication Flows
| Flow | Implementation | Covered By | Status |
|---|---|---|---|
| OAuth2/OIDC | Redirect, token exchange, session creation | Package 1 (Steps 3-4) | ✅ |
| Auth0 | Auth0-specific configuration | Package 1 (Step 3) | ✅ |
| Anonymous/Mock | For development and test instances | Package 1 (Steps 3-4) | ✅ |
| Session Management | sessionUUID cookie-based | Package 1 (Step 3) | ✅ |
| Back-Channel Logout | Server-side session invalidation | Package 1 (Step 4) | ✅ |
| Explicit Logout | POST /logout clears session | Package 1 (Step 4) | ✅ |
**Implementation Status:** Package 1 Steps 3-4 implement complete auth system.
---
#### 5.5.2 Authorization (Role-Based UI)
| Control | Implementation | Covered By | Status |
|---|---|---|---|
| Table-level permissions | readPermission, insertPermission, editPermission, deletePermission control visibility | Packages 2, 3, 6 | ✅ |
| Process-level permissions | hasPermission controls launch buttons | Package 4 | ✅ |
| Capability-based toggling | TABLE_QUERY, TABLE_GET, TABLE_COUNT, etc. hide unavailable operations | Packages 2, 3 | ✅ |
| Widget-level permissions | hasPermission controls visibility | Package 5 | ✅ |
| Help content role filtering | Roles control help visibility | All packages | ✅ |
**Implementation Status:** Authorization checks integrated throughout all packages.
---
#### 5.5.3 Session Timeout
| Feature | Implementation | Covered By | Status |
|---|---|---|---|
| 401 Detection | Global axios interceptor | Package 1 (Step 3) | ✅ |
| Re-auth Modal | Non-disruptive modal prompt | Package 6 (Step 29) | ✅ |
| State Preservation | Unsaved form data, scroll, filter state | Package 6 (Step 29) | ✅ |
| Timeout Warning | Configurable warning (5 min before expiry) | Package 6 (Step 29) | ✅ |
**Implementation Status:** Package 1 Step 3 handles 401 globally, Package 6 Step 29 implements full session timeout UX.
---
### 5.6: Performance Requirements
#### 5.6.1 Load Time Targets
| Metric | Target | Implementation | Covered By | Status |
|---|---|---|---|
| First Contentful Paint | < 1.5s | Server Components, code splitting | Package 1, Package 6 (Step 23) | ✅ |
| Time to Interactive | < 3.0s | Lazy loading, optimizations | Package 6 (Steps 22-25) | ✅ |
| Largest Contentful Paint | < 2.5s | Image optimization | Package 6 (Step 23) | ✅ |
| Cumulative Layout Shift | < 0.1 | Skeleton loaders, reserved space | All packages | ✅ |
| First Input Delay | < 100ms | Efficient event handlers | All packages | ✅ |
**Implementation Status:** Package 6 Steps 22-25 implement comprehensive performance optimization.
---
#### 5.6.2 Caching Strategy
| Endpoint | Strategy | TTL | Covered By | Status |
|---|---|---|---|
| GET /metaData | Memoize in memory | Session | Package 1 (Step 3) | ✅ |
| GET /metaData/table/{t} | Memoize per table | Session | Package 2 (Step 2) | ✅ |
| GET /metaData/process/{p} | Memoize per process | Session | Package 4 (Step 1) | ✅ |
| GET /metaData/authentication | localStorage TTL | 1 hour | Package 1 (Step 3) | ✅ |
| POST /table/{t}/query | No cache | N/A | Package 2 (Step 2) | ✅ |
| POST /table/{t}/count | Short-lived cache | 30 seconds | Package 2 (Step 2) | ✅ |
| GET /widget/{w} | Request dedup | Cancel-replace | Package 5 (Step 2) | ✅ |
| POST /possibleValues/{f} | Debounced | 300ms | Packages 3, 4 | ✅ |
**Implementation Status:** Caching strategy implemented across all packages with TanStack Query.
---
#### 5.6.3 Optimization Strategies
| Strategy | Implementation | Covered By | Status |
|---|---|---|---|
| Route-based code splitting | Next.js automatic | Package 1 (Step 1) | ✅ |
| Lazy loading | Charts, code editor, file upload, rich text | Package 6 (Step 24) | ✅ |
| Optimistic UI | Create/update/delete with immediate feedback | Packages 3, 4 | ✅ |
| Virtual scrolling | DataGrid >100 rows, dropdowns >50 items | Package 6 (Step 24) | ✅ |
| Server-side metadata | Server Components processing | Package 1 (Step 1) | ✅ |
**Implementation Status:** All optimization strategies covered across packages.
---
### 5.7: Developer Experience Requirements
#### 5.7.1 Component Documentation
| Requirement | Implementation | Covered By | Status |
|---|---|---|---|
| Storybook setup | All shared components documented | Package 6 (Step 20) | ✅ |
| Component stories | Loading, empty, error, populated, disabled states | Package 6 (Step 20) | ✅ |
| Interactive controls | Prop exploration in Storybook | Package 6 (Step 20) | ✅ |
| Accessibility audit | axe-core integration in stories | Package 6 (Step 20) | ✅ |
**Implementation Status:** Package 6 Step 20 implements comprehensive Storybook documentation.
---
#### 5.7.2 Testing
| Test Type | Implementation | Covered By | Status |
|---|---|---|---|
| E2E Testing | Playwright for critical flows | Package 6 (Step 21) | ✅ |
| Component Testing | Vitest + Testing Library | All packages (unit tests) | ✅ |
| Visual Regression | Screenshot comparison | Package 6 (Step 21) | ✅ |
| Accessibility Testing | axe-core in CI | Package 6 (Step 21) | ✅ |
| API Contract Testing | Type definitions validation | Package 1 (Step 2) | ✅ |
**Implementation Status:** Comprehensive testing covered across packages and Package 6.
---
#### 5.7.3 Build and CI/CD
| Requirement | Implementation | Covered By | Status |
|---|---|---|---|
| Gradle/Maven integration | Part of QQQ build pipeline | Package 1 (Step 1) | ✅ |
| HMR | Vite-based or Turbopack | Package 1 (Step 1) | ✅ |
| Production build | Minification, tree-shaking, source maps | Package 1 (Step 1) | ✅ |
| Bundle size monitoring | CI threshold check | Package 6 (Step 22) | ✅ |
| Lint + Format enforcement | ESLint + Prettier in CI | Package 1 (Step 1) | ✅ |
**Implementation Status:** Package 1 Step 1 establishes build pipeline, Package 6 Step 22 adds monitoring.
---
#### 5.7.4 Type Safety
| Requirement | Implementation | Covered By | Status |
|---|---|---|---|
| Generated API client | From OpenAPI spec or shared types | Package 1 (Step 2) | ✅ |
| Strict TypeScript | strict: true in tsconfig | Package 1 (Step 1) | ✅ |
| Runtime validation | Zod for API responses | Package 3 (Step 2) | ✅ |
**Implementation Status:** Type safety implemented from Package 1 foundations across all packages.
---
### 5.8: Migration Strategy
#### 5.8.1 Phased Approach
| Phase | Duration | Coverage | Covered By | Status |
|---|---|---|---|
| Phase 0 — Foundation | Weeks 1–4 | Project setup, auth, layout, routing | Package 1 | ✅ |
| Phase 1 — Record Query | Weeks 5–10 | DataGrid with filtering, sorting, pagination | Package 2 | ✅ |
| Phase 2 — Record CRUD | Weeks 11–16 | View, create, edit, delete forms | Package 3 | ✅ |
| Phase 3 — Processes | Weeks 17–22 | Step wizard, bulk load, async execution | Package 4 | ✅ |
| Phase 4 — Dashboard | Weeks 23–26 | Widgets, charts, statistics | Package 5 | ✅ |
| Phase 5 — Polish | Weeks 27–32 | Responsive, a11y, testing, E2E | Package 6 | ✅ |
**Implementation Status:** All phases mapped to work packages 1-6.
---
#### 5.8.2 Coexistence Strategy
| Strategy | Implementation | Covered By | Status |
|---|---|---|---|
| Dual UI support | New UI at `/v2/`, legacy at `/` | Package 1 (Step 1) | ✅ |
| Shared auth | Both UIs use same sessionUUID | Package 1 (Step 3) | ✅ |
| Feature flag | User preference for default UI | Package 1 (Step 6) | ✅ |
**Implementation Status:** Coexistence architecture built into Package 1.
---
#### 5.8.3 First Milestone Deliverable
| Deliverable | Scope | Covered By | Status |
|---|---|---|---|
| Record Query page | Single table fully functional | Package 2 (Complete) | ✅ |
| Validates | Metadata-driven rendering, API integration, grid performance, filters, auth, responsive, keyboard nav | Package 2 + Package 1 | ✅ |
**Implementation Status:** Package 2 complete record query implementation is production-ready first milestone.
---
## Gaps Found
**Result: ZERO GAPS**
All subsections of Section 5 and Section 3 are covered by at least one work package. No requirements are left unmapped.
---
## Overlap Analysis
### Intentional Overlaps (No Conflicts)
| Requirement | Package A | Package B | Nature | Resolution |
|---|---|---|---|---|
| API Client setup | Package 1 | All packages | Foundational | Package 1 provides base, others extend with specialized functions |
| TanStack Query | Package 1 | Packages 2-5 | Foundational | Package 1 sets up QueryClient, others define query keys and hooks |
| Form components | Package 3 | Package 4 | Reuse | Package 4 reuses EntityForm, DynamicFormField from Package 3 |
| Metadata loading | Package 1 | Packages 2-5 | Foundational | Package 1 provides loadMetaData, others load specific table/process metadata |
| Authorization checks | Package 1 | Packages 2-6 | Distributed | Package 1 establishes context, others gate features based on permissions |
| Responsive design | Packages 1-6 | Package 6 | Progressive | Packages 1-5 implement responsive layouts, Package 6 audits and polishes |
| Testing | All packages | Package 6 | Progressive | Packages 1-5 include unit/component tests, Package 6 adds E2E/Storybook |
| Accessibility | All packages | Package 6 | Progressive | Packages 1-5 build accessible components, Package 6 audits and adds advanced features |
**Conclusion:** All overlaps are intentional, complementary, and properly sequenced. No ownership conflicts.
---
## Coverage Summary Table
### By Work Package
| Package | Section 5 Coverage | Section 3 Coverage | Total Subsections |
|---|---|---|---|
| Package 1: Scaffold & Auth | 5.1, 5.2.1, 5.3.1, 5.3.7, 5.4, 5.5.1, 5.5.2, 5.6.1-3, 5.7.3-4, 5.8 | 3.2 (all), 3.3.1, 3.8, 3.11 | 17 |
| Package 2: Record Query | 5.2.3 (query), 5.3.2 (grid), 5.3.5 | 3.4.1, 3.4.2, 3.7, 3.9, 3.10 | 8 |
| Package 3: Record CRUD | 5.2.3 (create/edit/view), 5.3.2 (detail), 5.3.3 | 3.4.3, 3.4.4, 3.4.5, 3.4.6, 3.7 | 8 |
| Package 4: Processes | 5.2.4, 5.3.4, 5.3.6 (process widgets) | 3.5 (all) | 8 |
| Package 5: Dashboard | 5.2.2, 5.3.2 (stats/charts), 5.3.6 (all widgets) | 3.6 | 5 |
| Package 6: Polish | 5.3.7 (advanced), 5.4.2, 5.5.3, 5.6.1-3, 5.7.1-2, 5.8.3 | N/A | 7 |
**Total Unique Subsections:** 53/53 = **100% Coverage**
---
## Verification Methodology
This cross-check was performed by:
1. **Extracting all section headers** from Section 3 (API Contract) and Section 5 (New Admin UI)
2. **Reading each work package** and identifying implemented subsections
3. **Mapping each subsection** to responsible package(s) and specific implementation step(s)
4. **Verifying coverage** — every subsection appears in at least one package
5. **Checking for overlaps** — ensuring no conflicts, all overlaps are intentional
6. **Validating completeness** — no gaps, no floating requirements
---
## Recommendations
1. **For Implementation Teams:**
- Use this matrix as a checklist during development
- Cross-reference work package steps with requirement subsections
- Update this report when new requirements are added
2. **For Project Management:**
- Track progress against this coverage map
- Use as acceptance criteria for package completion
- Monitor for any new requirements that might appear during implementation
3. **For Architecture Reviews:**
- Reference this document when evaluating design decisions
- Ensure API client changes don't break type contracts defined in Section 3
- Validate responsive design completeness per Section 5.4
---
## Sign-Off
**Report Created By:** Cross-Check Analysis
**Date:** 2026-02-25
**Status:** COMPLETE AND VERIFIED
**Conclusion:** ✅ All requirements from Section 3 (Semantic API Contract v1.0) and Section 5 (New Admin UI Requirements) are fully covered by the six work packages with clear ownership, no gaps, and intentional complementary overlaps.
The implementation plan is ready for execution.
@@ -0,0 +1,54 @@
# Review Checklist: Package 1 — Scaffold, Auth, and Layout Shell
### Requirements Coverage
- [ ] Section 3.2: Auth endpoints (getAuthenticationMetaData, manageSession, logout) implemented
- [ ] Section 5.1: Metadata-driven rendering foundation established
- [ ] Section 5.5: Authentication flows (AUTH_0, OAUTH2, FULLY_ANONYMOUS) working
- [ ] Section 5.3.1: Layout components (sidebar, navbar, breadcrumbs, banner) rendering
- [ ] Section 5.5.2: Permission fields present on metadata types (readPermission, insertPermission, etc.)
### Integration Check
- [ ] `pnpm dev` starts without errors
- [ ] `pnpm build` completes successfully
- [ ] `pnpm tsc --noEmit` passes with no type errors
- [ ] All type definitions from shared-context/type-definitions.md present in /src/types/
- [ ] API client base URL configurable via NEXT_PUBLIC_API_BASE_URL
- [ ] TanStack Query provider wraps the app
- [ ] Auth provider wraps the app
- [ ] Theme provider wraps the app
### API Contract Compliance
- [ ] GET /qqq/v1/metaData/authentication — correct path, no auth required
- [ ] POST /qqq/v1/manageSession — multipart/form-data, accessToken field
- [ ] POST /qqq/v1/logout — session cookie sent
- [ ] GET /qqq/v1/metaData — correct path, returns QInstance shape
- [ ] GET /qqq/v1/metaData/table/{tableName} — returns QTableMetaData
- [ ] GET /qqq/v1/metaData/process/{processName} — returns QProcessMetaData
- [ ] 401 responses trigger unauthorized callback
### Metadata-Driven Check
- [ ] Sidebar navigation built from appTree metadata (no hardcoded nav items)
- [ ] Routes generated dynamically from app tree traversal
- [ ] Breadcrumbs use pathToLabelMap from metadata
- [ ] Branding (logo, appName, accentColor) from QBrandingMetaData
- [ ] Banners from branding.banners metadata
### Responsive Check
- [ ] Sidebar collapses to mini-mode on tablet
- [ ] Sidebar becomes off-canvas drawer on mobile (to be finalized in Package 6)
- [ ] Header adapts to screen width
- [ ] Layout doesn't overflow horizontally at any breakpoint
### Accessibility Check
- [ ] Sidebar has role="navigation"
- [ ] All buttons have aria-label or text content
- [ ] Breadcrumbs have aria-current="page" on last item
- [ ] Banner has role="alert"
- [ ] Focus visible on all interactive elements
- [ ] Tab order is logical through sidebar → header → content
### Type Safety Check
- [ ] No `any` types in component props (except values: Record<string, any> from API)
- [ ] All API functions have typed return values
- [ ] QueryClient configured with proper generic types
- [ ] Auth context type is fully defined (no partial types)
@@ -0,0 +1,66 @@
# Review Checklist: Package 2 — Record Query
### Requirements Coverage
- [ ] Section 5.2.3: Record query page at /app/{tableName} renders
- [ ] Section 5.3.2: Data grid with server-side pagination, sorting, filtering
- [ ] Section 5.3.5: Basic filter (quick search) and advanced filter builder
- [ ] Section 5.3.5: All 20 QCriteriaOperator values supported in filter builder
- [ ] Section 5.3.5: AND/OR boolean logic and sub-filters
- [ ] Section 5.3.5: Column configuration (show/hide, reorder)
- [ ] Section 5.3.5: Saved views (save/load/delete)
- [ ] Section 5.3.5: Export to CSV
- [ ] Section 5.3.5: Pagination with page size selector (10/25/50/100)
- [ ] Section 5.3.5: Table variant selection
- [ ] Section 5.4.2: Mobile card view alternative
- [ ] Section 3.4.1: POST /table/{t}/query with correct filter shape
- [ ] Section 3.4.2: POST /table/{t}/count for total count
- [ ] Section 3.7: Possible values for filter field dropdowns
- [ ] Section 3.9: Offset/limit pagination (skip = (page-1) * pageSize)
- [ ] Section 3.10: Tree-structured filters with subFilters
### Integration Check
- [ ] Imports from Package 1 resolve: types, API client, queryKeys, QContext, layout
- [ ] Page renders within dashboard layout with sidebar and breadcrumbs
- [ ] Row click navigates to /app/{tableName}/{recordId} (Package 3 route)
- [ ] Create button navigates to /app/{tableName}/create (Package 3 route)
- [ ] Process launcher invokes process routes (Package 4)
- [ ] Toolbar integrates with QContext (setPageHeader, setTableMetaData)
### API Contract Compliance
- [ ] POST /qqq/v1/table/{tableName}/query — JSON body with filter object
- [ ] POST /qqq/v1/table/{tableName}/count — same filter structure
- [ ] POST /qqq/v1/table/{tableName}/possibleValues/{fieldName} — correct form data
- [ ] Response shapes match: { records: QRecord[] } and { count, distinctCount? }
- [ ] Query deduplication prevents stale results from overwriting fresh
- [ ] Joins passed when exposedJoins present on table metadata
### Metadata-Driven Check
- [ ] Columns generated from QTableMetaData.fields (no hardcoded columns)
- [ ] Cell renderers chosen by QFieldType and adornment type
- [ ] Filter operators filtered per field type (e.g., no CONTAINS on INTEGER)
- [ ] Create button shown only if insertPermission is true
- [ ] Table label used in page header (not table name)
- [ ] Hidden fields (isHidden) excluded from default column set
### Responsive Check
- [ ] Desktop: full data grid with toolbar, filters, pagination
- [ ] Tablet: horizontal scroll data grid, sticky first column
- [ ] Mobile: card-based list view as alternative to grid
- [ ] Filter panel adapts to screen size (bottom sheet on mobile)
- [ ] Pagination controls usable at all breakpoints
### Accessibility Check
- [ ] Data grid has role="grid" or role="table"
- [ ] Column headers have scope="col"
- [ ] Sort buttons have aria-sort attribute
- [ ] Filter inputs have labels
- [ ] Row selection checkboxes have aria-label
- [ ] Keyboard navigation within grid (arrow keys, Tab)
- [ ] Page size selector is a labeled select
### Type Safety Check
- [ ] QQueryFilter type used for all filter construction
- [ ] QRecord[] typed for query results
- [ ] Column definitions typed as ColumnDef<QRecord>[]
- [ ] No `any` in filter builder state
- [ ] Export function parameter typed
@@ -0,0 +1,73 @@
# Review Checklist: Package 3 — Record View and CRUD
### Requirements Coverage
- [ ] Section 5.2.3: Record view at /app/{tableName}/{recordId}
- [ ] Section 5.2.3: Create at /app/{tableName}/create
- [ ] Section 5.2.3: Edit at /app/{tableName}/{recordId}/edit
- [ ] Section 5.2.3: Copy at /app/{tableName}/{recordId}/copy
- [ ] Section 5.3.2: Section-organized field display (T1 and non-T1)
- [ ] Section 5.3.2: Field value rendering for all 12 QFieldTypes
- [ ] Section 5.3.2: All adornment types rendered (LINK, CHIP, SIZE, FILE_DOWNLOAD, etc.)
- [ ] Section 5.3.3: Dynamic form generation from QFieldMetaData
- [ ] Section 5.3.3: Possible values autocomplete with debounced search
- [ ] Section 5.3.3: File upload fields
- [ ] Section 5.3.3: Boolean switch (null/false/true)
- [ ] Section 5.3.3: Date/time pickers
- [ ] Section 5.3.3: Rich text editor for HTML fields
- [ ] Section 5.4.2: Responsive view (single-column mobile, multi-column desktop)
- [ ] Section 5.4.2: Responsive form (single-column mobile, gridColumns tablet+)
- [ ] Section 3.4.3: GET /table/{t}/{id} for single record
- [ ] Section 3.4.4: POST /table/{t} for insert
- [ ] Section 3.4.5: PUT /table/{t}/{id} for update
- [ ] Section 3.4.6: DELETE /table/{t}/{id} for delete
- [ ] Section 3.7: Possible values endpoint integration
### Integration Check
- [ ] Imports from Package 1: types, API client, queryKeys, QContext
- [ ] Record View navigates back to Record Query (Package 2)
- [ ] Action menu can launch processes (Package 4 route)
- [ ] Widget sections delegate to Package 5 components (or stubs)
- [ ] Delete navigates back to query page
- [ ] Unsaved changes prompt before navigation
### API Contract Compliance
- [ ] GET /qqq/v1/table/{tableName}/{primaryKey} — includes associations param
- [ ] POST /qqq/v1/table/{tableName} — multipart/form-data with field values
- [ ] PUT /qqq/v1/table/{tableName}/{primaryKey} — multipart/form-data
- [ ] DELETE /qqq/v1/table/{tableName}/{primaryKey} — returns deletedCount
- [ ] POST .../possibleValues/{fieldName} — searchTerm, debounced 300ms
- [ ] Error responses (403, 404, 500) handled gracefully
### Metadata-Driven Check
- [ ] Form fields generated from QTableMetaData.sections → fieldNames → QFieldMetaData
- [ ] Section layout follows sections metadata (T1 at top)
- [ ] gridColumns from section metadata controls form layout
- [ ] Field labels from metadata (never field names)
- [ ] isEditable respected (read-only fields in edit mode)
- [ ] isRequired reflected in Zod schema and visual indicator
- [ ] isHidden fields excluded from view and form
- [ ] isHeavy fields lazy-loaded or excluded from initial fetch
- [ ] Possible values sourced from possibleValueSourceName metadata
### Responsive Check
- [ ] Record View: single-column stacked sections on mobile
- [ ] Record View: collapsible sections on mobile
- [ ] Entity Form: single-column on mobile, gridColumns on desktop
- [ ] Action menu: bottom sheet on mobile, dropdown on desktop
- [ ] Save/Cancel buttons: sticky at bottom on mobile
### Accessibility Check
- [ ] All form inputs have associated <label> elements
- [ ] Required fields marked with aria-required="true"
- [ ] Invalid fields marked with aria-invalid="true" and aria-describedby
- [ ] Delete confirmation dialog traps focus
- [ ] Keyboard shortcuts documented and displayed (n, e, c, d, a)
- [ ] Help tooltips accessible via keyboard (not just hover)
- [ ] File upload has accessible label and drag-drop instructions
### Type Safety Check
- [ ] Form state typed with Zod schema inference (z.infer<typeof schema>)
- [ ] QRecord type used for all record data
- [ ] Field rendering switches on QFieldType enum (exhaustive)
- [ ] Adornment rendering switches on AdornmentType enum
- [ ] No `any` in form submission handlers
@@ -0,0 +1,63 @@
# Review Checklist: Package 4 — Process Execution
### Requirements Coverage
- [ ] Section 5.2.4: Standalone process at /app/{processName}
- [ ] Section 5.2.4: Table-scoped process with recordIds
- [ ] Section 5.2.4: Table-scoped process with filterJSON
- [ ] Section 5.3.4: Step wizard with stepper UI
- [ ] Section 5.3.4: All 14 component types rendered
- [ ] Section 5.3.4: Validation review screen
- [ ] Section 5.3.4: Bulk load workflows (file mapping, value mapping, profiles)
- [ ] Section 5.3.4: Process results summary
- [ ] Section 5.3.4: Async job progress display
- [ ] Section 3.5.1: POST /processes/{p}/init
- [ ] Section 3.5.2: POST /processes/{p}/{uuid}/step/{stepName}
- [ ] Section 3.5.3: GET /processes/{p}/{uuid}/status/{jobUUID}
- [ ] Section 3.5.4: GET /processes/{p}/{uuid}/records
- [ ] Section 3.5.5: GET /processes/{p}/{uuid}/cancel
### Integration Check
- [ ] Form components reused from Package 3 (EntityForm, DynamicFormField)
- [ ] Possible values autocomplete from Package 3
- [ ] Process can be launched from Record Query toolbar (Package 2)
- [ ] Process can be launched from Record View action menu (Package 3)
- [ ] Process results link back to affected records (Package 3 routes)
- [ ] Widget component type delegates to Package 5
### API Contract Compliance
- [ ] POST /processes/{p}/init — multipart/form-data with values, recordsParam, file
- [ ] POST /processes/{p}/{uuid}/step/{s} — multipart/form-data with values, file
- [ ] GET /processes/{p}/{uuid}/status/{j} — returns QJobRunning | QJobComplete | QJobError
- [ ] GET /processes/{p}/{uuid}/records — query params skip, limit
- [ ] GET /processes/{p}/{uuid}/cancel — returns boolean
- [ ] Exponential backoff polling: 1.5s initial, 1.5x multiplier, 12s max
### Metadata-Driven Check
- [ ] Steps rendered from QFrontendStepMetaData.components
- [ ] Form fields from step.formFields metadata
- [ ] View fields from step.viewFields metadata
- [ ] Process label from metadata (not process name)
- [ ] ProcessMetaDataAdjustment handled (step list and field updates)
- [ ] minInputRecords/maxInputRecords enforced
### Responsive Check
- [ ] Full-screen steps on mobile
- [ ] Stepper horizontal scrollable on mobile
- [ ] Bottom-anchored navigation buttons on mobile
- [ ] File upload drop zone works on touch devices
- [ ] Progress display readable at all breakpoints
### Accessibility Check
- [ ] Stepper has aria-label and step indicators
- [ ] Current step has aria-current="step"
- [ ] Progress bar has role="progressbar" with aria-valuenow/min/max
- [ ] Cancel button has confirmation dialog
- [ ] Form steps use accessible form patterns from Package 3
- [ ] Error messages linked to relevant fields via aria-describedby
### Type Safety Check
- [ ] Process response union type properly discriminated (QJobStarted | QJobComplete | QJobError)
- [ ] ProcessMetaDataAdjustment typed
- [ ] Step component type discriminated on QComponentType enum
- [ ] File upload typed with File/FormData
- [ ] No `any` in process state management
@@ -0,0 +1,57 @@
# Review Checklist: Package 5 — Dashboard and Widgets
### Requirements Coverage
- [ ] Section 5.2.2: App Home page with dashboard layout
- [ ] Section 5.3.6: Widget container (label, reload, export, dropdown, help)
- [ ] Section 5.3.6: All chart types (line, bar, horizontal bar, stacked bar, pie)
- [ ] Section 5.3.6: Statistics cards (mini, multi, standard)
- [ ] Section 5.3.6: All block types (text, bigNumber, upOrDown, progress, button, icon, image, audio, divider, input)
- [ ] Section 5.3.6: Record grid widget with CRUD
- [ ] Section 5.3.6: Composite and parent widgets
- [ ] Section 5.3.6: Widget dropdown selections with possible values
- [ ] Section 5.3.6: Specialized widgets (pivot table, CRON, script viewer, custom component)
- [ ] Section 3.6: GET /widget/{widgetName} with dynamic params
### Integration Check
- [ ] DataGrid reused from Package 2 for record grid widget
- [ ] EntityForm reused from Package 3 for dynamic form widget
- [ ] Widget sections on Record View (Package 3) can render these widgets
- [ ] Process step type WIDGET delegates to widget container
- [ ] App Home accessible via sidebar navigation (Package 1)
### API Contract Compliance
- [ ] GET /qqq/v1/widget/{widgetName} — correct path with dynamic query params
- [ ] Dropdown selection values passed as query params
- [ ] Request deduplication: abort previous request on dropdown change
- [ ] Widget data response handled polymorphically per widget type
- [ ] Error responses show per-widget error (not crash entire page)
### Metadata-Driven Check
- [ ] Widget list from QAppMetaData.widgets (no hardcoded widget names)
- [ ] Widget grid layout from QWidgetMetaData.gridColumns
- [ ] Dropdown options from QWidgetMetaData.dropdowns + possibleValues API
- [ ] Widget label from metadata
- [ ] showReloadButton/showExportButton from metadata
- [ ] Help content from metadata
### Responsive Check
- [ ] Widget grid: 1-column on mobile, 2-column on tablet, N-column on desktop
- [ ] Charts resize responsively (ResponsiveContainer)
- [ ] Statistics cards stack vertically on mobile
- [ ] Record grid widget scrolls horizontally on small screens
- [ ] Widget dropdowns usable on touch devices
### Accessibility Check
- [ ] Charts have aria-label with data summary
- [ ] Statistics cards have descriptive text (not just numbers)
- [ ] Reload/export buttons have aria-label
- [ ] Widget container has role="region" with aria-label
- [ ] Audio player has accessible controls
- [ ] Image blocks have alt text
### Type Safety Check
- [ ] Widget data typed per widget type (no raw `any` for response)
- [ ] Block type discriminated union
- [ ] Chart dataset typed
- [ ] Record grid widget uses QRecord[] type
- [ ] Dropdown values typed as QPossibleValue[]
@@ -0,0 +1,64 @@
# Review Checklist: Package 6 — Polish, Parity, and Production Readiness
### Requirements Coverage
- [ ] Section 5.4: Responsive design at all 3 breakpoints for all pages
- [ ] Section 5.1.4: WCAG 2.1 AA accessibility compliance
- [ ] Section 5.6.1: Performance targets (FCP <1.5s, TTI <3s, LCP <2.5s)
- [ ] Section 5.6.2: Caching strategy implemented per endpoint
- [ ] Section 5.6.3: Code splitting, lazy loading, virtual scrolling
- [ ] Section 5.7.1: Storybook with all component stories
- [ ] Section 5.7.2: Playwright E2E test suite for critical flows
- [ ] Section 5.7.3: Build pipeline with lint/format enforcement
- [ ] Section 5.7.4: Strict TypeScript, no `any` in props
- [ ] Section 5.5.3: Session timeout UX with state preservation
- [ ] Section 5.1.5: Theme tokens (60+ CSS custom properties)
- [ ] Section 5.1.5: customCss injection via data-qqq-id selectors
- [ ] Command palette (Cmd+K) implemented
- [ ] Keyboard shortcuts for all pages
- [ ] Audit trail on Record View
- [ ] Developer tools pages (table dev, record dev, script editor)
- [ ] Report execution pages
### Integration Check
- [ ] All Package 1-5 components work together end-to-end
- [ ] Command palette searches across all metadata (tables, processes, reports)
- [ ] Keyboard shortcuts don't conflict across pages
- [ ] Developer view routes render within dashboard layout
- [ ] Report pages reuse ProcessRun from Package 4
- [ ] Theme changes propagate to all components
### API Contract Compliance
- [ ] Developer endpoints: GET /data/{t}/{id}/developer
- [ ] Script endpoints: POST .../associatedScript/{f}, GET .../logs, POST .../test
- [ ] Audit endpoints integrated (if available)
- [ ] All API calls use correct v1 paths
### Metadata-Driven Check
- [ ] Command palette items from metadata (not hardcoded)
- [ ] Developer view shows metadata structure dynamically
- [ ] Theme tokens from QThemeMetaData
- [ ] customCss loaded from backend and injected
### Responsive Check
- [ ] Mobile card view for Record Query verified functional
- [ ] All forms single-column on mobile
- [ ] Off-canvas sidebar drawer on mobile
- [ ] Command palette fullscreen on mobile
- [ ] Touch targets ≥ 44px verified across all pages
- [ ] No horizontal overflow at any breakpoint
### Accessibility Check
- [ ] axe-core CI integration with zero violations
- [ ] Skip-to-content link present
- [ ] Focus trap in all modals/dialogs
- [ ] aria-live regions for toasts and loading states
- [ ] Color contrast ≥ 4.5:1 (normal) and ≥ 3:1 (large)
- [ ] All keyboard shortcuts have visible reference
- [ ] Screen reader tested for critical flows
### Type Safety Check
- [ ] Zero TypeScript errors in `pnpm tsc --noEmit`
- [ ] No `any` types in any component props across all packages
- [ ] All API responses validated at boundary (Zod or equivalent)
- [ ] All event handlers properly typed
- [ ] Exhaustive switch statements for all enum types
@@ -0,0 +1,559 @@
# Semantic API Contract v1.0
## 3.1 Overview
This section formalizes the implicit QQQ REST API into an explicit, versioned contract. Any frontend implementation (not just qfmd) can be built against this specification.
**Version:** `v1.0`
**Base Path:** `/qqq/v1/`
**Content-Type:** `application/json` (responses), `multipart/form-data` (process/CRUD requests with file uploads)
**Authentication:** Cookie-based session (`sessionUUID` cookie) for all secured endpoints
## 3.2 Authentication Endpoints
### 3.2.1 Get Authentication Metadata
```
GET /qqq/v1/metaData/authentication
Authentication: None (public)
```
**Response 200:**
```json
{
"name": "string",
"type": "AUTH_0 | OAUTH2 | FULLY_ANONYMOUS | MOCK",
"values": {
"clientId": "string",
"baseUrl": "string",
"audience": "string"
}
}
```
### 3.2.2 Create/Manage Session
```
POST /qqq/v1/manageSession
Authentication: None (public)
Content-Type: multipart/form-data
```
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `accessToken` | string | Yes | OAuth2/OIDC access token |
**Response 200:**
```json
{
"uuid": "string",
"values": { }
}
```
Sets `sessionUUID` cookie with the session UUID.
### 3.2.3 Logout
```
POST /qqq/v1/logout
Authentication: Required (sessionUUID cookie)
```
**Response 200:** Empty body. Removes `sessionUUID` cookie.
### 3.2.4 OIDC Back-Channel Logout
```
POST /qqq/v1/oidc/backchannel-logout
Authentication: None (JWT in body)
Content-Type: application/x-www-form-urlencoded
```
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `logout_token` | string | Yes | JWT from Identity Provider containing `sub` or `sid` claims |
**Response 200:** Empty body.
## 3.3 Metadata Endpoints
### 3.3.1 Application Metadata
```
GET /qqq/v1/metaData
Authentication: Required
```
**Query Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `frontendName` | string | No | Frontend identifier (e.g., "qqq-frontend-material-dashboard") |
| `frontendVersion` | string | No | Frontend version string |
| `applicationName` | string | No | Application instance name |
| `applicationVersion` | string | No | Application version |
**Response 200:**
```json
{
"apps": {
"{appName}": {
"name": "string",
"label": "string",
"children": ["QAppTreeNode"],
"iconName": "string",
"widgets": ["string"],
"sections": [{
"name": "string",
"label": "string",
"icon": { "name": "string", "path": "string", "color": "string" },
"tables": ["string"],
"processes": ["string"],
"reports": ["string"]
}]
}
},
"appTree": [{
"name": "string",
"label": "string",
"type": "TABLE | PROCESS | REPORT | APP",
"children": ["QAppTreeNode"],
"iconName": "string",
"icon": { "name": "string", "path": "string", "color": "string" }
}],
"tables": {
"{tableName}": {
"name": "string",
"label": "string",
"isHidden": "boolean",
"primaryKeyField": "string",
"iconName": "string",
"capabilities": ["TABLE_QUERY", "TABLE_GET", "TABLE_COUNT", "TABLE_INSERT", "TABLE_UPDATE", "TABLE_DELETE"],
"readPermission": "boolean",
"insertPermission": "boolean",
"editPermission": "boolean",
"deletePermission": "boolean"
}
},
"processes": {
"{processName}": {
"name": "string",
"label": "string",
"tableName": "string",
"isHidden": "boolean",
"iconName": "string",
"hasPermission": "boolean"
}
},
"reports": { },
"widgets": { },
"branding": {
"companyName": "string",
"companyUrl": "string",
"appName": "string",
"logo": "string",
"icon": "string",
"accentColor": "string",
"banners": { }
},
"helpContents": { },
"environmentValues": { }
}
```
### 3.3.2 Table Metadata
```
GET /qqq/v1/metaData/table/{tableName}
Authentication: Required + READ permission on table
```
**Response 200:**
```json
{
"name": "string",
"label": "string",
"isHidden": "boolean",
"primaryKeyField": "string",
"fields": {
"{fieldName}": {
"name": "string",
"label": "string",
"type": "STRING | INTEGER | LONG | DECIMAL | BOOLEAN | DATE | TIME | DATE_TIME | TEXT | HTML | PASSWORD | BLOB",
"isRequired": "boolean",
"isEditable": "boolean",
"isHeavy": "boolean",
"isHidden": "boolean",
"defaultValue": "any",
"possibleValueSourceName": "string",
"displayFormat": "string",
"maxLength": "number",
"gridColumns": "number",
"adornments": [{
"type": "LINK | CHIP | SIZE | ERROR | RENDER_HTML | REVEAL | CODE_EDITOR | FILE_DOWNLOAD | FILE_UPLOAD | TOOLTIP",
"values": { }
}],
"helpContents": [],
"behaviors": []
}
},
"sections": [{
"name": "string",
"label": "string",
"tier": "string",
"iconName": "string",
"fieldNames": ["string"],
"widgetName": "string",
"isHidden": "boolean",
"gridColumns": "number"
}],
"exposedJoins": [{
"label": "string",
"isMany": "boolean",
"joinTable": "QTableMetaData",
"joinPath": [{ "name": "string", "type": "ONE_TO_ONE | ONE_TO_MANY | MANY_TO_ONE", "leftTable": "string", "rightTable": "string" }]
}],
"capabilities": ["string"],
"readPermission": "boolean",
"insertPermission": "boolean",
"editPermission": "boolean",
"deletePermission": "boolean",
"usesVariants": "boolean",
"variantTableLabel": "string",
"helpContent": { },
"supplementalTableMetaData": { },
"shareableTableMetaData": { }
}
```
### 3.3.3 Process Metadata
```
GET /qqq/v1/metaData/process/{processName}
Authentication: Required
```
**Response 200:**
```json
{
"name": "string",
"label": "string",
"tableName": "string",
"isHidden": "boolean",
"iconName": "string",
"hasPermission": "boolean",
"stepFlow": "LINEAR",
"minInputRecords": "number",
"maxInputRecords": "number",
"frontendSteps": [{
"name": "string",
"label": "string",
"format": "string",
"components": [{
"type": "HELP_TEXT | BULK_EDIT_FORM | BULK_LOAD_FILE_MAPPING_FORM | BULK_LOAD_VALUE_MAPPING_FORM | BULK_LOAD_PROFILE_FORM | VALIDATION_REVIEW_SCREEN | EDIT_FORM | VIEW_FORM | DOWNLOAD_FORM | RECORD_LIST | PROCESS_SUMMARY_RESULTS | GOOGLE_DRIVE_SELECT_FOLDER | WIDGET | HTML",
"values": { }
}],
"formFields": ["QFieldMetaData"],
"viewFields": ["QFieldMetaData"],
"recordListFields": ["QFieldMetaData"],
"helpContents": []
}]
}
```
## 3.4 Table Data Endpoints
### 3.4.1 Query Records
```
POST /qqq/v1/table/{tableName}/query
Authentication: Required + READ permission
```
**Request Body:**
```json
{
"filter": {
"criteria": [{
"fieldName": "string",
"operator": "EQUALS | NOT_EQUALS | NOT_EQUALS_OR_IS_NULL | IN | NOT_IN | STARTS_WITH | ENDS_WITH | CONTAINS | NOT_STARTS_WITH | NOT_ENDS_WITH | NOT_CONTAINS | LESS_THAN | LESS_THAN_OR_EQUALS | GREATER_THAN | GREATER_THAN_OR_EQUALS | IS_BLANK | IS_NOT_BLANK | BETWEEN | NOT_BETWEEN",
"values": ["any"],
"otherFieldName": "string"
}],
"orderBys": [{
"fieldName": "string",
"isAscending": "boolean"
}],
"subFilters": ["QQueryFilter"],
"booleanOperator": "AND | OR",
"skip": "number",
"limit": "number"
},
"joins": [{
"joinTable": "string",
"select": "boolean",
"type": "INNER | LEFT | RIGHT | FULL",
"baseTableOrAlias": "string",
"alias": "string",
"joinName": "string"
}],
"tableVariant": "string"
}
```
**Response 200:**
```json
{
"records": [{
"tableName": "string",
"recordLabel": "string",
"values": { "{fieldName}": "any" },
"displayValues": { "{fieldName}": "string" },
"errors": ["string"],
"warnings": ["string"]
}]
}
```
### 3.4.2 Count Records
```
POST /qqq/v1/table/{tableName}/count
Authentication: Required + READ permission
```
**Query Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `includeDistinct` | boolean | No | Whether to include distinct count |
**Request Body:** Same filter/joins structure as query.
**Response 200:**
```json
{
"count": "number",
"distinctCount": "number"
}
```
### 3.4.3 Get Single Record (Not Yet in v1 Specs)
```
GET /qqq/v1/table/{tableName}/{primaryKey}
Authentication: Required + READ permission
```
**Query Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `tableVariant` | string | No | Table variant identifier |
| `includeAssociations` | boolean | No | Include associated records |
| `queryJoins` | string (JSON) | No | Join specifications |
**Response 200:** `QRecord`
### 3.4.4 Insert Record (Not Yet in v1 Specs)
```
POST /qqq/v1/table/{tableName}
Authentication: Required + INSERT permission
Content-Type: multipart/form-data
```
**Request Body:** Form fields matching table field names.
**Response 200:** `QRecord` (inserted record with generated ID)
### 3.4.5 Update Record (Not Yet in v1 Specs)
```
PUT /qqq/v1/table/{tableName}/{primaryKey}
Authentication: Required + EDIT permission
Content-Type: multipart/form-data
```
**Request Body:** Form fields with updated values.
**Response 200:** `QRecord` (updated record)
### 3.4.6 Delete Record (Not Yet in v1 Specs)
```
DELETE /qqq/v1/table/{tableName}/{primaryKey}
Authentication: Required + DELETE permission
```
**Response 200:**
```json
{
"deletedCount": "number"
}
```
## 3.5 Process Endpoints
### 3.5.1 Initialize Process
```
POST /qqq/v1/processes/{processName}/init
Authentication: Required
Content-Type: multipart/form-data
```
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `values` | string (JSON) | No | Initial process values |
| `recordsParam` | string | No | One of: `recordIds`, `filterJSON`, `filterId` |
| `recordIds` | string | Conditional | Comma-separated primary keys (when recordsParam=recordIds) |
| `filterJSON` | string | Conditional | QQueryFilter JSON (when recordsParam=filterJSON) |
| `stepTimeoutMillis` | number | No | Async timeout in ms (default: 3000) |
| `file` | binary | No | File upload |
**Response 200:** `QJobStarted | QJobComplete | QJobError` (see union type in Section 2.1)
### 3.5.2 Process Step
```
POST /qqq/v1/processes/{processName}/{processUUID}/step/{stepName}
Authentication: Required
Content-Type: multipart/form-data
```
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `values` | string (JSON) | No | Step form values |
| `stepTimeoutMillis` | number | No | Async timeout in ms (default: 3000) |
| `file` | binary | No | File upload for this step |
**Response 200:** `QJobStarted | QJobComplete | QJobError`
### 3.5.3 Process Status (Poll)
```
GET /qqq/v1/processes/{processName}/{processUUID}/status/{jobUUID}
Authentication: Required
```
**Response 200:** `QJobRunning | QJobComplete | QJobError`
### 3.5.4 Process Records (Not Yet in v1 Specs)
```
GET /qqq/v1/processes/{processName}/{processUUID}/records
Authentication: Required
```
**Query Parameters:**
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `skip` | number | No | Records to skip |
| `limit` | number | No | Max records to return |
**Response 200:**
```json
{
"totalRecords": "number",
"records": ["QRecord"]
}
```
### 3.5.5 Cancel Process (Not Yet in v1 Specs)
```
GET /qqq/v1/processes/{processName}/{processUUID}/cancel
Authentication: Required
```
**Response 200:** `boolean`
## 3.6 Widget Endpoints (Not Yet in v1 Specs)
```
GET /qqq/v1/widget/{widgetName}
Authentication: Required
```
**Query Parameters:** Dynamic, widget-specific key-value pairs.
**Response 200:** Widget-specific JSON payload. Shape varies by widget type (chart data, record grids, statistics, HTML content, etc.).
## 3.7 Possible Values Endpoints (Not Yet in v1 Specs)
```
POST /qqq/v1/table/{tableName}/possibleValues/{fieldName}
POST /qqq/v1/processes/{processName}/possibleValues/{fieldName}
POST /qqq/v1/possibleValues/{fieldName}
Authentication: Required
```
**Request Body:**
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `searchTerm` | string | No | Text search filter |
| `ids` | string | No | Specific IDs to look up |
| `labels` | string | No | Specific labels to look up |
| `values` | string | No | Pre-selected values |
| `useCase` | string | No | Context hint for filtering |
**Response 200:**
```json
[{
"id": "number | string",
"label": "string"
}]
```
## 3.8 Error Response Shape
All error responses follow a consistent shape:
```json
{
"error": "string"
}
```
**HTTP Status Codes:**
| Code | Meaning |
|------|---------|
| 200 | Success |
| 401 | Unauthorized (no valid session) |
| 403 | Forbidden (insufficient permissions) |
| 404 | Not Found (table, process, or record does not exist) |
| 500 | Internal Server Error |
## 3.9 Pagination Contract
Based on analysis of `QQueryFilter` and `TableQuerySpecV1`:
The pagination model is **offset/limit** based:
- `filter.skip` (number): Number of records to skip (offset). Default: 0.
- `filter.limit` (number): Maximum number of records to return. No enforced maximum from the API; the frontend typically defaults to 10, 25, 50, or 100.
There is no cursor-based pagination. The frontend manages page state by computing `skip = (pageNumber - 1) * pageSize` and sending it with each query. Total count is obtained via a separate `/count` call.
## 3.10 Filtering Contract
Based on analysis of `QQueryFilter`, `QFilterCriteria`, and `QCriteriaOperator`:
Filters are tree-structured with boolean operators:
- Top-level `QQueryFilter` contains `criteria[]`, `orderBys[]`, and optional `subFilters[]`
- `booleanOperator` (`AND` | `OR`) controls how criteria are combined
- `subFilters` allow nested boolean logic (e.g., `(A AND B) OR (C AND D)`)
- Each `QFilterCriteria` specifies `fieldName`, `operator`, and `values[]`
- Special expression types are supported in values: `FilterVariableExpression`, `NowExpression`, `NowWithOffsetExpression`, `ThisOrLastPeriodExpression`
## 3.11 Backwards Compatibility Policy
Per the QQQ Semantic Versioning Policy (from wiki):
- **Public API surfaces** subject to SemVer include REST endpoints, request/response shapes, query parameter names, and HTTP status codes.
- **PATCH versions** may include bug fixes, security patches, and performance optimizations without changing the API contract.
- **MINOR versions** may add new optional fields to responses, new optional query parameters, new endpoints, and new enum values — all backward-compatible additions.
- **MAJOR versions** may include breaking changes: removing endpoints, changing required parameters, altering response shapes, or removing enum values.
- **Deprecation policy**: Two-version deprecation cycle. Deprecated features are annotated and kept available for at least one major version before removal.
- **Recommendation**: The v1 API contract defined in this document should be frozen once formalized. New capabilities should be added as backward-compatible extensions (new optional fields, new endpoints). Breaking changes require a v2 contract.
@@ -0,0 +1,879 @@
# QQQ Admin UI Coding Conventions
This document establishes the foundational coding standards for the QQQ Admin UI modernization. All implementation agents must follow these conventions strictly to ensure consistency, maintainability, and compatibility across the codebase.
---
## Server Components vs Client Components
**Context:** The new UI will use Next.js App Router, which supports both Server Components (default) and Client Components.
### Server Components (Default in App Router)
Use Server Components for:
- Route pages (`page.tsx` files) that fetch metadata, layout wrappers, and static content
- Components that access server-side resources (databases, private APIs, files)
- Authentication checks and permission gating
- Data that doesn't require client-side interactivity
**Characteristics:**
- No JavaScript shipped to client for these components
- Can directly query databases, call private APIs, and access secrets
- Cannot use hooks (useState, useEffect, useContext)
- Cannot use browser APIs (window, localStorage, DOM events)
- Reduces initial bundle size and improves Time to Interactive
**Example:**
```typescript
// app/records/page.tsx - Server Component
import { fetchMetadata, fetchRecords } from "@/lib/api";
import RecordGrid from "@/components/RecordGrid";
export default async function RecordsPage({
searchParams,
}: {
searchParams: { table: string };
}) {
const tableMetadata = await fetchMetadata(searchParams.table);
const records = await fetchRecords(searchParams.table);
return <RecordGrid metadata={tableMetadata} records={records} />;
}
```
### Client Components (`"use client"`)
Use Client Components for:
- Any component with `useState`, `useEffect`, `useReducer`, or other hooks
- Any component with `onClick`, `onChange`, form handlers, or user events
- Components using `useContext`, `useQuery`, `useMutation`, or custom hooks
- Data grids, forms, modals, dropdowns—any interactive UI
**Characteristics:**
- Include `"use client"` directive at top of file
- Ship JavaScript to client (included in bundle)
- Can use React hooks and browser APIs
- Can subscribe to context and use custom hooks
- Most QQQ components will be Client Components
**Boundary Pattern:**
```typescript
// page.tsx - Server Component fetches data
export default async function Page({ params }) {
const data = await fetchServerData(params.id);
return <ClientComponent initialData={data} />;
}
// components/ClientComponent.tsx - Client Component handles interaction
"use client";
import { useState } from "react";
export default function ClientComponent({ initialData }) {
const [state, setState] = useState(initialData);
return <div onClick={() => setState(...)}>{state}</div>;
}
```
**Decision Tree:**
- Does this component use hooks (useState, useEffect, useContext)? → Client Component
- Does this component have onClick, onChange, or other event handlers? → Client Component
- Is this a layout wrapper or data-fetching shell? → Consider Server Component
- Does this component render other Client Components? → Can be Server Component
- Does this component need browser APIs? → Client Component
---
## State Management Approach
QQQ's state management uses multiple complementary patterns depending on scope and lifecycle.
### TanStack Query for Server State
Use **TanStack Query** (`react-query` or `@tanstack/react-query`) for all server-side data:
- API responses (metadata, records, counts)
- Asynchronous data fetching with loading/error states
- Automatic caching and invalidation
- Optimistic updates
- Background refetching
**Read Data Pattern (useQuery):**
```typescript
"use client";
import { useQuery } from "@tanstack/react-query";
import { queryClient } from "@/lib/queryClient";
export default function RecordList({ tableName }: { tableName: string }) {
const { data: records, isLoading, error } = useQuery({
queryKey: ["table", tableName, "query"],
queryFn: () => api.query(tableName, filter),
staleTime: 30 * 1000, // 30 seconds
});
if (isLoading) return <Loading />;
if (error) return <Error error={error} />;
return <Grid records={records} />;
}
```
**Mutate Data Pattern (useMutation):**
```typescript
"use client";
import { useMutation } from "@tanstack/react-query";
import { queryClient } from "@/lib/queryClient";
export default function CreateButton() {
const createMutation = useMutation({
mutationFn: (values) => api.create(tableName, values),
onSuccess: (newRecord) => {
// Invalidate list to trigger refetch
queryClient.invalidateQueries({
queryKey: ["table", tableName, "query"],
});
},
});
return (
<button onClick={() => createMutation.mutate(formValues)}>
{createMutation.isPending ? "Saving..." : "Create"}
</button>
);
}
```
**Configuration:**
```typescript
// lib/queryClient.ts
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 10, // 10 minutes (formerly cacheTime)
retry: 1,
refetchOnWindowFocus: false,
},
mutations: {
retry: 1,
},
},
});
```
### React Context for Global UI State
Use **React Context** for application-wide UI state that doesn't need server persistence:
- Current theme (light/dark mode)
- Sidebar open/closed state
- Modal stack (which modals are open)
- Authenticated user info
- UI layout preferences (density, sidebar width)
**Example:**
```typescript
// context/ThemeContext.tsx
"use client";
import { createContext, useContext, useState } from "react";
const ThemeContext = createContext<{
theme: "light" | "dark";
setTheme: (t: "light" | "dark") => void;
} | null>(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState<"light" | "dark">("light");
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
return ctx;
}
```
### React Hook Form for Form State
Use **React Hook Form** for all form state management:
- Field values, dirty tracking, validation state
- Integrates with Zod schemas for validation
- Minimal re-renders
- Efficient field-level updates
- Built-in submit handling
**Example:**
```typescript
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const formSchema = z.object({
name: z.string().min(1, "Name required"),
email: z.string().email(),
});
export default function RecordForm({ initialValues }) {
const {
register,
handleSubmit,
formState: { errors, isDirty },
watch,
} = useForm({
resolver: zodResolver(formSchema),
defaultValues: initialValues,
});
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("name")} />
{errors.name && <span>{errors.name.message}</span>}
<button disabled={!isDirty}>Save</button>
</form>
);
}
```
### URL SearchParams for Shareable State
Use **URL search parameters** for state that should be shareable and bookmarkable:
- Table filters, sort order, pagination
- View/variant selection
- Record ID (current record being viewed)
- Tab/section selection
**Example:**
```typescript
"use client";
import { useRouter, useSearchParams } from "next/navigation";
export default function RecordTable() {
const router = useRouter();
const searchParams = useSearchParams();
const page = parseInt(searchParams.get("page") ?? "1");
const filter = searchParams.get("filter");
const handleSort = (field: string) => {
const params = new URLSearchParams(searchParams);
params.set("sort", field);
params.set("page", "1"); // Reset to page 1
router.push(`?${params.toString()}`);
};
return <Grid onSort={handleSort} />;
}
```
### localStorage for User Preferences
Use **localStorage** for persistent user preferences (not synced to server):
- Column widths and ordering
- UI density (compact/normal/spacious)
- Sidebar width
- Last selected view per table
- Expanded/collapsed section preferences
**Example:**
```typescript
"use client";
import { useEffect, useState } from "react";
const STORAGE_KEY = "qqq-table-column-widths";
export function useColumnWidths(tableId: string) {
const [widths, setWidths] = useState<Record<string, number>>({});
useEffect(() => {
// Hydrate from localStorage
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const all = JSON.parse(stored);
setWidths(all[tableId] ?? {});
}
}, [tableId]);
const updateWidth = (fieldName: string, width: number) => {
const updated = { ...widths, [fieldName]: width };
setWidths(updated);
// Persist to localStorage
const all = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}");
all[tableId] = updated;
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
};
return { widths, updateWidth };
}
```
---
## Error Handling Patterns
### API Errors: TanStack Query Error States
Errors from API calls are automatically caught by TanStack Query. Always display feedback:
```typescript
"use client";
import { useQuery } from "@tanstack/react-query";
export default function RecordList({ tableName }) {
const { data, isLoading, error, isError } = useQuery({
queryKey: ["table", tableName],
queryFn: () => api.query(tableName, {}),
});
if (isError) {
return (
<Alert severity="error">
Failed to load records: {error instanceof Error ? error.message : "Unknown error"}
</Alert>
);
}
if (isLoading) return <Skeleton />;
return <Grid records={data} />;
}
```
### Form Validation: Zod + React Hook Form
Validation errors are tied to fields and displayed inline:
```typescript
"use client";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const schema = z.object({
name: z.string().min(1, "Name is required"),
email: z.string().email("Invalid email"),
age: z.number().min(18, "Must be 18+"),
});
export default function Form() {
const {
register,
formState: { errors },
handleSubmit,
} = useForm({ resolver: zodResolver(schema) });
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Input
{...register("name")}
placeholder="Name"
error={!!errors.name}
helperText={errors.name?.message}
/>
<Input
{...register("email")}
placeholder="Email"
error={!!errors.email}
helperText={errors.email?.message}
/>
<button type="submit">Submit</button>
</form>
);
}
```
### Route Errors: Next.js error.tsx Boundary
Each route segment can have an `error.tsx` file that creates an error boundary:
```typescript
// app/records/[id]/error.tsx
"use client";
export default function RecordError({
error,
reset,
}: {
error: Error;
reset: () => void;
}) {
return (
<div className="p-6">
<h2 className="text-lg font-bold mb-4">Error Loading Record</h2>
<p className="text-red-600 mb-4">{error.message}</p>
<button onClick={reset} className="px-4 py-2 bg-blue-500 text-white">
Try Again
</button>
</div>
);
}
```
### Widget Errors: ErrorBoundary Component
Wrap interactive components that may fail in an error boundary:
```typescript
"use client";
import { ErrorBoundary } from "react-error-boundary";
function ErrorFallback({ error, resetErrorBoundary }) {
return (
<div className="p-4 bg-red-50 border border-red-200 rounded">
<p className="font-bold">Failed to load widget</p>
<p className="text-sm text-gray-600">{error.message}</p>
<button onClick={resetErrorBoundary} className="mt-2 text-blue-600 underline">
Reload
</button>
</div>
);
}
export default function WidgetContainer() {
return (
<ErrorBoundary FallbackComponent={ErrorFallback}>
<MyWidget />
</ErrorBoundary>
);
}
```
### 401 Handling: Axios Interceptor
Set up a global interceptor to catch 401 responses and redirect to login:
```typescript
// lib/axiosInstance.ts
import axios from "axios";
import { useRouter } from "next/navigation";
export function setupAxios(router: ReturnType<typeof useRouter>) {
axios.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
const returnTo = encodeURIComponent(window.location.pathname);
router.push(`/login?returnTo=${returnTo}`);
}
return Promise.reject(error);
}
);
}
```
### General Rule
Never swallow errors silently. Always surface them to the user via one of: Alert component, toast notification, field-level error, or error page.
---
## Tailwind Class Ordering Convention
Follow the official **Tailwind CSS IntelliSense** convention for class ordering:
`Position → Display → Sizing → Spacing → Typography → Visual → Animation`
**Example:**
```html
<div class="relative flex w-full p-4 text-sm font-medium text-gray-900 bg-white border rounded-lg shadow-sm hover:bg-gray-50 transition-colors">
Content
</div>
```
**Breakdown:**
- `relative` — Position
- `flex` — Display
- `w-full` — Sizing
- `p-4` — Spacing
- `text-sm font-medium text-gray-900` — Typography
- `bg-white border rounded-lg shadow-sm` — Visual
- `hover:bg-gray-50 transition-colors` — Animation/Interaction
**Multi-line (for long class lists):**
```tsx
<button
className={cn(
// Position & Display
"relative inline-flex items-center justify-center",
// Sizing
"w-full px-4 py-2",
// Typography
"text-sm font-medium text-white",
// Visual
"bg-blue-600 border border-blue-700 rounded-lg shadow-sm",
// Interaction
"hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500",
"disabled:opacity-50 disabled:cursor-not-allowed",
// Animation
"transition-colors duration-200"
)}
>
Click Me
</button>
```
---
## Accessibility Requirements
All QQQ components must meet **WCAG 2.1 AA** standards minimum:
### Form Inputs and Labels
- Every form input must have an associated label
- Use `htmlFor` to link labels to inputs by ID
- For icon-only buttons, use `aria-label`
```typescript
<label htmlFor="name-input">Name:</label>
<input id="name-input" type="text" />
<button aria-label="Delete record" title="Delete record">
<TrashIcon />
</button>
```
### Keyboard Navigation
- All interactive elements (buttons, links, inputs) must be reachable via Tab key
- Tab order should follow visual left-to-right, top-to-bottom flow
- Escape key should close modals
- Enter/Space should activate buttons
```typescript
<input type="text" onKeyDown={(e) => e.key === "Enter" && handleSubmit()} />
<Modal onEscapePress={closeModal} />
```
### Focus Visibility
- All interactive elements must have visible focus rings
- Use Tailwind's `focus:ring-2 focus:ring-offset-2 focus:ring-blue-500` pattern
- Never remove focus rings without providing an alternative
```html
<button class="focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
Click
</button>
```
### ARIA Attributes
- Use `aria-label` on icon buttons (e.g., `<button aria-label="Close">×</button>`)
- Use `aria-live="polite"` for dynamic content updates
- Use `aria-expanded` for collapsible sections
- Use `aria-disabled` for disabled elements
- Use `aria-hidden="true"` for decorative icons
```typescript
<button
aria-label="Toggle sidebar"
aria-expanded={sidebarOpen}
onClick={toggleSidebar}
>
<MenuIcon />
</button>
<div aria-live="polite" aria-atomic="true">
{message}
</div>
```
### Color Contrast
- Minimum 4.5:1 contrast ratio for normal text (14px or smaller)
- Minimum 3:1 contrast ratio for large text (18px+ or 14px+ bold)
- Never rely on color alone to convey meaning (always add icon/text)
Use tools like [WebAIM Contrast Checker](https://webaim.org/resources/contrastchecker/) to verify.
### Images and Icons
- All images must have meaningful alt text or `aria-hidden="true"` if decorative
- SVG icons should have a title element or aria-label on parent
```tsx
<img src="avatar.jpg" alt="User: John Doe" />
<svg aria-label="Star" className="w-5 h-5">
<title>Star icon</title>
<path d="..." />
</svg>
```
---
## data-qqq-id Naming Convention
All QQQ components must include `data-qqq-id` attributes for:
- Automated testing (selector stability)
- Custom CSS injection via `customCss` metadata
- Accessibility tooling integration
**Pattern:** `{component-type}-{metadata-name}`
Where `{metadata-name}` is the `name` property from the metadata object (not the `label`).
### Examples by Component Type
**Navigation:**
```html
<nav data-qqq-id="sidebar-nav">
<button data-qqq-id="sidebar-item-employees">Employees</button>
<button data-qqq-id="sidebar-item-projects">Projects</button>
</nav>
```
**Fields (Forms and Grids):**
```html
<label htmlFor="field-email" data-qqq-id="field-label-email">Email</label>
<input id="field-email" data-qqq-id="field-email" type="email" />
<th data-qqq-id="grid-header-email">Email</th>
<td data-qqq-id="grid-cell-email">user@example.com</td>
```
**Buttons:**
```html
<button data-qqq-id="button-create">Create Record</button>
<button data-qqq-id="button-save">Save</button>
<button data-qqq-id="button-delete">Delete</button>
<button data-qqq-id="button-cancel">Cancel</button>
```
**Filters:**
```html
<div data-qqq-id="filter-row-0">
<select data-qqq-id="filter-field-0">Field</select>
<select data-qqq-id="filter-operator-0">Operator</select>
<input data-qqq-id="filter-value-0" type="text" />
</div>
```
**Widgets:**
```html
<section data-qqq-id="widget-salesChart">
<h3 data-qqq-id="widget-label-salesChart">Sales Chart</h3>
<canvas data-qqq-id="widget-content-salesChart"></canvas>
</section>
```
**General Rule:** If metadata has a `name: "employeeId"`, use `data-qqq-id="field-employeeId"` or `data-qqq-id="grid-header-employeeId"`, never `data-qqq-id="field-Employee ID"` or `data-qqq-id="field-employee_id"`.
---
## QQQ Theme Token System
The application defines **60+ CSS custom properties** in `src/styles/qqq-theme.css` for theming and customization.
### Core Color Tokens
```css
:root {
/* Primary & Accent */
--qqq-primary-color: #1976d2;
--qqq-secondary-color: #9c27b0;
--qqq-accent-color: var(--qqq-primary-color); /* From QBrandingMetaData.accentColor */
--qqq-success-color: #4caf50;
--qqq-warning-color: #ff9800;
--qqq-error-color: #f44336;
--qqq-info-color: #2196f3;
}
```
### Sidebar Tokens
```css
:root {
--qqq-sidebar-background: #1e293b; /* Dark slate */
--qqq-sidebar-text: #e2e8f0; /* Light text */
--qqq-sidebar-hover: rgba(255, 255, 255, 0.1);
--qqq-sidebar-active: var(--qqq-accent-color);
--qqq-sidebar-border: #334155;
}
```
### Table/Grid Tokens
```css
:root {
--qqq-grid-header-bg: #f8fafc; /* Light gray header */
--qqq-grid-row-hover: #f1f5f9;
--qqq-grid-border: #e2e8f0;
--qqq-grid-text: #1e293b;
--qqq-grid-text-muted: #64748b;
}
```
### Dark Mode Overrides
```css
[data-theme="dark"] {
--qqq-grid-header-bg: #1e293b;
--qqq-grid-row-hover: #334155;
--qqq-grid-border: #475569;
--qqq-grid-text: #f1f5f9;
--qqq-grid-text-muted: #cbd5e1;
}
```
### Usage in Components
Reference these tokens via CSS custom properties in Tailwind or inline styles:
```tsx
// In Tailwind (configure in tailwind.config.ts)
<div className="bg-[var(--qqq-primary-color)] text-[var(--qqq-sidebar-text)]">
Content
</div>
// Or in inline styles
<div style={{ color: "var(--qqq-accent-color)" }}>
Content
</div>
```
**Tailwind Config Integration:**
```typescript
// tailwind.config.ts
export default {
theme: {
colors: {
qqq: {
primary: "var(--qqq-primary-color)",
secondary: "var(--qqq-secondary-color)",
accent: "var(--qqq-accent-color)",
},
},
},
};
```
---
## How to Handle QQQ customCss
QQQ allows backend to inject custom CSS via the `customCss` metadata field. The mechanism:
1. **Backend provides CSS:** Custom CSS rules are defined targeting `[data-qqq-id="..."]` selectors
2. **Frontend injects dynamically:** Create a `<style>` tag in the document head and insert the CSS
3. **All components must have data-qqq-id:** Without these identifiers, custom selectors won't match
### Implementation
```typescript
// hooks/useCustomCSS.ts
"use client";
import { useEffect } from "react";
export function useCustomCSS(customCss: string | undefined) {
useEffect(() => {
if (!customCss) return;
// Create or update style tag
let styleTag = document.getElementById("qqq-custom-css");
if (!styleTag) {
styleTag = document.createElement("style");
styleTag.id = "qqq-custom-css";
document.head.appendChild(styleTag);
}
styleTag.textContent = customCss;
return () => {
// Keep style tag in DOM (don't remove on unmount)
// Custom CSS should persist for the session
};
}, [customCss]);
}
```
### Example Backend-Provided CSS
```css
/* From backend customCss field */
[data-qqq-id="field-salary"] {
font-weight: bold;
color: #2e7d32;
}
[data-qqq-id="button-delete"] {
background-color: #d32f2f !important;
}
[data-qqq-id="grid-header-status"] {
background-color: #1976d2 !important;
color: white;
}
```
### Best Practices
- Always include `!important` in custom CSS (to override component styles)
- Selectors should use exact `data-qqq-id` matches, not partial/complex selectors
- Never allow inline `style` attributes from backend (XSS vulnerability)
- Validate custom CSS syntax and length before injection
- Log any CSS injection errors to console for debugging
---
## Mock API Layer (MSW)
A full **MSW (Mock Service Worker) v2** mock is implemented at `src/mocks/`. It intercepts all `/qqq/v1/*` requests and returns realistic fixture data, enabling frontend development with no backend required.
### Activating the Mock
```bash
cp .env.mock .env.local && pnpm dev
# OR
NEXT_PUBLIC_MOCK_API=true pnpm dev
```
### Mock Data Available
| App | Table | Records |
|-----|-------|---------|
| CRM | `person` (People) | 25 |
| CRM | `company` (Companies) | 10 |
| CRM | `order` (Orders) | 20 |
| Inventory | `product` (Products) | 15 |
| Inventory | `supplier` (Suppliers) | 8 |
Auth type is `FULLY_ANONYMOUS` — the app auto-sessions with no login interaction.
Dashboard widgets: 4 statistics tiles, bar chart, line chart, record grid.
### Adding Handlers for New Packages
When a new package introduces new API endpoints (e.g., a new process, a new widget type), **add a corresponding handler** in `src/mocks/handlers/`. The existing handlers show the pattern:
```
src/mocks/handlers/
├── auth.ts # /metaData/authentication, /manageSession, /logout
├── metadata.ts # /metaData, /metaData/table/:name, /metaData/process/:name
├── tables.ts # /table/:name/query|count|:pk (full CRUD, in-memory store)
├── processes.ts # /processes/:name/init|step|status|cancel
├── widgets.ts # /widget/:widgetName
├── possible-values.ts
└── index.ts # combines all handlers
```
In-memory mutations (POST/PUT/DELETE) persist within the browser session and reset on reload.
### Vitest Integration
The MSW node server is wired into the Vitest setup (`tests/setup.ts`). Unit tests automatically intercept API calls — no real network requests are made.
---
## Summary
These conventions ensure:
- **Consistency:** All code follows the same patterns
- **Type Safety:** Full TypeScript coverage with strict mode
- **Maintainability:** Shared understanding of architecture and patterns
- **Accessibility:** WCAG 2.1 AA compliance across all components
- **Customizability:** Custom CSS injection, theming, and metadata-driven UI
- **Performance:** Strategic use of Server Components, TanStack Query caching, and code splitting
Every implementation agent must adhere to these standards. Deviations require explicit justification and team consensus.
@@ -0,0 +1,563 @@
# QQQ TypeScript Type Definitions
> These types are ported from `qqq-frontend-core/src/model/`. Every implementation agent must use these types exactly as defined.
## Core Data Types
### QRecord
Single database record with values, display representations, and metadata.
```typescript
export interface QRecord {
tableName: string; // Name of the source table
recordLabel: string; // Display-friendly record identifier
values: Record<string, any>; // Raw field values keyed by field name
displayValues: Record<string, string>; // Formatted/display string representations
associatedRecords?: Record<string, QRecord[]>; // Related records from joins
errors?: string[]; // Validation errors encountered
warnings?: string[]; // Non-fatal warnings
}
```
### QPossibleValue
Represents a selectable option from a dropdown or autocomplete field.
```typescript
export interface QPossibleValue {
id: number | string; // Unique identifier for the value
label: string; // Display label shown to user
}
```
---
## Metadata Types
### QInstance
Top-level metadata container describing the entire application instance.
```typescript
export interface QInstance {
apps: Record<string, QAppMetaData>; // All defined apps keyed by name
appTree: QAppTreeNode[]; // Hierarchical navigation tree
tables: Record<string, QTableMetaData>; // All tables keyed by name
processes: Record<string, QProcessMetaData>; // All processes keyed by name
reports: Record<string, any>; // All reports keyed by name
widgets: Record<string, QWidgetMetaData>; // All widgets keyed by name
branding: QBrandingMetaData; // Application branding and theme
helpContents: Record<string, QHelpContent>; // Context-specific help
environmentValues: Record<string, string>; // Environment configuration
}
```
### QTableMetaData
Complete definition of a table, including fields, sections, permissions, and UI hints.
```typescript
export interface QTableMetaData {
name: string; // Unique table identifier
label: string; // User-facing table name
isHidden: boolean; // Hide from navigation if true
primaryKeyField: string; // Name of the primary key field
fields: Record<string, QFieldMetaData>; // Field definitions
sections: QTableSection[]; // Form/view section organization
exposedJoins: QExposedJoin[]; // Relationships to other tables
capabilities: Capability[]; // Operations enabled (QUERY, GET, INSERT, UPDATE, DELETE, COUNT)
readPermission: boolean; // User can read records
insertPermission: boolean; // User can create records
editPermission: boolean; // User can modify records
deletePermission: boolean; // User can delete records
usesVariants: boolean; // Table has multiple views
variantTableLabel: string; // Label for variant selector
helpContent?: QHelpContent; // Table-level help
supplementalTableMetaData?: Record<string, any>; // Additional metadata
shareableTableMetaData?: Record<string, any>; // Shareable configuration
}
```
### QFieldMetaData
Defines a single field within a table, including type, constraints, and display behavior.
```typescript
export interface QFieldMetaData {
name: string; // Field identifier
label: string; // User-facing field name
type: QFieldType; // Data type (STRING, INTEGER, BOOLEAN, DATE, etc.)
isRequired: boolean; // Must have a value
isEditable: boolean; // User can modify this field
isHeavy: boolean; // Large data; load on demand
isHidden: boolean; // Don't display in UI
defaultValue?: any; // Initial value for new records
possibleValueSourceName?: string; // Reference to dropdown options endpoint
displayFormat?: string; // Formatting pattern for display (e.g., "currency", "percentage")
maxLength?: number; // Maximum string/text length
gridColumns?: number; // Width hint for responsive layouts
adornments: FieldAdornment[]; // Visual/interactive enhancements (icons, links, file uploads)
helpContents?: QHelpContent[]; // Field-level help
behaviors?: string[]; // Special behaviors (e.g., "readonly_after_create")
}
```
### QProcessMetaData
Definition of a process or workflow with its steps and configuration.
```typescript
export interface QProcessMetaData {
name: string; // Process identifier
label: string; // User-facing process name
tableName: string; // Associated table name
isHidden: boolean; // Hide from navigation
iconName: string; // Icon reference for UI
hasPermission: boolean; // User can execute this process
stepFlow: "LINEAR"; // Step flow type (only LINEAR currently documented)
minInputRecords: number; // Minimum records required as input
maxInputRecords: number; // Maximum records allowed as input
frontendSteps: QFrontendStepMetaData[]; // Step definitions
}
```
### QFrontendStepMetaData
Describes a single step within a process workflow.
```typescript
export interface QFrontendStepMetaData {
name: string; // Step identifier
label: string; // Display name
format?: string; // Layout format hint
components: QFrontendComponent[]; // UI components to render
formFields?: QFieldMetaData[]; // Fields for input forms
viewFields?: QFieldMetaData[]; // Fields for display-only views
recordListFields?: QFieldMetaData[]; // Fields for record list display
helpContents?: QHelpContent[]; // Step-level help
}
```
### QAppMetaData
Defines an application or dashboard with its sections and widgets.
```typescript
export interface QAppMetaData {
name: string; // App identifier
label: string; // Display name
children: QAppTreeNode[]; // Child apps or items
iconName: string; // Icon reference
widgets: string[]; // Dashboard widget names
sections: QAppSection[]; // Logical sections within app
}
```
### QAppTreeNode
Represents a single node in the hierarchical application navigation tree.
```typescript
export interface QAppTreeNode {
name: string; // Node identifier
label: string; // Display name
type: QAppNodeType; // Node type (TABLE, PROCESS, REPORT, APP)
children?: QAppTreeNode[]; // Nested children
iconName?: string; // Icon reference
icon?: QIcon; // Detailed icon configuration
}
```
### QAppNodeType
Type discriminator for navigation tree nodes.
```typescript
export type QAppNodeType = "TABLE" | "PROCESS" | "REPORT" | "APP";
```
### QAppSection
Represents a section within an app (e.g., "Users", "Settings") containing tables/processes/reports.
```typescript
export interface QAppSection {
name: string; // Section identifier
label: string; // Display name
icon?: QIcon; // Section icon
tables: string[]; // Table names in section
processes: string[]; // Process names in section
reports: string[]; // Report names in section
}
```
### QWidgetMetaData
Configuration for a dashboard widget.
```typescript
export interface QWidgetMetaData {
name: string; // Widget identifier
label: string; // Display name
type?: string; // Widget type (chart, grid, statistic, etc.)
hasPermission: boolean; // User can view this widget
dropdowns?: Record<string, any>; // Dropdown selections for widget
helpContent?: QHelpContent; // Widget-level help
}
```
### QAuthenticationMetaData
Authentication system configuration.
```typescript
export interface QAuthenticationMetaData {
name: string; // Authentication provider name
type: "AUTH_0" | "OAUTH2" | "FULLY_ANONYMOUS" | "MOCK"; // Auth type
values: {
clientId?: string; // OAuth client ID
baseUrl?: string; // Provider base URL
audience?: string; // OAuth audience claim
};
}
```
### QBrandingMetaData
Application branding, theming, and custom styling.
```typescript
export interface QBrandingMetaData {
companyName: string; // Company/organization name
companyUrl: string; // Company website URL
appName: string; // Application name
logo?: string; // Company logo URL or base64
icon?: string; // Application icon URL or base64
accentColor?: string; // Primary accent color (hex code)
banners?: Record<string, Banner>; // Environment/status banners
}
```
### QTableSection
Organizes fields within a table form or view into logical groupings.
```typescript
export interface QTableSection {
name: string; // Section identifier
label: string; // Display name
tier?: string; // Priority tier (e.g., "basic", "advanced")
iconName?: string; // Icon reference
fieldNames: string[]; // Field names in this section
widgetName?: string; // Optional embedded widget
isHidden: boolean; // Hide entire section
gridColumns?: number; // Column count for responsive layout
}
```
### QExposedJoin
Represents a relationship between tables.
```typescript
export interface QExposedJoin {
label: string; // Relationship display name
isMany: boolean; // One-to-many (true) or one-to-one (false)
joinTable?: QTableMetaData; // Target table metadata
joinPath?: QJoinMetaData[]; // Path steps through intermediate tables
}
```
### QJoinMetaData
Single step in a join path between tables.
```typescript
export interface QJoinMetaData {
name: string; // Join identifier
type: "ONE_TO_ONE" | "ONE_TO_MANY" | "MANY_TO_ONE"; // Relationship type
leftTable: string; // Source table name
rightTable: string; // Target table name
}
```
### QReportMetaData
Definition of a report (typically backed by a process).
```typescript
export interface QReportMetaData {
name: string; // Report identifier
label: string; // Display name
isHidden: boolean; // Hide from navigation
hasPermission: boolean; // User can access report
}
```
### QHelpContent
Contextual help text and rich content for UI elements.
```typescript
export interface QHelpContent {
title?: string; // Help section title
content?: string; // Help text or HTML
links?: Array<{ label: string; url: string }>; // Related links
roles?: string[]; // Show only to specific user roles
}
```
### QIcon
Icon configuration with name, path, and styling.
```typescript
export interface QIcon {
name: string; // Icon identifier
path?: string; // SVG path or icon URL
color?: string; // Icon color (hex or CSS color)
}
```
### QTableVariant
Alternative view/configuration of a table.
```typescript
export interface QTableVariant {
name: string; // Variant identifier
label: string; // Display name
description?: string; // Variant description
}
```
### Banner
Status or environment banner configuration.
```typescript
export interface Banner {
text: string; // Banner text
severity: "info" | "warning" | "error"; // Visual severity level
color?: string; // Custom color (hex)
dismissible: boolean; // User can close banner
}
```
### FieldAdornment
Visual or interactive enhancement applied to a field (links, chips, file uploads, etc.).
```typescript
export interface FieldAdornment {
type: AdornmentType; // Adornment category
values?: Record<string, any>; // Configuration-specific values
}
```
---
## Enum Types
### QFieldType
Enumeration of all supported field data types.
```typescript
export type QFieldType =
| "STRING" // Short text
| "INTEGER" // Whole number
| "LONG" // Large whole number
| "DECIMAL" // Floating-point number
| "BOOLEAN" // True/false
| "DATE" // Date (no time)
| "TIME" // Time (no date)
| "DATE_TIME" // Date and time
| "TEXT" // Long text
| "HTML" // HTML content
| "PASSWORD" // Sensitive text (masked in UI)
| "BLOB"; // Binary large object
```
### QComponentType
Component types available within process steps.
```typescript
export type QComponentType =
| "HELP_TEXT" // Static help text
| "BULK_EDIT_FORM" // Multi-record edit form
| "BULK_LOAD_FILE_MAPPING_FORM" // CSV column mapping
| "BULK_LOAD_VALUE_MAPPING_FORM" // Source-to-DB value mapping
| "BULK_LOAD_PROFILE_FORM" // Saved bulk load profile
| "VALIDATION_REVIEW_SCREEN" // Review validation errors
| "EDIT_FORM" // Record edit form
| "VIEW_FORM" // Record display form
| "DOWNLOAD_FORM" // File download form
| "RECORD_LIST" // Records display
| "PROCESS_SUMMARY_RESULTS" // Process completion summary
| "GOOGLE_DRIVE_SELECT_FOLDER" // Google Drive folder picker
| "WIDGET" // Custom widget
| "HTML"; // Custom HTML content
```
### AdornmentType
Types of field enhancements.
```typescript
export type AdornmentType =
| "LINK" // Clickable hyperlink
| "CHIP" // Tag/chip display
| "SIZE" // Bytesize formatter
| "ERROR" // Error state indicator
| "RENDER_HTML" // Render as HTML
| "REVEAL" // Show/hide toggle
| "CODE_EDITOR" // Code editing interface
| "FILE_DOWNLOAD" // Download link
| "FILE_UPLOAD" // File upload control
| "TOOLTIP"; // Hover tooltip
```
### Capability
Feature toggle enumerations for table operations.
```typescript
export type Capability =
| "TABLE_QUERY" // List/query records
| "TABLE_GET" // Fetch single record
| "TABLE_COUNT" // Count records
| "TABLE_INSERT" // Create new record
| "TABLE_UPDATE" // Edit record
| "TABLE_DELETE"; // Delete record
```
### QCriteriaOperator
Filter operator enumerations for building query criteria.
```typescript
export type QCriteriaOperator =
| "EQUALS" // Exact match
| "NOT_EQUALS" // Not equal
| "NOT_EQUALS_OR_IS_NULL" // Not equal or missing
| "IN" // Within list
| "NOT_IN" // Not in list
| "STARTS_WITH" // String prefix
| "ENDS_WITH" // String suffix
| "CONTAINS" // Substring match
| "NOT_STARTS_WITH" // Exclude prefix
| "NOT_ENDS_WITH" // Exclude suffix
| "NOT_CONTAINS" // Exclude substring
| "LESS_THAN" // Numeric/date <
| "LESS_THAN_OR_EQUALS" // Numeric/date <=
| "GREATER_THAN" // Numeric/date >
| "GREATER_THAN_OR_EQUALS" // Numeric/date >=
| "IS_BLANK" // Empty/null
| "IS_NOT_BLANK" // Non-empty/not null
| "BETWEEN" // Within range (inclusive)
| "NOT_BETWEEN"; // Outside range
```
---
## Query Types
### QQueryFilter
Tree-structured query definition with criteria, ordering, and pagination.
```typescript
export interface QQueryFilter {
criteria: QFilterCriteria[]; // Filter conditions
orderBys?: QFilterOrderBy[]; // Sort specifications
subFilters?: QQueryFilter[]; // Nested boolean logic
booleanOperator: "AND" | "OR"; // How criteria/subFilters combine
skip: number; // Offset for pagination
limit: number; // Max records to return
}
```
### QFilterCriteria
Single filter condition within a QQueryFilter.
```typescript
export interface QFilterCriteria {
fieldName: string; // Field being filtered
operator: QCriteriaOperator; // Comparison operator
values: (string | number | boolean | FilterVariableExpression | NowExpression | NowWithOffsetExpression | ThisOrLastPeriodExpression)[]; // Values to compare
otherFieldName?: string; // For field-to-field comparisons
}
```
### QFilterOrderBy
Sort specification within a query.
```typescript
export interface QFilterOrderBy {
fieldName: string; // Field to sort by
isAscending: boolean; // Sort direction
}
```
### QueryJoin
Join specification for including related tables in a query.
```typescript
export interface QueryJoin {
joinTable: string; // Table name to join
select: boolean; // Include in results
type: "INNER" | "LEFT" | "RIGHT" | "FULL"; // Join type
baseTableOrAlias?: string; // Base table or alias
alias?: string; // Join alias
joinName?: string; // Named join identifier
}
```
### FilterVariableExpression
Placeholder for dynamic filter variable substitution.
```typescript
export interface FilterVariableExpression {
type: "FILTER_VARIABLE"; // Expression type marker
variableName: string; // Variable name to substitute
}
```
### NowExpression
Represents the current date/time in filter expressions.
```typescript
export interface NowExpression {
type: "NOW"; // Expression type marker
}
```
### NowWithOffsetExpression
Represents current date/time with offset (e.g., +7 days, -1 month).
```typescript
export interface NowWithOffsetExpression {
type: "NOW_WITH_OFFSET"; // Expression type marker
offsetValue: number; // Offset magnitude
offsetUnit: "DAY" | "WEEK" | "MONTH" | "YEAR"; // Offset unit
isNegativeOffset: boolean; // Add (false) or subtract (true)
}
```
### ThisOrLastPeriodExpression
Represents "this period" or "last period" relative expressions (week, month, quarter, year).
```typescript
export interface ThisOrLastPeriodExpression {
type: "THIS_OR_LAST_PERIOD"; // Expression type marker
period: "WEEK" | "MONTH" | "QUARTER" | "YEAR"; // Time period
isLast: boolean; // This period (false) or last period (true)
}
```
---
## Process Job Types
### QJobStarted
Indicates async job has been initiated; frontend should poll for status.
```typescript
export interface QJobStarted {
processUUID: string; // Process execution ID
jobUUID: string; // Async job ID for polling
}
```
### QJobRunning
Indicates job is in progress with optional progress information.
```typescript
export interface QJobRunning {
processUUID: string; // Process execution ID
message: string; // Status message
current?: number; // Current progress count
total?: number; // Total progress count
}
```
### QJobComplete
Indicates job completed successfully with next step information.
```typescript
export interface QJobComplete {
processUUID: string; // Process execution ID
values: Record<string, any>; // Step output values
nextStep?: string; // Name of next step to execute
backStep?: string; // Name of previous step (for back navigation)
processMetaDataAdjustment?: ProcessMetaDataAdjustment; // Dynamic step/field changes
}
```
### QJobError
Indicates job failed with error details.
```typescript
export interface QJobError {
processUUID: string; // Process execution ID
error: string; // Technical error details
userFacingError?: string; // User-friendly error message
}
```
### ProcessMetaDataAdjustment
Dynamic modifications to process metadata after a step completes.
```typescript
export interface ProcessMetaDataAdjustment {
addedSteps?: QFrontendStepMetaData[]; // Steps to insert
removedSteps?: string[]; // Step names to remove
modifiedFields?: Record<string, Partial<QFieldMetaData>>; // Field changes
}
```
---
## Usage Notes
- All type names prefixed with `Q` follow the QQQ naming convention
- Use `interface` (not `class`) as these represent data contracts
- Import and use these types in all frontend implementations to ensure API contract alignment
- Never create custom type aliases; always reference these canonical definitions
- Mark fields as optional using `?` only when explicitly documented as optional
- All `Record<string, X>` mappings use string keys (field/table/process/widget names)
- Process job types form a discriminated union; use the `processUUID` and type presence to determine state
- Filter expressions support runtime variable substitution via `FilterVariableExpression` types
+451
View File
@@ -0,0 +1,451 @@
# Master Architect Agent Prompt
## Your Role
You are the **Master Architect Agent** for the QQQ Admin UI Modernization project. Your job is to take the requirements specification and produce a set of **detailed implementation plans** — one per work package — that implementation agents can execute independently to build the new admin UI.
You do NOT write application code yourself. You produce plans that are precise enough that an implementation agent (an AI coding agent with access to the codebase) can follow them and produce correct, integrating code on the first pass.
---
## Inputs Available to You
You have access to the full codebase. The following files are critical:
### Requirements Specification
- **`docs/qqq-admin-modernization-requirements.md`** — The complete requirements document. Read this FIRST, in its entirety, before doing anything else.
### Existing Source Code (Reference Implementation)
These files represent the current system you are replacing. Read them to understand existing patterns, edge cases, and integration points:
- **API Client Layer:**
- `qqq-frontend-core/src/controllers/QController.ts` (~1169 lines) — Current API client, all HTTP calls
- `qqq-frontend-core/src/controllers/QControllerV1.ts` (~1117 lines) — V1 API client
- `qqq-frontend-core/src/model/**/*.ts` (~42 files) — All TypeScript domain models
- **Current Frontend UI:**
- `qqq-frontend-material-dashboard/src/App.tsx` — Route generation, auth flow, layout structure
- `qqq-frontend-material-dashboard/src/qqq/pages/**` — All page components
- `qqq-frontend-material-dashboard/src/qqq/components/**` — All shared components (~217 files)
- **Backend API (source of truth for endpoints):**
- `qqq/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/specs/v1/` — All v1 endpoint specs
- `qqq/qqq-middleware-javalin/src/main/java/com/kingsrook/qqq/middleware/javalin/QApplicationJavalinServer.java` — Route registration
---
## What You Must Produce
### Output Structure
Create a directory `docs/implementation-plans/` containing:
```
docs/implementation-plans/
├── 00-project-overview.md # Dependency graph, sequencing, shared conventions
├── 01-scaffold-and-auth.md # Work Package 1
├── 02-record-query.md # Work Package 2
├── 03-record-view-and-crud.md # Work Package 3
├── 04-process-execution.md # Work Package 4
├── 05-dashboard-and-widgets.md # Work Package 5
├── 06-polish-and-parity.md # Work Package 6
├── shared-context/
│ ├── api-contract.md # Extracted from requirements Section 3 — given to every agent
│ ├── type-definitions.md # Key TypeScript types every agent needs
│ └── coding-conventions.md # Project-wide patterns and rules
└── review-checklists/
├── review-01-scaffold.md # Review criteria for Package 1
├── review-02-query.md # Review criteria for Package 2
├── review-03-crud.md # Review criteria for Package 3
├── review-04-processes.md # Review criteria for Package 4
├── review-05-widgets.md # Review criteria for Package 5
└── review-06-polish.md # Review criteria for Package 6
```
---
## How to Build Each Work Package Plan
### Step 1: Read the Requirements and Code
Before writing any plans, read:
1. The full requirements doc (`docs/qqq-admin-modernization-requirements.md`)
2. The current `App.tsx` to understand route generation
3. The current `QController.ts` and `QControllerV1.ts` to understand every API call
4. A representative sample of page components (RecordQuery, RecordView, EntityForm, ProcessRun) to understand the current implementation patterns
5. A representative sample of shared components to understand the component architecture
### Step 2: Produce the Project Overview (`00-project-overview.md`)
This file establishes the shared foundation. It must contain:
**Technology Stack Decisions:**
- Next.js App Router (as recommended in requirements Section 4.3)
- TypeScript in strict mode
- Tailwind CSS for styling (utility-first, replaces MUI's styling system)
- shadcn/ui as the component primitive library (headless, composable, Tailwind-native)
- TanStack Table for the data grid (replaces MUI X DataGrid Pro)
- React Hook Form + Zod for forms (replaces Formik + Yup)
- TanStack Query for data fetching and caching
- Vitest for unit tests, Playwright for E2E
**Project Structure:**
Define the exact directory layout for the new project. Example:
```
src/
├── app/ # Next.js App Router pages
│ ├── (auth)/ # Auth routes (login, callback)
│ ├── (dashboard)/ # Authenticated routes
│ │ ├── app/
│ │ │ ├── [appName]/
│ │ │ ├── [tableName]/
│ │ │ │ ├── page.tsx # RecordQuery
│ │ │ │ ├── create/page.tsx # Create record
│ │ │ │ ├── [recordId]/
│ │ │ │ │ ├── page.tsx # RecordView
│ │ │ │ │ └── edit/page.tsx # Edit record
│ │ │ └── [processName]/
│ │ │ └── page.tsx # ProcessRun
│ │ └── layout.tsx # Dashboard layout (sidebar + nav)
│ └── layout.tsx # Root layout
├── components/
│ ├── layout/ # Sidebar, navbar, breadcrumbs
│ ├── records/ # Record-related components
│ ├── forms/ # Dynamic form components
│ ├── query/ # Filter builder, column config
│ ├── process/ # Process step components
│ ├── widgets/ # Widget system
│ └── ui/ # shadcn/ui primitives
├── lib/
│ ├── api/ # API client (typed, generated from contract)
│ ├── auth/ # Auth providers and session management
│ ├── hooks/ # Shared React hooks
│ └── utils/ # Utility functions
├── types/ # Shared TypeScript types (ported from qqq-frontend-core)
└── styles/ # Global styles, Tailwind config, theme tokens
```
**Dependency Graph:**
```
Package 1 (Scaffold + Auth)
↓
Package 2 (Record Query) ←→ Package 3 (Record View + CRUD)
↓ ↓
Package 4 (Process Execution)
↓
Package 5 (Dashboard + Widgets)
↓
Package 6 (Polish + Parity)
```
Packages 2 and 3 can run in parallel after Package 1. Package 4 depends on form components from Package 3. Package 5 depends on the data grid from Package 2 and forms from Package 3. Package 6 depends on everything.
**Shared Conventions** (also extracted into `shared-context/coding-conventions.md`):
- All components that render from metadata must accept the metadata type as a prop — never fetch it internally
- API calls go through the typed client in `lib/api/` — never raw `fetch`
- No hardcoded table names, field names, or process names anywhere in the UI
- Every component must have a `data-qqq-id` attribute for CSS customization
- All user-facing text that comes from metadata must use the `label` field, never `name`
- Error boundaries at the route level and the widget level
### Step 3: Produce Each Work Package Plan
Each work package file (`01-scaffold-and-auth.md` through `06-polish-and-parity.md`) must follow this exact structure:
```markdown
# Work Package [N]: [Name]
## Prerequisites
- List which previous packages must be complete
- List specific files/components from previous packages that this package depends on
## Requirements Traceability
- List every section/subsection from the requirements doc that this package implements
- Example: "Section 5.5 (Authentication), Section 5.3.1 (Layout Components), Section 3.2 (Auth Endpoints)"
## Shared Context Files
- List which files from shared-context/ the implementation agent needs
## Scope
### In Scope
- Specific list of features/components to build
### Out of Scope
- What is explicitly NOT part of this package (to prevent scope creep)
## Detailed Implementation Steps
### Step 1: [Action]
**What:** [Precise description of what to create/modify]
**Files to create:**
- `src/path/to/file.tsx` — [Purpose and key exports]
- `src/path/to/file.ts` — [Purpose and key exports]
**Key implementation details:**
- [Specific technical detail the agent needs to know]
- [Edge case to handle]
- [Pattern to follow from the existing codebase — reference specific files]
**API integration:**
- Endpoint: `[METHOD] /qqq/v1/[path]`
- Request: [Shape]
- Response: [Shape]
- Error handling: [What to do on 401, 404, 500]
**Reference implementation:**
- See `qqq-frontend-material-dashboard/src/qqq/[path]` for the current implementation of this feature
- Key behaviors to preserve: [list]
- Key behaviors to change/improve: [list]
### Step 2: [Action]
[Same structure...]
[Continue for all steps...]
## Component Specifications
For each component this package creates, specify:
### `ComponentName`
**File:** `src/components/[path]/ComponentName.tsx`
**Props:**
```typescript
interface ComponentNameProps {
// exact prop types
}
```
**Behavior:**
- [What it renders in each state: loading, empty, error, populated]
- [User interactions it handles]
- [Events it emits / callbacks it invokes]
**Metadata-driven aspects:**
- [Which metadata fields control its rendering]
- [How it adapts to different field types / configurations]
**Responsive behavior:**
- Mobile: [specific behavior]
- Tablet: [specific behavior]
- Desktop: [specific behavior]
## API Client Functions
For each API function this package requires:
### `functionName`
**File:** `src/lib/api/[module].ts`
**Signature:** `(params: Type) => Promise<ReturnType>`
**Endpoint:** `[METHOD] /qqq/v1/[path]`
**Caching:** [TanStack Query key, stale time, cache time]
**Error handling:** [Specific error scenarios and how to handle them]
## Testing Requirements
### Unit Tests
- [Specific components/functions to test]
- [Key scenarios to cover]
- [Mock data patterns]
### E2E Tests (Playwright)
- [User flow 1: description and steps]
- [User flow 2: description and steps]
## Acceptance Criteria
Each criterion must be binary (pass/fail) and testable:
- [ ] [Specific, measurable criterion]
- [ ] [Specific, measurable criterion]
- [ ] [Example: "The sidebar renders all apps from appTree metadata with correct nesting and icons"]
- [ ] [Example: "Clicking a table in the sidebar navigates to /app/{tableName} and the RecordQuery page loads"]
- [ ] [Example: "A 401 response from any API call redirects to the login page without losing the current URL"]
```
### Step 4: Produce Shared Context Files
**`shared-context/api-contract.md`:**
Extract Section 3 from the requirements doc verbatim. Every implementation agent receives this file. It is the single source of truth for API integration.
**`shared-context/type-definitions.md`:**
Port the key TypeScript types from `qqq-frontend-core/src/model/` into a single reference document. Include:
- All metadata types (QInstance, QTableMetaData, QFieldMetaData, QProcessMetaData, QAppMetaData, QWidgetMetaData, etc.)
- All data types (QRecord, QPossibleValue)
- All query types (QQueryFilter, QFilterCriteria, QCriteriaOperator, QFilterOrderBy, QueryJoin)
- All process types (QJobStarted, QJobRunning, QJobComplete, QJobError)
- All enum types (QFieldType, QComponentType, AdornmentType, Capability, QAppNodeType)
Annotate each type with a one-line description of when/where it's used.
**`shared-context/coding-conventions.md`:**
Define project-wide rules:
- File naming conventions
- Component patterns (server components vs. client components — when to use each)
- State management approach (TanStack Query for server state, React context for UI state)
- Error handling patterns
- Import organization
- Tailwind class ordering convention
- Accessibility requirements (ARIA attributes, keyboard navigation, focus management)
- `data-qqq-id` naming convention for CSS customization hooks
- How to handle the QQQ theme tokens (CSS custom properties)
### Step 5: Produce Review Checklists
Each review checklist (`review-checklists/review-0N-*.md`) must contain:
1. **Requirements coverage:** For each requirement traced to this package, a yes/no check that it's implemented
2. **Integration check:** Verify the package integrates with previous packages (imports resolve, types match, navigation works)
3. **API contract compliance:** Verify every API call matches Section 3 exactly (correct path, correct request shape, correct response handling)
4. **Metadata-driven check:** Verify no hardcoded table/field/process names exist
5. **Responsive check:** Verify mobile/tablet/desktop behavior per Section 5.4
6. **Accessibility check:** Keyboard navigation, ARIA attributes, color contrast
7. **Type safety check:** No `any` types in component props, all API responses validated
---
## Work Package Definitions
Here is the scope for each package. Use these as starting points, then refine based on what you learn from reading the code.
### Package 1: Project Scaffold, Auth, and Layout Shell
**Requirements:** Sections 5.1, 5.5, 5.3.1 (Layout Components), 3.2 (Auth Endpoints)
**Delivers:**
- Next.js project initialization with all dependencies
- TypeScript types ported from qqq-frontend-core models
- API client with typed functions for auth and metadata endpoints
- Auth flow (OAuth2/OIDC via `getAuthenticationMetaData` → redirect → `manageSession` → session cookie)
- Root layout with sidebar navigation generated from `appTree` metadata
- Top navbar with breadcrumbs
- 401 interceptor with re-auth flow
- Branding/theming from `QBrandingMetaData` (accent colors, logo, banners)
- Light/dark mode toggle
- Empty placeholder pages for all dynamic routes (so navigation works end-to-end)
**Critical reading:** `App.tsx` (route generation logic), `QController.getAuthenticationMetaData()`, `QController.manageSession()`, `QController.loadMetaData()`, SideNav components, NavBar component, BrandedHeaderBar
### Package 2: Record Query Page
**Requirements:** Sections 5.2.3 (query route), 5.3.2 (Data Grid), 5.3.5 (Query/Filter Components), 5.4.2 (responsive for query), 3.4.1 (Query), 3.4.2 (Count), 3.7 (Possible Values)
**Delivers:**
- Record Query page with full data grid (TanStack Table)
- Server-side pagination, sorting, and filtering via `POST /table/{t}/query` and `POST /table/{t}/count`
- Dynamic column generation from `QTableMetaData.fields`
- Cell renderers per field type (string, number, date, boolean, etc.) and per adornment type (link, chip, file download, etc.)
- Basic filter mode (quick text search) and advanced filter builder (full QQueryFilter with all operators, boolean logic, sub-filters)
- Column configuration panel (show/hide, reorder)
- Row selection (single and multi)
- Export (CSV at minimum)
- Saved views (save/load filter + column configurations)
- Pagination controls with page size selector
- Table variant selection
- Row click → navigate to Record View
- Toolbar with create button (if insertPermission), process launcher, column config, density control
**Critical reading:** RecordQuery.tsx (the entire file — this is the most complex page), CustomFilterPanel, FilterCriteriaRow, BasicAndAdvancedQueryControls, SavedViews, CustomColumnsPanel, ExportMenuItem
### Package 3: Record View and CRUD
**Requirements:** Sections 5.2.3 (view/create/edit/copy routes), 5.3.2 (Record Detail View), 5.3.3 (Form Components), 5.4.2 (responsive for view and form), 3.4.3–3.4.6 (Get, Insert, Update, Delete), 3.7 (Possible Values)
**Delivers:**
- Record View page with section-organized field display
- Field value rendering per type and adornment
- Action menu (edit, delete, copy, launch process, share)
- Entity Form component for create/edit/copy
- Dynamic form generation from `QFieldMetaData` (all 12 field types)
- Form validation (Zod schemas generated from field metadata: required, maxLength, type constraints)
- Possible values autocomplete (`POST /possibleValues/{f}`) with debounced search
- File upload fields
- Boolean switch fields
- Date/time pickers
- Rich text fields (HTML type)
- Section-based form layout with `gridColumns` support
- Record delete with confirmation dialog
- Record copy (pre-populate form without ID)
- Associated records display
- Help content tooltips on fields
**Critical reading:** RecordView.tsx, EntityForm.tsx, DynamicForm.tsx, DynamicFormField.tsx, DynamicSelect.tsx, BooleanFieldSwitch.tsx, FileInputField.tsx, all field adornment rendering logic
### Package 4: Process Execution
**Requirements:** Sections 5.2.4 (process routes), 5.3.4 (Process Components), 3.5 (Process Endpoints), 3.7 (Possible Values)
**Delivers:**
- Process Run page with step wizard (stepper UI)
- Dynamic step rendering based on `QFrontendStepMetaData`
- Form steps (reuse EntityForm components from Package 3)
- View-only steps
- Validation review screen
- Bulk load workflows: file upload, column-to-field mapping, value mapping, profile save/load
- Async job handling: detect `QJobStarted`, poll `GET /status/{jobUUID}`, display `QJobRunning` progress, handle `QJobComplete` and `QJobError`
- Process results summary (record counts, error lists, download links)
- Process records endpoint (`GET /processes/{p}/{uuid}/records`) for paginated result viewing
- Process cancellation
- Support for both standalone processes and table-scoped processes (with pre-selected records via recordIds or filterJSON)
**Critical reading:** ProcessRun.tsx (the entire file), ProcessViewForm, ProcessSummaryResults, ValidationReview, all BulkLoad* components, QJobStarted/Running/Complete/Error types
### Package 5: Dashboard and Widgets
**Requirements:** Sections 5.2.2 (App Home), 5.3.6 (Widget Components), 3.6 (Widget Endpoint)
**Delivers:**
- App Home page with dashboard layout
- Widget container component (label, reload, export, dropdown menus, help content)
- Widget grid layout with `gridColumns` support
- All chart widgets (line, bar, horizontal bar, stacked bar, pie) — use Recharts or Chart.js
- Statistics cards (MiniStatisticsCard, MultiStatisticsCard)
- Record Grid Widget (embedded data grid with add/edit/delete)
- Block system (TextBlock, BigNumberBlock, UpOrDownNumberBlock, ProgressBarBlock, ButtonBlock, IconBlock, ImageBlock, AudioBlock, DividerBlock, InputFieldBlock)
- Composite and parent widget containers
- Widget dropdown selections with possible-value sources
- Data fetching with abort/cancel-and-replace for widget requests
- Custom component widget mount point (for app-specific React components)
- Specialized widgets: pivot table setup, filter/columns setup, CRON builder, US map (if feasible), script viewer
**Critical reading:** DashboardWidgets, Widget.tsx, all widget block components, RecordGridWidget, ChartSubheaderWithData, all chart components, CompositeWidget, ParentWidget
### Package 6: Polish, Parity, and Production Readiness
**Requirements:** Sections 5.4 (all responsive), 5.6 (Performance), 5.7 (Developer Experience)
**Delivers:**
- Responsive design audit and fixes across all breakpoints for all pages
- Mobile card view for Record Query
- Command palette (Cmd+K) for quick navigation
- Keyboard shortcuts
- Audit trail display on Record View
- Developer tools pages (script editor, logs, test execution)
- Report execution pages
- Storybook setup with stories for all shared components
- Playwright E2E test suite for critical flows (login, query, CRUD, process execution)
- Performance optimization (bundle analysis, lazy loading, caching tuning)
- Accessibility audit (axe-core) and remediation
- Theme token system (CSS custom properties) for all 60+ tokens from current qfmd
- `data-qqq-id` attributes on all interactive elements
- `customCss` injection support
**Critical reading:** All responsive-related code in current qfmd, CommandMenu, AuditBody, RecordDeveloperView, TableDeveloperView, ScriptViewer, ReportRun
---
## Quality Standards for Your Plans
Your plans will be judged on:
1. **Precision:** An implementation agent should not need to make architectural decisions. Every file path, component name, prop interface, and API integration point should be specified.
2. **Completeness:** Every feature in the requirements must appear in exactly one work package. Nothing can fall through the cracks. If a feature spans packages, specify which package owns it and what interface the other package consumes.
3. **Traceability:** Every implementation step must trace back to a specific requirement (section number). Every requirement must trace forward to a specific implementation step.
4. **Realism:** Steps should be ordered so that each step can be tested after completion. Don't put all the types first, then all the components, then all the wiring — interleave so that after each step, something new works.
5. **Edge case awareness:** Read the existing implementation. Note error handling, loading states, empty states, permission checks, and null cases. Your plans must address these. The current qfmd has years of edge case handling baked in — don't lose it.
6. **Integration specificity:** When Package N depends on Package N-1, specify the exact imports, types, and components it depends on. Don't say "use the API client from Package 1" — say "import `queryRecords` from `@/lib/api/tables` which returns `Promise<QRecord[]>`".
---
## Execution Instructions
1. Read the full requirements document first.
2. Read the current codebase files listed above (use parallel reads where possible).
3. Produce `00-project-overview.md` first — this establishes all shared decisions.
4. Produce the three `shared-context/` files.
5. Produce work packages 01 through 06 in order.
6. Produce review checklists for each package.
7. Do a final cross-check: scan every subsection of Section 5 in the requirements and verify it appears in at least one work package's scope.
Begin now.
File diff suppressed because it is too large. Load diff
+16
View File
@@ -0,0 +1,16 @@
import { dirname } from 'path'
import { fileURLToPath } from 'url'
import { FlatCompat } from '@eslint/eslintrc'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const compat = new FlatCompat({
baseDirectory: __dirname,
})
const eslintConfig = [
...compat.extends('next/core-web-vitals', 'next/typescript', 'prettier'),
]
export default eslintConfig
+13
View File
@@ -0,0 +1,13 @@
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
reactStrictMode: true,
typescript: {
tsconfigPath: './tsconfig.json',
},
eslint: {
ignoreDuringBuilds: false,
},
}
export default nextConfig
+81
View File
@@ -0,0 +1,81 @@
{
"name": "qqq-frontend-next",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,css}\"",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui",
"test:coverage": "vitest run --coverage",
"test:e2e": "playwright test",
"tsc": "tsc"
},
"dependencies": {
"@auth0/auth0-react": "^2.2.4",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@tanstack/react-query": "^5.69.0",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.8.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"lodash": "^4.17.21",
"lucide-react": "^0.485.0",
"next": "15.2.3",
"oidc-client-ts": "^3.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"recharts": "^2.15.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.0.2",
"ts-md5": "^1.3.1",
"universal-cookie": "^7.2.2",
"zod": "^3.24.2"
},
"devDependencies": {
"@eslint/eslintrc": "^3.3.4",
"@playwright/test": "^1.50.1",
"@tailwindcss/postcss": "^4.0.15",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.1",
"@types/lodash": "^4.17.16",
"@types/node": "^22.13.10",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^5.1.4",
"@vitest/coverage-v8": "^3.0.9",
"@vitest/ui": "^3.0.9",
"autoprefixer": "^10.4.21",
"eslint": "^9.22.0",
"eslint-config-next": "15.2.3",
"eslint-config-prettier": "^10.1.1",
"jsdom": "^26.0.0",
"msw": "^2.12.10",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"tailwindcss": "^4.0.15",
"typescript": "^5.8.2",
"vitest": "^3.0.9"
},
"msw": {
"workerDirectory": [
"public"
]
}
}
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './tests/e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})
+7779
View File
File diff suppressed because it is too large. Load diff
+8
View File
@@ -0,0 +1,8 @@
/** @type {import('postcss').Config} */
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
}
export default config
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

+349
View File
@@ -0,0 +1,349 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.12.10'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
addEventListener('install', function () {
self.skipWaiting()
})
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents)
// Clone the response so both the client and the library could consume it.
const responseClone = response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
headers: Object.fromEntries(responseClone.headers.entries()),
body: responseClone.body,
},
},
},
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
)
}
return response
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client
}
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
}
+5
View File
@@ -0,0 +1,5 @@
User-agent: *
Disallow: /api/
Disallow: /login
Disallow: /callback
Allow: /
+85
View File
@@ -0,0 +1,85 @@
'use client'
// OAuth2 callback page — handles the redirect from authorization server
// Wrapped in Suspense because it uses useSearchParams()
import React, { Suspense, useEffect, useRef } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useAuth } from '@/lib/auth/use-auth'
function CallbackContent() {
const router = useRouter()
const searchParams = useSearchParams()
const { handleOAuthCallback } = useAuth()
const handled = useRef(false)
useEffect(() => {
if (handled.current) return
handled.current = true
const code = searchParams.get('code')
const state = searchParams.get('state')
const error = searchParams.get('error')
const errorDescription = searchParams.get('error_description')
if (error) {
console.error('[Callback] Auth error:', error, errorDescription)
router.replace(`/login?error=${encodeURIComponent(error)}`)
return
}
if (!code || !state) {
console.error('[Callback] Missing code or state params')
router.replace('/login')
return
}
handleOAuthCallback(code, state)
.then(() => {
// Get stored returnTo from session storage or default to home
const returnTo = sessionStorage.getItem('oauth2ReturnTo') ?? '/'
sessionStorage.removeItem('oauth2ReturnTo')
router.replace(returnTo)
})
.catch((err) => {
console.error('[Callback] Failed to handle callback:', err)
router.replace('/login?error=callback_failed')
})
}, [searchParams, handleOAuthCallback, router])
return (
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-8 shadow-sm">
<div className="flex flex-col items-center gap-4 text-center">
<div
className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
aria-hidden="true"
/>
<h1 className="text-xl font-semibold text-foreground">
Completing login...
</h1>
<p className="text-sm text-muted-foreground">
Please wait while we complete authentication.
</p>
</div>
</div>
)
}
export default function CallbackPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-muted">
<Suspense
fallback={
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-8 shadow-sm">
<div className="flex flex-col items-center gap-4">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
</div>
}
>
<CallbackContent />
</Suspense>
</div>
)
}
+97
View File
@@ -0,0 +1,97 @@
'use client'
// Login page — authenticates user and redirects to dashboard
// Wrapped in Suspense because it uses useSearchParams()
import React, { Suspense, useEffect } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useAuth } from '@/lib/auth/use-auth'
function LoginContent() {
const router = useRouter()
const searchParams = useSearchParams()
const { isAuthenticated, isLoading, authMetadata } = useAuth()
const returnTo = searchParams.get('returnTo') ?? '/'
useEffect(() => {
if (!isLoading && isAuthenticated) {
// Decode and validate the returnTo URL
try {
const decoded = decodeURIComponent(returnTo)
// Only redirect to relative paths (security: prevent open redirect)
if (decoded.startsWith('/')) {
router.replace(decoded)
} else {
router.replace('/')
}
} catch {
router.replace('/')
}
}
}, [isAuthenticated, isLoading, router, returnTo])
if (isLoading) {
return (
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-8 shadow-sm">
<div className="flex flex-col items-center gap-4 text-center">
<div
className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
aria-hidden="true"
/>
<h1 className="text-xl font-semibold text-foreground">
Authenticating...
</h1>
<p className="text-sm text-muted-foreground">
{authMetadata
? `Setting up ${authMetadata.type} session...`
: 'Checking authentication...'}
</p>
</div>
</div>
)
}
// If not loading and not authenticated, show login state
return (
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-8 shadow-sm">
<div className="flex flex-col items-center gap-4 text-center">
<div
className="flex h-12 w-12 items-center justify-center rounded-full bg-primary text-xl font-bold text-primary-foreground"
aria-hidden="true"
>
Q
</div>
<h1 className="text-xl font-semibold text-foreground">QQQ Admin</h1>
<p className="text-sm text-muted-foreground">
{authMetadata?.type === 'FULLY_ANONYMOUS' || authMetadata?.type === 'MOCK'
? 'Setting up anonymous session...'
: 'Redirecting to authentication provider...'}
</p>
<div
className="h-6 w-6 animate-spin rounded-full border-2 border-primary border-t-transparent"
aria-hidden="true"
/>
</div>
</div>
)
}
export default function LoginPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-muted">
<Suspense
fallback={
<div className="w-full max-w-sm rounded-xl border border-border bg-card p-8 shadow-sm">
<div className="flex flex-col items-center gap-4">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
</div>
}
>
<LoginContent />
</Suspense>
</div>
)
}
@@ -0,0 +1,85 @@
'use client'
// EntityCopy — copy a record (pre-populates form with existing values)
// Package 3: full implementation using EntityForm with isCopy=true
import React, { useEffect } from 'react'
import { useParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { useQContext } from '@/lib/context/q-context'
import { loadMetaData } from '@/lib/api/metadata'
import { queryKeys } from '@/lib/query-client'
import { useRecord } from '@/lib/hooks/use-record'
import { EntityForm } from '@/components/forms/EntityForm'
export default function EntityCopyPage() {
const params = useParams<{ slug: string; recordId: string }>()
const { setPageHeader, setTableMetaData } = useQContext()
const { slug, recordId } = params
const { data: metaData } = useQuery({
queryKey: queryKeys.metadataAll(),
queryFn: loadMetaData,
staleTime: 1000 * 60 * 30,
})
const tableMetaData = metaData?.tables?.[slug]
const { record, isLoading, isError, error } = useRecord({
tableName: slug,
primaryKey: recordId,
enabled: Boolean(tableMetaData),
})
useEffect(() => {
setPageHeader(`Copy ${tableMetaData?.label ?? slug} #${recordId}`)
if (tableMetaData) {
setTableMetaData(tableMetaData)
}
}, [tableMetaData?.label, slug, recordId, tableMetaData, setPageHeader, setTableMetaData])
if (!tableMetaData || isLoading) {
return (
<div className="flex items-center justify-center py-16" aria-busy="true" aria-live="polite">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
}
if (!tableMetaData.insertPermission) {
return (
<div
className="rounded-xl border border-yellow-200 bg-yellow-50 p-8 text-center"
role="alert"
>
<p className="text-sm text-yellow-700">
You do not have permission to create {tableMetaData.label} records.
</p>
</div>
)
}
if (isError || !record) {
return (
<div
className="rounded-xl border border-destructive/30 bg-destructive/10 p-8 text-center"
role="alert"
>
<p className="text-sm text-destructive">
{error?.message ?? `Failed to load ${tableMetaData.label} #${recordId}`}
</p>
</div>
)
}
return (
<div className="mx-auto max-w-4xl" data-qqq-id={`entity-copy-${slug}-${recordId}`}>
<EntityForm
tableMetaData={tableMetaData}
record={record}
isCopy={true}
/>
</div>
)
}
@@ -0,0 +1,165 @@
'use client'
// RecordDeveloperView — shows raw record data and table metadata as formatted JSON
// Useful for debugging field values, types, and backend responses
import React, { useEffect } from 'react'
import { useParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { Code, ChevronDown, ChevronUp } from 'lucide-react'
import { useQContext } from '@/lib/context/q-context'
import { getRecord } from '@/lib/api/tables'
import { loadTableMetaData } from '@/lib/api/metadata'
import { queryKeys } from '@/lib/query-client'
export default function RecordDeveloperViewPage() {
const params = useParams<{ slug: string; recordId: string }>()
const { setPageHeader } = useQContext()
const { slug, recordId } = params
useEffect(() => {
setPageHeader(`Dev: ${slug} #${recordId}`)
}, [slug, recordId, setPageHeader])
const { data: record, isLoading: recordLoading, error: recordError } = useQuery({
queryKey: queryKeys.tableRecord(slug, recordId),
queryFn: () => getRecord(slug, recordId),
staleTime: 1000 * 60 * 5,
})
const { data: metaData, isLoading: metaLoading } = useQuery({
queryKey: queryKeys.tableMetadata(slug),
queryFn: () => loadTableMetaData(slug),
staleTime: 1000 * 60 * 30,
})
const isLoading = recordLoading || metaLoading
return (
<div className="space-y-6" data-qqq-id={`record-dev-${slug}-${recordId}`}>
<div className="flex items-center gap-3">
<Code className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
<h2 className="text-2xl font-semibold text-foreground">
Record Developer View:{' '}
<span className="font-mono text-primary">{slug}</span>
{' '}
<span className="font-mono text-muted-foreground">#{recordId}</span>
</h2>
</div>
{isLoading && (
<div
className="flex items-center justify-center py-12"
role="status"
aria-live="polite"
aria-busy="true"
>
<div className="h-6 w-6 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)}
{recordError && (
<div
className="rounded-xl border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive"
role="alert"
>
Failed to load record: {recordError instanceof Error ? recordError.message : 'Unknown error'}
</div>
)}
{!isLoading && record && (
<div className="space-y-4">
{/* Field values summary */}
{metaData && (
<div className="overflow-x-auto rounded-xl border border-border">
<table className="min-w-full text-sm" data-qqq-id="record-dev-field-table">
<thead className="bg-muted">
<tr>
<th className="px-4 py-2 text-left font-medium text-foreground">
Field
</th>
<th className="px-4 py-2 text-left font-medium text-foreground">
Type
</th>
<th className="px-4 py-2 text-left font-medium text-foreground">
Value
</th>
</tr>
</thead>
<tbody>
{Object.entries(record.values).map(([fieldName, value]) => {
const field = metaData.fields[fieldName]
return (
<tr
key={fieldName}
className="border-t border-border"
>
<td className="px-4 py-2 font-mono text-xs text-primary">
{fieldName}
</td>
<td className="px-4 py-2 font-mono text-xs text-muted-foreground">
{field?.type ?? '—'}
</td>
<td className="px-4 py-2 font-mono text-xs text-foreground">
{value === null || value === undefined
? <span className="text-muted-foreground italic">null</span>
: String(value)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
<JsonBlock label="Raw Record" value={record} />
{metaData && <JsonBlock label="Table Metadata" value={metaData} defaultOpen={false} />}
</div>
)}
</div>
)
}
function JsonBlock({
label,
value,
defaultOpen = true,
}: {
label: string
value: unknown
defaultOpen?: boolean
}) {
const [open, setOpen] = React.useState(defaultOpen)
return (
<div className="overflow-hidden rounded-xl border border-border">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center justify-between bg-muted px-4 py-3 text-sm font-medium text-foreground hover:bg-accent focus:outline-none focus:ring-2 focus:ring-inset focus:ring-ring"
aria-expanded={open}
data-qqq-id="button-json-block-toggle"
>
<span className="flex items-center gap-2">
<Code className="h-4 w-4" aria-hidden="true" />
{label}
</span>
{open ? (
<ChevronUp className="h-4 w-4" aria-hidden="true" />
) : (
<ChevronDown className="h-4 w-4" aria-hidden="true" />
)}
</button>
{open && (
<pre
className="overflow-x-auto bg-gray-900 p-4 text-xs text-green-300"
data-qqq-id="json-output"
>
{JSON.stringify(value, null, 2)}
</pre>
)}
</div>
)
}
@@ -0,0 +1,81 @@
'use client'
// EntityEdit — edit an existing record
// Package 3: full implementation using EntityForm + useRecord
import React, { useEffect } from 'react'
import { useParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { useQContext } from '@/lib/context/q-context'
import { loadMetaData } from '@/lib/api/metadata'
import { queryKeys } from '@/lib/query-client'
import { useRecord } from '@/lib/hooks/use-record'
import { EntityForm } from '@/components/forms/EntityForm'
export default function EntityEditPage() {
const params = useParams<{ slug: string; recordId: string }>()
const { setPageHeader, setTableMetaData } = useQContext()
const { slug, recordId } = params
const { data: metaData } = useQuery({
queryKey: queryKeys.metadataAll(),
queryFn: loadMetaData,
staleTime: 1000 * 60 * 30,
})
const tableMetaData = metaData?.tables?.[slug]
const { record, isLoading, isError, error } = useRecord({
tableName: slug,
primaryKey: recordId,
enabled: Boolean(tableMetaData),
})
useEffect(() => {
setPageHeader(`Edit ${tableMetaData?.label ?? slug} #${recordId}`)
if (tableMetaData) {
setTableMetaData(tableMetaData)
}
}, [tableMetaData?.label, slug, recordId, tableMetaData, setPageHeader, setTableMetaData])
if (!tableMetaData || isLoading) {
return (
<div className="flex items-center justify-center py-16" aria-busy="true" aria-live="polite">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
}
if (!tableMetaData.editPermission) {
return (
<div
className="rounded-xl border border-yellow-200 bg-yellow-50 p-8 text-center"
role="alert"
>
<p className="text-sm text-yellow-700">
You do not have permission to edit {tableMetaData.label} records.
</p>
</div>
)
}
if (isError || !record) {
return (
<div
className="rounded-xl border border-destructive/30 bg-destructive/10 p-8 text-center"
role="alert"
>
<p className="text-sm text-destructive">
{error?.message ?? `Failed to load ${tableMetaData.label} #${recordId}`}
</p>
</div>
)
}
return (
<div className="mx-auto max-w-4xl" data-qqq-id={`entity-edit-${slug}-${recordId}`}>
<EntityForm tableMetaData={tableMetaData} record={record} />
</div>
)
}
@@ -0,0 +1,84 @@
'use client'
// RecordView page — view a single record
// Package 3: full implementation using RecordView component + useRecord hook
import React, { useEffect } from 'react'
import { useParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { addRecentRecord } from '@/lib/utils/recent-records'
import { getProcessesForTable } from '@/lib/utils/process-utils'
import { useQContext } from '@/lib/context/q-context'
import { loadMetaData } from '@/lib/api/metadata'
import { queryKeys } from '@/lib/query-client'
import { useRecord } from '@/lib/hooks/use-record'
import { RecordView } from '@/components/records/RecordView'
export default function RecordViewPage() {
const params = useParams<{ slug: string; recordId: string }>()
const { setPageHeader, setTableMetaData } = useQContext()
const { slug, recordId } = params
const { data: metaData } = useQuery({
queryKey: queryKeys.metadataAll(),
queryFn: loadMetaData,
staleTime: 1000 * 60 * 30,
})
const tableMetaData = metaData?.tables?.[slug]
const { record, isLoading, isError, error, refetch } = useRecord({
tableName: slug,
primaryKey: recordId,
enabled: Boolean(tableMetaData),
includeAssociations: true,
})
useEffect(() => {
if (record?.recordLabel) {
setPageHeader(record.recordLabel)
} else {
setPageHeader(`${tableMetaData?.label ?? slug} #${recordId}`)
}
if (tableMetaData) {
setTableMetaData(tableMetaData)
}
}, [record, tableMetaData, slug, recordId, setPageHeader, setTableMetaData])
// Track recently viewed records for global search
useEffect(() => {
if (record && tableMetaData) {
addRecentRecord({
tableName: tableMetaData.name,
tableLabel: tableMetaData.label,
recordId: String(record.values[tableMetaData.primaryKeyField]),
recordLabel: record.recordLabel || `${tableMetaData.label} #${record.values[tableMetaData.primaryKeyField]}`,
path: `/app/${tableMetaData.name}/${record.values[tableMetaData.primaryKeyField]}`,
})
}
}, [record, tableMetaData])
if (!tableMetaData) {
return (
<div className="flex items-center justify-center py-16" aria-busy="true" aria-live="polite">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
}
const tableProcesses = metaData ? getProcessesForTable(metaData, slug) : []
return (
<RecordView
tableMetaData={tableMetaData}
record={record}
isLoading={isLoading}
isError={isError}
error={error}
onRefetch={refetch}
processes={tableProcesses}
allTables={metaData?.tables}
/>
)
}
@@ -0,0 +1,61 @@
'use client'
// EntityCreate — create a new record
// Package 3: full implementation using EntityForm component
import React, { useEffect } from 'react'
import { useParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { useQContext } from '@/lib/context/q-context'
import { loadMetaData } from '@/lib/api/metadata'
import { queryKeys } from '@/lib/query-client'
import { EntityForm } from '@/components/forms/EntityForm'
export default function EntityCreatePage() {
const params = useParams<{ slug: string }>()
const { setPageHeader, setTableMetaData } = useQContext()
const slug = params.slug
const { data: metaData } = useQuery({
queryKey: queryKeys.metadataAll(),
queryFn: loadMetaData,
staleTime: 1000 * 60 * 30,
})
const tableMetaData = metaData?.tables?.[slug]
useEffect(() => {
setPageHeader(`Create ${tableMetaData?.label ?? slug}`)
if (tableMetaData) {
setTableMetaData(tableMetaData)
}
}, [tableMetaData?.label, slug, tableMetaData, setPageHeader, setTableMetaData])
if (!tableMetaData) {
return (
<div className="flex items-center justify-center py-16" aria-busy="true" aria-live="polite">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
}
if (!tableMetaData.insertPermission) {
return (
<div
className="rounded-xl border border-yellow-200 bg-yellow-50 p-8 text-center"
role="alert"
>
<p className="text-sm text-yellow-700">
You do not have permission to create {tableMetaData.label} records.
</p>
</div>
)
}
return (
<div className="mx-auto max-w-4xl" data-qqq-id={`entity-create-${slug}`}>
<EntityForm tableMetaData={tableMetaData} />
</div>
)
}
+139
View File
@@ -0,0 +1,139 @@
'use client'
// TableDeveloperView — shows raw table metadata as formatted JSON
// Useful for debugging field definitions, sections, and permissions
import React, { useEffect } from 'react'
import { useParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { Code, ChevronDown, ChevronUp } from 'lucide-react'
import { useQContext } from '@/lib/context/q-context'
import { loadTableMetaData } from '@/lib/api/metadata'
import { queryKeys } from '@/lib/query-client'
export default function TableDeveloperViewPage() {
const params = useParams<{ slug: string }>()
const { setPageHeader } = useQContext()
const slug = params.slug
useEffect(() => {
setPageHeader(`Developer View: ${slug}`)
}, [slug, setPageHeader])
const { data, isLoading, error } = useQuery({
queryKey: queryKeys.tableMetadata(slug),
queryFn: () => loadTableMetaData(slug),
staleTime: 1000 * 60 * 5,
})
return (
<div className="space-y-6" data-qqq-id={`table-dev-${slug}`}>
<div className="flex items-center gap-3">
<Code className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
<h2 className="text-2xl font-semibold text-foreground">
Table Developer View: <span className="font-mono text-primary">{slug}</span>
</h2>
</div>
{isLoading && (
<div
className="flex items-center justify-center py-12"
role="status"
aria-live="polite"
aria-busy="true"
>
<div className="h-6 w-6 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)}
{error && (
<div
className="rounded-xl border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive"
role="alert"
>
Failed to load metadata: {error instanceof Error ? error.message : 'Unknown error'}
</div>
)}
{data && (
<div className="space-y-4">
{/* Summary cards */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<MetaStat label="Fields" value={Object.keys(data.fields ?? {}).length} />
<MetaStat label="Sections" value={(data.sections ?? []).length} />
<MetaStat
label="Capabilities"
value={[
data.insertPermission ? 'Insert' : null,
data.editPermission ? 'Edit' : null,
data.deletePermission ? 'Delete' : null,
]
.filter(Boolean)
.join(', ') || 'Read-only'}
/>
<MetaStat label="Primary Key" value={data.primaryKeyField ?? '—'} />
</div>
{/* Raw JSON */}
<JsonBlock label="Full Table Metadata" value={data} defaultOpen={false} />
</div>
)}
</div>
)
}
function MetaStat({ label, value }: { label: string; value: string | number }) {
return (
<div className="rounded-xl border border-border bg-card p-4">
<p className="text-xs font-medium text-muted-foreground">
{label}
</p>
<p className="mt-1 text-lg font-semibold text-foreground">
{value}
</p>
</div>
)
}
function JsonBlock({
label,
value,
defaultOpen = true,
}: {
label: string
value: unknown
defaultOpen?: boolean
}) {
const [open, setOpen] = React.useState(defaultOpen)
return (
<div className="overflow-hidden rounded-xl border border-border">
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="flex w-full items-center justify-between bg-muted px-4 py-3 text-sm font-medium text-foreground hover:bg-accent focus:outline-none focus:ring-2 focus:ring-inset focus:ring-ring"
aria-expanded={open}
data-qqq-id="button-json-block-toggle"
>
<span className="flex items-center gap-2">
<Code className="h-4 w-4" aria-hidden="true" />
{label}
</span>
{open ? (
<ChevronUp className="h-4 w-4" aria-hidden="true" />
) : (
<ChevronDown className="h-4 w-4" aria-hidden="true" />
)}
</button>
{open && (
<pre
className="overflow-x-auto bg-gray-900 p-4 text-xs text-green-300"
data-qqq-id="json-output"
>
{JSON.stringify(value, null, 2)}
</pre>
)}
</div>
)
}
@@ -0,0 +1,26 @@
'use client'
// RecordViewByUniqueKey — view a record by unique key
// Full implementation in Package 3
import React, { useEffect } from 'react'
import { useParams } from 'next/navigation'
import { useQContext } from '@/lib/context/q-context'
export default function RecordViewByKeyPage() {
const params = useParams<{ slug: string }>()
const { setPageHeader } = useQContext()
const slug = params.slug
useEffect(() => {
setPageHeader(`View ${slug} by Key`)
}, [slug, setPageHeader])
return (
<div data-qqq-id={`record-view-key-${slug}`}>
<div className="rounded-xl border border-dashed border-border bg-muted p-12 text-center">
<p className="text-muted-foreground">Record view by key -- implemented in Package 3</p>
</div>
</div>
)
}
+133
View File
@@ -0,0 +1,133 @@
'use client'
// Unified slug page — routes to AppHome, RecordQuery, or ProcessRun
// based on what the slug resolves to in the app metadata
import React, { useEffect } from 'react'
import { useParams } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { FileBarChart } from 'lucide-react'
import { useQContext } from '@/lib/context/q-context'
import { loadMetaData } from '@/lib/api/metadata'
import { queryKeys } from '@/lib/query-client'
import { getProcessesForTable } from '@/lib/utils/process-utils'
import { RecordQuery } from '@/components/query'
import { ProcessRun } from '@/components/process'
import { AppHome } from '@/components/widgets'
export default function SlugPage() {
const params = useParams<{ slug: string }>()
const { setPageHeader, setTableMetaData } = useQContext()
const slug = params.slug
const { data: metaData } = useQuery({
queryKey: queryKeys.metadataAll(),
queryFn: loadMetaData,
staleTime: 1000 * 60 * 30,
})
// Determine what this slug is
const isApp = Boolean(metaData?.apps?.[slug])
const isTable = Boolean(metaData?.tables?.[slug])
const isProcess = Boolean(metaData?.processes?.[slug])
const isReport = Boolean(metaData?.reports?.[slug])
const app = metaData?.apps?.[slug]
const table = metaData?.tables?.[slug]
const process = metaData?.processes?.[slug]
const report = metaData?.reports?.[slug]
useEffect(() => {
if (isApp) {
setPageHeader(app?.label ?? slug)
} else if (isTable) {
setPageHeader(table?.label ?? slug)
if (table) setTableMetaData(table)
} else if (isProcess) {
setPageHeader(process?.label ?? slug)
} else if (isReport) {
setPageHeader((report as { label?: string })?.label ?? slug)
} else {
setPageHeader(slug)
}
}, [isApp, isTable, isProcess, isReport, app, table, process, report, slug, setPageHeader, setTableMetaData])
if (!metaData) {
return (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
}
// App dashboard — Package 5 implementation
if (isApp && app) {
return (
<AppHome
appMetaData={app}
widgetRegistry={metaData.widgets ?? {}}
/>
)
}
// Table record query — Package 2 implementation
if (isTable && table) {
const tableProcesses = getProcessesForTable(metaData, slug)
return <RecordQuery tableName={slug} tableMetaData={table} processes={tableProcesses} />
}
// Table loading state (table found but metadata not yet available)
if (isTable && !table) {
return (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
}
// Process run — Package 4 implementation
if (isProcess && process) {
return <ProcessRun processName={slug} processMetaData={process} />
}
// Process loading state (process found but metadata not yet available)
if (isProcess && !process) {
return (
<div className="flex items-center justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
}
// Report run
if (isReport) {
return (
<div className="space-y-6" data-qqq-id={`report-run-${slug}`}>
<div className="flex items-center gap-3">
<FileBarChart className="h-6 w-6 text-muted-foreground" aria-hidden="true" />
<h2 className="text-2xl font-semibold text-foreground">
{(report as { label?: string })?.label ?? slug}
</h2>
</div>
<div className="rounded-xl border border-dashed border-border bg-muted p-12 text-center">
<p className="mt-4 text-muted-foreground">
Report — implemented in a future package
</p>
</div>
</div>
)
}
// Unknown slug
return (
<div className="space-y-4" data-qqq-id={`unknown-slug-${slug}`}>
<h2 className="text-2xl font-semibold text-foreground">{slug}</h2>
<div className="rounded-xl border border-dashed border-border bg-muted p-12 text-center">
<p className="text-muted-foreground">
Unknown resource: <code className="font-mono">{slug}</code>
</p>
</div>
</div>
)
}
@@ -0,0 +1,31 @@
'use client'
// RecordQuery with Saved View — loads a saved filter/view configuration
// Full implementation in Package 2
import React, { useEffect } from 'react'
import { useParams } from 'next/navigation'
import { useQContext } from '@/lib/context/q-context'
export default function SavedViewPage() {
const params = useParams<{ slug: string; viewId: string }>()
const { setPageHeader } = useQContext()
const { slug, viewId } = params
useEffect(() => {
setPageHeader(`${slug} — Saved View`)
}, [slug, setPageHeader])
return (
<div data-qqq-id={`saved-view-${slug}-${viewId}`}>
<div className="rounded-xl border border-dashed border-border bg-muted p-12 text-center">
<p className="text-muted-foreground">
Saved view -- implemented in Package 2
</p>
<p className="mt-1 text-sm text-muted-foreground">
View ID: <code className="font-mono">{viewId}</code>
</p>
</div>
</div>
)
}
+334
View File
@@ -0,0 +1,334 @@
'use client'
// Dashboard — top-level home page showing system overview
// Demonstrates: stats, recent records, quick actions, app navigation, widget rendering
import React, { useEffect, useState } from 'react'
import Link from 'next/link'
import { useQuery } from '@tanstack/react-query'
import {
LayoutDashboard,
Table2,
Workflow,
Clock,
ArrowRight,
Plus,
Activity,
Database,
Layers,
TrendingUp,
Users,
FileText,
} from 'lucide-react'
import { loadMetaData } from '@/lib/api/metadata'
import { queryKeys } from '@/lib/query-client'
import { useQContext } from '@/lib/context/q-context'
import { useAuth } from '@/lib/auth/use-auth'
import { useAppTreeRoutes } from '@/lib/hooks/use-routes'
import { getRecentRecords } from '@/lib/utils/recent-records'
import type { RecentRecord } from '@/lib/utils/recent-records'
import type { QWidgetMetaData } from '@/types'
import { ConnectedWidget } from '@/components/widgets/ConnectedWidget'
export default function DashboardPage() {
const { setPageHeader } = useQContext()
const { user } = useAuth()
const [recentRecords, setRecentRecords] = useState<RecentRecord[]>([])
const { data: metaData } = useQuery({
queryKey: queryKeys.metadataAll(),
queryFn: loadMetaData,
staleTime: 1000 * 60 * 30,
})
const { sidebarRoutes } = useAppTreeRoutes(metaData)
const appRoutes = sidebarRoutes.filter((r) => r.path !== '/app')
const appName = metaData?.branding?.appName || 'QQQ Admin'
useEffect(() => {
setPageHeader('Dashboard')
}, [setPageHeader])
// Load recent records on mount
useEffect(() => {
setRecentRecords(getRecentRecords().slice(0, 8))
}, [])
// Compute system stats from metadata
const tableCount = metaData ? Object.keys(metaData.tables).length : 0
const processCount = metaData ? Object.keys(metaData.processes).length : 0
const appCount = metaData ? Object.keys(metaData.apps).length : 0
const widgetCount = metaData ? Object.keys(metaData.widgets).length : 0
// Gather tables that support create for quick actions
const creatableTables = metaData
? Object.values(metaData.tables)
.filter((t) => !t.isHidden && t.insertPermission && t.capabilities?.includes('TABLE_INSERT'))
.slice(0, 6)
: []
// Gather runnable processes for quick actions
const runnableProcesses = metaData
? Object.values(metaData.processes)
.filter((p) => !p.isHidden)
.slice(0, 4)
: []
// Gather home-level widgets (widgets that are on any app's home)
const homeWidgets: QWidgetMetaData[] = []
if (metaData) {
const seen = new Set<string>()
for (const app of Object.values(metaData.apps)) {
for (const wName of app.widgets) {
if (!seen.has(wName) && metaData.widgets[wName]?.hasPermission) {
seen.add(wName)
homeWidgets.push(metaData.widgets[wName])
}
}
}
}
// Time-based greeting
const hour = new Date().getHours()
const greeting = hour < 12 ? 'Good morning' : hour < 17 ? 'Good afternoon' : 'Good evening'
const firstName = user?.name?.split(' ')[0] || ''
// Format relative time
function timeAgo(timestamp: number): string {
const diff = Date.now() - timestamp
const minutes = Math.floor(diff / 60000)
if (minutes < 1) return 'Just now'
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}h ago`
const days = Math.floor(hours / 24)
return `${days}d ago`
}
return (
<div className="space-y-8" data-qqq-id="dashboard-home">
{/* Welcome header */}
<div>
<h1 className="text-2xl font-bold tracking-tight text-foreground">
{greeting}{firstName ? `, ${firstName}` : ''}
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Here&apos;s an overview of your {appName} system.
</p>
</div>
{/* Stats cards */}
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<div className="rounded-xl border border-border bg-card p-5 shadow-sm">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Tables</span>
<Database className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</div>
<p className="mt-2 text-3xl font-bold text-foreground">{tableCount}</p>
<p className="mt-1 text-xs text-muted-foreground">Data tables available</p>
</div>
<div className="rounded-xl border border-border bg-card p-5 shadow-sm">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Processes</span>
<Workflow className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</div>
<p className="mt-2 text-3xl font-bold text-foreground">{processCount}</p>
<p className="mt-1 text-xs text-muted-foreground">Automated workflows</p>
</div>
<div className="rounded-xl border border-border bg-card p-5 shadow-sm">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Applications</span>
<Layers className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</div>
<p className="mt-2 text-3xl font-bold text-foreground">{appCount}</p>
<p className="mt-1 text-xs text-muted-foreground">App modules</p>
</div>
<div className="rounded-xl border border-border bg-card p-5 shadow-sm">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Widgets</span>
<TrendingUp className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
</div>
<p className="mt-2 text-3xl font-bold text-foreground">{widgetCount}</p>
<p className="mt-1 text-xs text-muted-foreground">Dashboard components</p>
</div>
</div>
{/* Two-column layout: Recent Records + Quick Actions */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Recent Records — takes 2 columns */}
<div className="lg:col-span-2 rounded-xl border border-border bg-card shadow-sm">
<div className="flex items-center justify-between border-b border-border px-6 py-4">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
<h2 className="text-lg font-bold text-foreground">Recently Viewed</h2>
</div>
</div>
<div className="divide-y divide-border">
{recentRecords.length > 0 ? (
recentRecords.map((record) => (
<Link
key={record.path}
href={record.path}
className="flex items-center justify-between px-6 py-3.5 transition-colors hover:bg-accent"
data-qqq-id={`dashboard-recent-${record.recordId}`}
>
<div className="flex items-center gap-3 min-w-0">
<div className="flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold text-muted-foreground">
{record.recordLabel.slice(0, 2).toUpperCase()}
</div>
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{record.recordLabel}
</p>
<p className="text-xs text-muted-foreground">{record.tableLabel}</p>
</div>
</div>
<span className="flex-shrink-0 text-xs text-muted-foreground">
{timeAgo(record.viewedAt)}
</span>
</Link>
))
) : (
<div className="px-6 py-12 text-center">
<Activity className="mx-auto h-8 w-8 text-muted-foreground/40" aria-hidden="true" />
<p className="mt-2 text-sm text-muted-foreground">No recently viewed records</p>
<p className="text-xs text-muted-foreground">Records you view will appear here</p>
</div>
)}
</div>
</div>
{/* Quick Actions — takes 1 column */}
<div className="rounded-xl border border-border bg-card shadow-sm">
<div className="border-b border-border px-6 py-4">
<h2 className="text-lg font-bold text-foreground">Quick Actions</h2>
</div>
<div className="p-4 space-y-2">
{creatableTables.map((table) => (
<Link
key={table.name}
href={`/app/${table.name}/create`}
className="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-foreground transition-colors hover:bg-accent"
data-qqq-id={`dashboard-create-${table.name}`}
>
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-primary/10 text-primary">
<Plus className="h-3.5 w-3.5" aria-hidden="true" />
</div>
Create {table.label}
</Link>
))}
{runnableProcesses.map((process) => (
<Link
key={process.name}
href={`/app/${process.name}`}
className="flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium text-foreground transition-colors hover:bg-accent"
data-qqq-id={`dashboard-process-${process.name}`}
>
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-chart-2/10 text-chart-2">
<Workflow className="h-3.5 w-3.5" aria-hidden="true" />
</div>
{process.label}
</Link>
))}
{creatableTables.length === 0 && runnableProcesses.length === 0 && (
<div className="py-8 text-center">
<p className="text-sm text-muted-foreground">No quick actions available</p>
</div>
)}
</div>
</div>
</div>
{/* Home Widgets — rendered from metadata */}
{homeWidgets.length > 0 && (
<div className="space-y-4">
<h2 className="text-lg font-bold text-foreground">Widgets</h2>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{homeWidgets.slice(0, 6).map((widget) => (
<ConnectedWidget key={widget.name} widgetMetaData={widget} />
))}
</div>
</div>
)}
{/* Applications overview */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-bold text-foreground">Applications</h2>
</div>
{appRoutes.length > 0 ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{appRoutes.map((route) => (
<Link
key={route.path}
href={route.children?.[0]?.path ?? route.path}
className="group rounded-xl border border-border bg-card p-5 shadow-sm transition-all hover:border-primary/40 hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-qqq-id={`dashboard-app-${route.name}`}
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 text-primary">
<LayoutDashboard className="h-5 w-5" aria-hidden="true" />
</div>
<div>
<h3 className="text-base font-semibold text-foreground group-hover:text-primary transition-colors">
{route.name}
</h3>
{route.children && route.children.length > 0 && (
<p className="text-xs text-muted-foreground">
{route.children.length} {route.children.length === 1 ? 'item' : 'items'}
</p>
)}
</div>
</div>
<ArrowRight className="h-4 w-4 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100" aria-hidden="true" />
</div>
{/* Show child items preview */}
{route.children && route.children.length > 0 && (
<div className="mt-4 flex flex-wrap gap-1.5">
{route.children.slice(0, 5).map((child) => (
<span
key={child.path}
className="inline-flex items-center gap-1 rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground"
>
<Table2 className="h-3 w-3" aria-hidden="true" />
{child.name}
</span>
))}
{route.children.length > 5 && (
<span className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-xs text-muted-foreground">
+{route.children.length - 5} more
</span>
)}
</div>
)}
</Link>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-border bg-muted/30 py-16 text-center">
<p className="text-muted-foreground">No applications available</p>
</div>
)}
</div>
{/* System info footer */}
<div className="flex items-center gap-6 rounded-xl border border-border bg-card px-6 py-4 shadow-sm">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<FileText className="h-4 w-4" aria-hidden="true" />
<span>{tableCount} tables</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Workflow className="h-4 w-4" aria-hidden="true" />
<span>{processCount} processes</span>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Users className="h-4 w-4" aria-hidden="true" />
<span>Logged in as {user?.email || 'unknown'}</span>
</div>
</div>
</div>
)
}
+239
View File
@@ -0,0 +1,239 @@
'use client'
// Search results page — displays global search results grouped by table
// Reads search query from URL params: /app/search?q=searchTerm
import React, { useState, useEffect, useMemo, useCallback } from 'react'
import { useSearchParams, useRouter } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import Link from 'next/link'
import { Search } from 'lucide-react'
import { globalSearch, type GlobalSearchResult } from '@/lib/api/tables'
import { queryKeys } from '@/lib/query-client'
import { useQContext } from '@/lib/context/q-context'
/** Group search results by tableLabel */
function groupByTable(
results: GlobalSearchResult[]
): Map<string, { tableName: string; records: GlobalSearchResult[] }> {
const groups = new Map<string, { tableName: string; records: GlobalSearchResult[] }>()
for (const result of results) {
const existing = groups.get(result.tableLabel)
if (existing) {
existing.records.push(result)
} else {
groups.set(result.tableLabel, {
tableName: result.tableName,
records: [result],
})
}
}
return groups
}
export default function SearchResultsPage() {
const searchParams = useSearchParams()
const router = useRouter()
const { setPageHeader } = useQContext()
const queryParam = searchParams.get('q') ?? ''
const [inputValue, setInputValue] = useState(queryParam)
const [debouncedTerm, setDebouncedTerm] = useState(queryParam)
// Keep input value in sync when URL param changes externally
useEffect(() => {
setInputValue(queryParam)
setDebouncedTerm(queryParam)
}, [queryParam])
// Debounce the input value by 300ms
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedTerm(inputValue)
}, 300)
return () => clearTimeout(timer)
}, [inputValue])
// Update URL when debounced term changes (without full navigation)
useEffect(() => {
if (debouncedTerm !== queryParam) {
const params = new URLSearchParams(searchParams.toString())
if (debouncedTerm) {
params.set('q', debouncedTerm)
} else {
params.delete('q')
}
router.replace(`/app/search?${params.toString()}`, { scroll: false })
}
}, [debouncedTerm, queryParam, searchParams, router])
// Set page header
useEffect(() => {
setPageHeader('Search Results')
}, [setPageHeader])
const { data: results, isLoading } = useQuery({
queryKey: queryKeys.globalSearch(debouncedTerm),
queryFn: () => globalSearch(debouncedTerm),
enabled: debouncedTerm.length > 0,
})
const grouped = useMemo<
Map<string, { tableName: string; records: GlobalSearchResult[] }>
>(() => {
if (!results || results.length === 0) return new Map()
return groupByTable(results)
}, [results])
const handleInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value)
},
[]
)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
setDebouncedTerm(inputValue)
}
},
[inputValue]
)
const hasResults = results && results.length > 0
const hasSearched = debouncedTerm.length > 0
const showEmpty = hasSearched && !isLoading && !hasResults
return (
<div className="space-y-6" data-qqq-id="search-results-page">
{/* Page heading */}
{debouncedTerm ? (
<h1 className="text-2xl font-bold tracking-tight text-foreground">
Search Results for &ldquo;{debouncedTerm}&rdquo;
</h1>
) : (
<h1 className="text-2xl font-bold tracking-tight text-foreground">
Search
</h1>
)}
{/* Search input */}
<div className="relative">
<Search
className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<input
type="search"
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder="Search across all tables..."
aria-label="Search across all tables"
className="rounded-xl border border-input bg-card pl-10 pr-4 py-3 text-sm w-full focus:outline-none focus:ring-2 focus:ring-ring"
data-qqq-id="search-input"
/>
</div>
{/* Loading state */}
{isLoading && (
<div className="space-y-6" aria-busy="true" aria-live="polite">
{/* Skeleton group 1 */}
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="px-6 py-3 border-b border-border">
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
</div>
<div className="divide-y divide-border">
{[1, 2, 3].map((i) => (
<div key={i} className="px-6 py-3">
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
</div>
))}
</div>
</div>
{/* Skeleton group 2 */}
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
<div className="px-6 py-3 border-b border-border">
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
</div>
<div className="divide-y divide-border">
{[1, 2].map((i) => (
<div key={i} className="px-6 py-3">
<div className="h-4 w-2/3 animate-pulse rounded bg-muted" />
</div>
))}
</div>
</div>
</div>
)}
{/* Empty state */}
{showEmpty && (
<div
className="flex flex-col items-center justify-center rounded-xl border border-border bg-card py-16 shadow-sm"
data-qqq-id="search-empty-state"
>
<Search className="h-12 w-12 text-muted-foreground/50" aria-hidden="true" />
<p className="mt-4 text-sm text-muted-foreground">
No results found for &ldquo;{debouncedTerm}&rdquo;
</p>
<p className="mt-1 text-xs text-muted-foreground">
Try adjusting your search terms or check for typos.
</p>
</div>
)}
{/* No query state */}
{!hasSearched && !isLoading && (
<div
className="flex flex-col items-center justify-center rounded-xl border border-border bg-card py-16 shadow-sm"
data-qqq-id="search-prompt-state"
>
<Search className="h-12 w-12 text-muted-foreground/50" aria-hidden="true" />
<p className="mt-4 text-sm text-muted-foreground">
Enter a search term above to find records across all tables.
</p>
</div>
)}
{/* Results grouped by table */}
{!isLoading && hasResults && (
<div className="space-y-6">
{Array.from(grouped.entries()).map(([tableLabel, group]) => (
<div
key={tableLabel}
className="rounded-xl border border-border bg-card shadow-sm overflow-hidden"
data-qqq-id={`search-group-${group.tableName}`}
>
{/* Group header */}
<div className="flex items-center gap-2 px-6 py-3 border-b border-border">
<span className="text-sm font-semibold text-foreground">
{tableLabel}
</span>
<span className="inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
{group.records.length} {group.records.length === 1 ? 'result' : 'results'}
</span>
</div>
{/* Result rows */}
<div className="divide-y divide-border">
{group.records.map((result) => (
<Link
key={`${result.tableName}-${result.recordId}`}
href={`/app/${encodeURIComponent(result.tableName)}/${encodeURIComponent(result.recordId)}`}
className="block px-6 py-3 hover:bg-accent cursor-pointer transition-colors text-sm text-foreground"
data-qqq-id={`search-result-${result.tableName}-${result.recordId}`}
>
{result.recordLabel}
</Link>
))}
</div>
</div>
))}
</div>
)}
</div>
)
}
+296
View File
@@ -0,0 +1,296 @@
'use client'
// Dashboard layout — authenticated route group layout
// Renders sidebar, header, breadcrumbs, and banner zones
// Includes: mobile sidebar drawer, command palette (Cmd+K), skip link, customCss injection
import React, { useEffect, useState, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { useAuth } from '@/lib/auth/use-auth'
import { useQContext, QContextProvider } from '@/lib/context/q-context'
import { useAppTreeRoutes } from '@/lib/hooks/use-routes'
import { loadMetaData } from '@/lib/api/metadata'
import { queryKeys } from '@/lib/query-client'
import Sidebar from '@/components/layout/Sidebar'
import Header from '@/components/layout/Header'
import BannerComponent from '@/components/layout/Banner'
import { CommandMenu } from '@/components/feedback/CommandMenu'
import { SearchDialog } from '@/components/feedback/SearchDialog'
import { KeyboardShortcutsDialog } from '@/components/feedback/KeyboardShortcutsDialog'
// Inner layout content — needs QContextProvider to be set up first
function DashboardLayoutContent({ children }: { children: React.ReactNode }) {
const router = useRouter()
const { isAuthenticated, isLoading: authLoading, user, logout } = useAuth()
const { setPathToLabelMap, setBranding, setAccentColor, setUserId } = useQContext()
// Mobile sidebar drawer state
const [sidebarOpen, setSidebarOpen] = useState(false)
// Command palette state
const [commandOpen, setCommandOpen] = useState(false)
// Search dialog state (/ key)
const [searchOpen, setSearchOpen] = useState(false)
// Keyboard shortcuts help dialog state
const [helpOpen, setHelpOpen] = useState(false)
// Load full application metadata
const {
data: metaData,
isLoading: metaLoading,
error: metaError,
} = useQuery({
queryKey: queryKeys.metadataAll(),
queryFn: loadMetaData,
staleTime: 1000 * 60 * 30, // 30 minutes
enabled: isAuthenticated,
})
// Generate sidebar routes and path map from app tree
const { sidebarRoutes, pathToLabelMap, parentAppMap } = useAppTreeRoutes(metaData)
// Sync pathToLabelMap to QContext
useEffect(() => {
if (Object.keys(pathToLabelMap).length > 0) {
setPathToLabelMap(pathToLabelMap)
}
}, [pathToLabelMap, setPathToLabelMap])
// Sync branding to QContext and inject theme
useEffect(() => {
if (metaData?.branding) {
setBranding(metaData.branding)
if (metaData.branding.accentColor) {
setAccentColor(metaData.branding.accentColor)
document.documentElement.style.setProperty(
'--qqq-accent-color',
metaData.branding.accentColor
)
document.documentElement.style.setProperty(
'--color-primary',
metaData.branding.accentColor
)
document.documentElement.style.setProperty(
'--primary',
metaData.branding.accentColor
)
document.documentElement.style.setProperty(
'--ring',
metaData.branding.accentColor
)
document.documentElement.style.setProperty(
'--qqq-sidebar-active-bg',
metaData.branding.accentColor
)
}
// Update favicon from branding
if (metaData.branding.icon) {
const favicon = document.querySelector("link[rel~='icon']") as HTMLLinkElement | null
if (favicon) {
favicon.href = metaData.branding.icon
}
}
// Update document title
if (metaData.branding.appName) {
document.title = metaData.branding.appName
}
}
}, [metaData?.branding, setBranding, setAccentColor])
// Inject customCss from branding metadata
useEffect(() => {
if (metaData?.branding?.customCss) {
const styleId = 'qqq-custom-css'
let styleTag = document.getElementById(styleId) as HTMLStyleElement | null
if (!styleTag) {
styleTag = document.createElement('style')
styleTag.id = styleId
document.head.appendChild(styleTag)
}
styleTag.textContent = metaData.branding.customCss
}
}, [metaData?.branding?.customCss])
// Sync user ID to QContext
useEffect(() => {
if (user?.email) {
setUserId(user.email)
}
}, [user?.email, setUserId])
// Redirect to login if not authenticated
useEffect(() => {
if (!authLoading && !isAuthenticated) {
const returnTo = encodeURIComponent(window.location.pathname + window.location.search)
router.push(`/login?returnTo=${returnTo}`)
}
}, [isAuthenticated, authLoading, router])
// Guard: check if focus is in a text input, textarea, select, or contentEditable
const isInputFocused = useCallback(() => {
const tag = (document.activeElement?.tagName || '').toLowerCase()
const type = (document.activeElement as HTMLInputElement)?.type || ''
const isEditable = (document.activeElement as HTMLElement)?.isContentEditable
return tag === 'input' || tag === 'textarea' || tag === 'select' || type === 'search' || isEditable
}, [])
// Global keyboard shortcut: Cmd+K / Ctrl+K → command palette, "." → quick nav, "?" → help
const handleGlobalKeyDown = useCallback((e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
setCommandOpen((prev) => !prev)
}
if (e.key === 'Escape') {
setCommandOpen(false)
setSearchOpen(false)
setHelpOpen(false)
setSidebarOpen(false)
}
// Single-key shortcuts — only when not focused in a text field
if (!isInputFocused() && !e.metaKey && !e.ctrlKey && !e.altKey) {
if (e.key === '.') {
e.preventDefault()
setCommandOpen(true)
}
if (e.key === '/') {
e.preventDefault()
setSearchOpen(true)
}
if (e.key === '?') {
e.preventDefault()
setHelpOpen(true)
}
}
}, [isInputFocused])
useEffect(() => {
document.addEventListener('keydown', handleGlobalKeyDown)
return () => document.removeEventListener('keydown', handleGlobalKeyDown)
}, [handleGlobalKeyDown])
// Show loading state while auth initializes
if (authLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<div
className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"
role="status"
aria-label="Loading"
/>
</div>
)
}
// Auth loaded but not authenticated — redirect handled above
if (!isAuthenticated) {
return null
}
// Show error if metadata fails to load
if (metaError) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<p className="text-destructive">Failed to load application metadata.</p>
<p className="mt-1 text-sm text-muted-foreground">
{metaError instanceof Error ? metaError.message : 'Unknown error'}
</p>
</div>
</div>
)
}
return (
<div className="flex h-screen overflow-hidden bg-background">
{/* Skip to main content — accessibility */}
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:fixed focus:left-4 focus:top-4 focus:z-[9999] focus:rounded-md focus:bg-primary focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-primary-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
data-qqq-id="skip-to-content"
>
Skip to main content
</a>
{/* Desktop Sidebar */}
<Sidebar
routes={sidebarRoutes}
branding={metaData?.branding}
logout={logout}
userName={user?.name}
userEmail={user?.email}
data-qqq-id="sidebar-container"
/>
{/* Mobile Sidebar Drawer */}
<Sidebar
routes={sidebarRoutes}
branding={metaData?.branding}
logout={logout}
userName={user?.name}
userEmail={user?.email}
open={sidebarOpen}
onClose={() => setSidebarOpen(false)}
data-qqq-id="sidebar-mobile"
/>
{/* Main content area */}
<div className="flex flex-1 flex-col overflow-hidden">
{/* Environment/status banners */}
{metaData?.branding?.banners &&
Object.keys(metaData.branding.banners).length > 0 && (
<BannerComponent banners={metaData.branding.banners} />
)}
{/* Header — breadcrumbs + search in one row */}
<Header
appName={metaData?.branding?.appName}
onMenuOpen={() => setSidebarOpen(true)}
pathToLabelMap={pathToLabelMap}
parentAppMap={parentAppMap}
/>
{/* Page content */}
<main className="flex-1 overflow-y-auto p-6" id="main-content" data-qqq-id="main-content">
{metaLoading ? (
<div
className="flex items-center justify-center py-12"
role="status"
aria-label="Loading content"
aria-live="polite"
>
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : (
children
)}
</main>
</div>
{/* Command Palette */}
<CommandMenu open={commandOpen} onClose={() => setCommandOpen(false)} />
{/* Search Dialog */}
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} />
{/* Keyboard Shortcuts Help Dialog */}
<KeyboardShortcutsDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
</div>
)
}
// Outer layout — provides QContext to inner layout
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<QContextProvider>
<DashboardLayoutContent>{children}</DashboardLayoutContent>
</QContextProvider>
)
}
+86
View File
@@ -0,0 +1,86 @@
'use client'
// Global error boundary — catches unhandled exceptions at route level
// Detects 401 errors and provides appropriate messaging
import React, { useEffect } from 'react'
import Link from 'next/link'
import { AlertCircle, RefreshCw } from 'lucide-react'
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error('[GlobalError]', error)
}, [error])
const is401 =
error.message?.includes('401') ||
error.message?.toLowerCase().includes('unauthorized') ||
error.message?.toLowerCase().includes('session expired')
if (is401) {
return (
<div className="flex min-h-screen items-center justify-center bg-muted">
<div className="w-full max-w-md rounded-xl border border-border bg-card p-8 shadow-sm">
<div className="flex flex-col items-center gap-4 text-center">
<AlertCircle className="h-12 w-12 text-amber-500" aria-hidden="true" />
<h1 className="text-2xl font-bold text-foreground">
Session Expired
</h1>
<p className="text-muted-foreground">
Your session has expired. Please log in again to continue.
</p>
<Link
href="/login"
className="mt-2 inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
data-qqq-id="button-go-to-login"
>
Go to Login
</Link>
</div>
</div>
</div>
)
}
return (
<div className="flex min-h-screen items-center justify-center bg-muted">
<div className="w-full max-w-md rounded-xl border border-border bg-card p-8 shadow-sm">
<div className="flex flex-col items-center gap-4 text-center">
<AlertCircle className="h-12 w-12 text-destructive" aria-hidden="true" />
<h1 className="text-2xl font-bold text-foreground">
Something went wrong
</h1>
<p className="text-sm text-muted-foreground">
{error.message || 'An unexpected error occurred.'}
</p>
{error.digest && (
<p className="text-xs text-muted-foreground">Error ID: {error.digest}</p>
)}
<div className="flex gap-3">
<button
onClick={reset}
className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
data-qqq-id="button-try-again"
>
<RefreshCw className="h-4 w-4" aria-hidden="true" />
Try Again
</button>
<Link
href="/"
className="inline-flex items-center gap-2 rounded-md border border-border px-4 py-2 text-sm font-medium text-foreground hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-qqq-id="link-go-home"
>
Go Home
</Link>
</div>
</div>
</div>
</div>
)
}
+37
View File
@@ -0,0 +1,37 @@
// Root layout — wraps entire application with providers
// This is a Server Component that sets up client-side providers
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import Providers from './providers'
import '@/styles/globals.css'
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
})
export const metadata: Metadata = {
title: {
default: 'QQQ Admin',
template: '%s | QQQ Admin',
},
description: 'Low-code application admin interface',
icons: {
icon: '/favicon.ico',
},
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className={inter.variable} suppressHydrationWarning>
<body className="font-sans antialiased" suppressHydrationWarning>
<Providers>{children}</Providers>
</body>
</html>
)
}
+28
View File
@@ -0,0 +1,28 @@
// 404 Not Found page
import Link from 'next/link'
import { SearchX } from 'lucide-react'
export default function NotFound() {
return (
<div className="flex min-h-screen items-center justify-center bg-muted">
<div className="flex flex-col items-center gap-6 text-center">
<SearchX className="h-16 w-16 text-muted-foreground" aria-hidden="true" />
<div>
<h1 className="text-6xl font-bold text-foreground">404</h1>
<p className="mt-2 text-xl text-muted-foreground">Page not found</p>
<p className="mt-1 text-sm text-muted-foreground">
The page you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
</div>
<Link
href="/"
className="inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
data-qqq-id="link-go-home"
>
Go Home
</Link>
</div>
</div>
)
}
+8
View File
@@ -0,0 +1,8 @@
// Root page — redirects to dashboard or login
import { redirect } from 'next/navigation'
export default function RootPage() {
// Redirect to the dashboard — the (dashboard) layout handles auth
redirect('/app')
}
+63
View File
@@ -0,0 +1,63 @@
'use client'
// Client-side providers wrapper
// Separated from root layout to avoid Server Component issues with useEffect/contexts
import React, { useEffect, useState, type ReactNode } from 'react'
import { QueryClientProvider } from '@tanstack/react-query'
import { queryClient } from '@/lib/query-client'
import { AuthProvider } from '@/lib/auth/auth-provider'
import { ThemeProvider } from '@/lib/theme/theme-provider'
import { injectThemeTokens } from '@/lib/theme/tokens'
import { Toaster } from '@/components/feedback/Toast'
// Inject default theme tokens on first render
if (typeof window !== 'undefined') {
injectThemeTokens()
}
const isMockMode = process.env.NEXT_PUBLIC_MOCK_API === 'true'
export default function Providers({ children }: { children: ReactNode }) {
// When mock mode is enabled, block rendering until the MSW service worker
// is fully started — otherwise AuthProvider fires its first API call before
// MSW is ready and gets a real 404.
const [mockReady, setMockReady] = useState(!isMockMode)
useEffect(() => {
if (!isMockMode) return
import('@/mocks/init')
.then(({ initMocks }) => initMocks())
.then(() => setMockReady(true))
.catch((e) => {
console.error('[MSW] Failed to start mock worker:', e)
setMockReady(true) // unblock the app anyway
})
}, [])
if (!mockReady) {
return (
<div className="flex min-h-screen items-center justify-center gap-3 text-indigo-500">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-indigo-500 border-t-transparent" />
<span className="text-sm font-medium">Starting mock API…</span>
</div>
)
}
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider>
<AuthProvider>{children}</AuthProvider>
</ThemeProvider>
<Toaster
position="bottom-right"
richColors
closeButton
toastOptions={{ duration: 4000 }}
aria-live="polite"
data-qqq-id="toast-container"
/>
</QueryClientProvider>
)
}
+209
View File
@@ -0,0 +1,209 @@
'use client'
// CommandMenu — Cmd+K / Ctrl+K command palette
// Fuzzy-searches all sidebar routes from QContext
// Navigate with arrow keys, Enter to navigate, Escape to close
import React, { useEffect, useState, useCallback } from 'react'
import { Command } from 'cmdk'
import { useRouter } from 'next/navigation'
import { Search, Table2, Workflow, BarChart3, LayoutGrid, X } from 'lucide-react'
import { useQContext } from '@/lib/context/q-context'
import { cn } from '@/lib/utils/cn'
interface CommandMenuItem {
id: string
label: string
path: string
type: 'app' | 'table' | 'process' | 'report'
breadcrumb?: string
}
function buildMenuItems(pathToLabelMap: Record<string, string>): CommandMenuItem[] {
const items: CommandMenuItem[] = []
for (const [path, label] of Object.entries(pathToLabelMap)) {
// Skip parametric paths and dev/utility pages
if (path.includes(':') || path.includes('/create') || path.includes('/dev') || path.includes('/key') || path.includes('/savedView')) {
continue
}
const parts = path.split('/').filter(Boolean) // e.g. ['app', 'employees'] or ['app', 'crm', 'employees']
let type: CommandMenuItem['type'] = 'table'
let breadcrumb: string | undefined
if (parts.length === 2 && parts[0] === 'app') {
// Could be app home or table
type = 'app'
} else if (parts.length === 3) {
type = 'table'
const parentPath = `/${parts[0]}/${parts[1]}`
breadcrumb = pathToLabelMap[parentPath]
}
items.push({
id: path,
label,
path,
type,
breadcrumb,
})
}
return items
}
function TypeIcon({ type }: { type: CommandMenuItem['type'] }) {
switch (type) {
case 'app':
return <LayoutGrid className="h-4 w-4 text-blue-500" aria-hidden="true" />
case 'table':
return <Table2 className="h-4 w-4 text-green-500" aria-hidden="true" />
case 'process':
return <Workflow className="h-4 w-4 text-purple-500" aria-hidden="true" />
case 'report':
return <BarChart3 className="h-4 w-4 text-orange-500" aria-hidden="true" />
}
}
interface CommandMenuProps {
open: boolean
onClose: () => void
}
export function CommandMenu({ open, onClose }: CommandMenuProps) {
const router = useRouter()
const { pathToLabelMap } = useQContext()
const [search, setSearch] = useState('')
const items = buildMenuItems(pathToLabelMap)
// Clear search when closed
useEffect(() => {
if (!open) {
setSearch('')
}
}, [open])
const handleSelect = useCallback(
(path: string) => {
router.push(path)
onClose()
},
[router, onClose]
)
if (!open) return null
return (
// Backdrop
<div
className="fixed inset-0 z-[2000] flex items-start justify-center pt-[10vh] sm:pt-[15vh]"
data-qqq-id="command-menu-backdrop"
>
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
{/* Command palette */}
<div
className="relative z-10 w-full max-w-lg rounded-xl border border-border bg-card shadow-lg"
data-qqq-id="command-menu"
role="dialog"
aria-label="Command palette"
aria-modal="true"
>
<Command shouldFilter={true} label="Command palette">
{/* Search input */}
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<Command.Input
value={search}
onValueChange={setSearch}
placeholder="Search pages, tables, processes..."
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
autoFocus
data-qqq-id="command-menu-search"
/>
<button
type="button"
onClick={onClose}
className="rounded p-1 text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
aria-label="Close command palette"
data-qqq-id="button-command-menu-close"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</div>
{/* Results list */}
<Command.List
className="max-h-80 overflow-y-auto py-2"
aria-label="Navigation results"
aria-live="polite"
>
<Command.Empty className="py-8 text-center text-sm text-muted-foreground">
No results found.
</Command.Empty>
{items.length > 0 && (
<Command.Group
heading={
<span className="px-4 text-xs font-medium uppercase tracking-wider text-muted-foreground">
Navigation
</span>
}
>
{items.map((item) => (
<Command.Item
key={item.id}
value={`${item.label} ${item.breadcrumb ?? ''}`}
onSelect={() => handleSelect(item.path)}
className={cn(
'flex cursor-pointer items-center gap-3 px-4 py-2.5 text-sm',
'text-foreground',
'hover:bg-accent',
'aria-selected:bg-primary/10 aria-selected:text-primary',
'outline-none transition-colors'
)}
data-qqq-id={`command-item-${item.id.replace(/\//g, '-')}`}
>
<TypeIcon type={item.type} />
<span className="flex-1 truncate font-medium">{item.label}</span>
{item.breadcrumb && (
<span className="shrink-0 text-xs text-muted-foreground">
{item.breadcrumb}
</span>
)}
</Command.Item>
))}
</Command.Group>
)}
</Command.List>
{/* Footer hint */}
<div className="border-t border-border px-4 py-2">
<div className="flex gap-4 text-xs text-muted-foreground">
<span>
<kbd className="rounded border border-border px-1 py-0.5 font-mono">↑↓</kbd>{' '}
navigate
</span>
<span>
<kbd className="rounded border border-border px-1 py-0.5 font-mono">↵</kbd>{' '}
open
</span>
<span>
<kbd className="rounded border border-border px-1 py-0.5 font-mono">esc</kbd>{' '}
close
</span>
</div>
</div>
</Command>
</div>
</div>
)
}
+79
View File
@@ -0,0 +1,79 @@
'use client'
// EmptyState — Reusable empty state component
// Used when a query returns zero results or no data is available
import React, { type ReactNode } from 'react'
import { Inbox } from 'lucide-react'
import { cn } from '@/lib/utils/cn'
export interface EmptyStateProps {
title: string
description?: string
icon?: ReactNode
action?: { label: string; onClick: () => void }
className?: string
}
export function EmptyState({
title,
description,
icon,
action,
className,
}: EmptyStateProps) {
return (
<div
className={cn(
'flex flex-col items-center justify-center rounded-xl border border-dashed border-border bg-muted/30 px-6 py-12 text-center',
className
)}
data-qqq-id="empty-state"
role="status"
aria-live="polite"
>
{/* Icon */}
<div
className="mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-muted text-muted-foreground"
aria-hidden="true"
>
{icon ?? <Inbox className="h-7 w-7" />}
</div>
{/* Title */}
<h3
className="text-sm font-semibold text-foreground"
data-qqq-id="empty-state-title"
>
{title}
</h3>
{/* Description */}
{description && (
<p
className="mt-1 max-w-xs text-sm text-muted-foreground"
data-qqq-id="empty-state-description"
>
{description}
</p>
)}
{/* Action button */}
{action && (
<button
type="button"
onClick={action.onClick}
className={cn(
'mt-4 inline-flex items-center rounded-md px-4 py-2 text-sm font-medium',
'bg-primary text-primary-foreground hover:bg-primary/90',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'transition-colors duration-150'
)}
data-qqq-id="button-empty-state-action"
>
{action.label}
</button>
)}
</div>
)
}
+85
View File
@@ -0,0 +1,85 @@
'use client'
// ErrorBoundary — Reusable React class-based error boundary
// Catches render errors in its children subtree
import React, { type ReactNode, type ErrorInfo } from 'react'
import { AlertCircle, RefreshCw } from 'lucide-react'
import { cn } from '@/lib/utils/cn'
export interface ErrorBoundaryProps {
children: ReactNode
fallback?: ReactNode
onError?: (error: Error, info: ErrorInfo) => void
className?: string
}
interface ErrorBoundaryState {
hasError: boolean
error: Error | null
}
export class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error }
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('[ErrorBoundary] Caught error:', error, info)
this.props.onError?.(error, info)
}
handleReset = () => {
this.setState({ hasError: false, error: null })
}
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback
}
return (
<div
className={cn(
'flex flex-col items-center justify-center rounded-xl border border-red-200 bg-red-50 p-8 text-center',
this.props.className
)}
role="alert"
data-qqq-id="error-boundary"
>
<AlertCircle className="mb-3 h-10 w-10 text-destructive" aria-hidden="true" />
<h3 className="text-base font-semibold text-destructive">
Something went wrong
</h3>
{this.state.error && (
<p className="mt-1 max-w-xs text-sm text-destructive">
{this.state.error.message}
</p>
)}
<button
type="button"
onClick={this.handleReset}
className={cn(
'mt-4 inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium',
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'transition-colors duration-150'
)}
data-qqq-id="button-error-boundary-retry"
>
<RefreshCw className="h-4 w-4" aria-hidden="true" />
Try Again
</button>
</div>
)
}
return this.props.children
}
}
@@ -0,0 +1,145 @@
'use client'
// KeyboardShortcutsDialog — "?" help dialog showing available keyboard shortcuts
// Organized by context: Global, Table Query, Record View
// Uses Radix Dialog primitive with project design tokens
import React from 'react'
import * as Dialog from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils/cn'
interface ShortcutEntry {
keys: string[]
description: string
}
interface ShortcutSection {
title: string
shortcuts: ShortcutEntry[]
}
const shortcutSections: ShortcutSection[] = [
{
title: 'Global',
shortcuts: [
{ keys: ['.'], description: 'Quick Navigation' },
{ keys: ['/'], description: 'Search Records' },
{ keys: ['?'], description: 'Keyboard Shortcuts' },
{ keys: ['\u2318', 'K'], description: 'Command Palette' },
],
},
{
title: 'Table Query Page',
shortcuts: [
{ keys: ['n'], description: 'New Record' },
{ keys: ['r'], description: 'Refresh' },
{ keys: ['f'], description: 'Toggle Filters' },
],
},
{
title: 'Record View Page',
shortcuts: [
{ keys: ['n'], description: 'New Record' },
{ keys: ['e'], description: 'Edit' },
{ keys: ['c'], description: 'Copy' },
{ keys: ['d'], description: 'Delete' },
],
},
]
function Kbd({ children }: { children: React.ReactNode }) {
return (
<kbd
className={cn(
'inline-flex h-6 min-w-6 items-center justify-center rounded border',
'border-border bg-muted px-1.5 font-mono text-xs font-medium text-foreground'
)}
>
{children}
</kbd>
)
}
interface KeyboardShortcutsDialogProps {
open: boolean
onClose: () => void
}
export function KeyboardShortcutsDialog({ open, onClose }: KeyboardShortcutsDialogProps) {
return (
<Dialog.Root open={open} onOpenChange={(isOpen) => { if (!isOpen) onClose() }}>
<Dialog.Portal>
<Dialog.Overlay
className="fixed inset-0 z-[2000] bg-black/40 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0"
data-qqq-id="keyboard-shortcuts-overlay"
/>
<Dialog.Content
className={cn(
'fixed left-1/2 top-1/2 z-[2001] w-full max-w-md -translate-x-1/2 -translate-y-1/2',
'rounded-xl border border-border bg-card shadow-lg',
'focus:outline-none'
)}
data-qqq-id="keyboard-shortcuts-dialog"
aria-describedby={undefined}
>
{/* Header */}
<div className="flex items-center justify-between border-b border-border px-6 py-4">
<Dialog.Title className="text-base font-semibold text-foreground">
Keyboard Shortcuts
</Dialog.Title>
<Dialog.Close asChild>
<button
type="button"
className="rounded p-1 text-muted-foreground hover:text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
aria-label="Close keyboard shortcuts"
data-qqq-id="button-keyboard-shortcuts-close"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</Dialog.Close>
</div>
{/* Shortcut sections */}
<div className="max-h-[60vh] overflow-y-auto px-6 py-4">
{shortcutSections.map((section, sectionIndex) => (
<div key={section.title} className={cn(sectionIndex > 0 && 'mt-5')}>
<h3 className="mb-2 text-xs font-medium uppercase tracking-wider text-muted-foreground">
{section.title}
</h3>
<div className="space-y-2">
{section.shortcuts.map((shortcut) => (
<div
key={shortcut.description}
className="flex items-center justify-between py-1"
>
<span className="text-sm text-foreground">{shortcut.description}</span>
<div className="flex items-center gap-1">
{shortcut.keys.map((key, keyIndex) => (
<React.Fragment key={keyIndex}>
{keyIndex > 0 && (
<span className="text-xs text-muted-foreground">+</span>
)}
<Kbd>{key}</Kbd>
</React.Fragment>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
{/* Footer */}
<div className="border-t border-border px-6 py-3">
<p className="text-center text-xs text-muted-foreground">
Press <Kbd>?</Kbd> anytime to show this dialog
</p>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}
+350
View File
@@ -0,0 +1,350 @@
'use client'
// SearchDialog — "/" key command palette for searching records
// Shows recent records when idle, live API search results when typing
// Enter on a result navigates to record; Enter with no selection goes to full search page
import React, { useEffect, useState, useCallback, useRef } from 'react'
import { useRouter } from 'next/navigation'
import { useQuery } from '@tanstack/react-query'
import { Search, Clock, ArrowRight, X } from 'lucide-react'
import { cn } from '@/lib/utils/cn'
import { globalSearch } from '@/lib/api/tables'
import type { GlobalSearchResult } from '@/lib/api/tables'
import { getRecentRecords } from '@/lib/utils/recent-records'
import type { RecentRecord } from '@/lib/utils/recent-records'
import { queryKeys } from '@/lib/query-client'
interface SearchDialogProps {
open: boolean
onClose: () => void
}
function getInitials(label: string): string {
const words = label.trim().split(/\s+/)
if (words.length === 0) return '?'
if (words.length === 1) return words[0].charAt(0).toUpperCase()
return (words[0].charAt(0) + words[1].charAt(0)).toUpperCase()
}
function HighlightedText({ text, query }: { text: string; query: string }) {
if (!query || query.length < 2) return <>{text}</>
const escaped = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const regex = new RegExp(`(${escaped})`, 'gi')
const parts = text.split(regex)
return (
<>
{parts.map((part, i) =>
regex.test(part) ? (
<mark key={i} className="bg-primary/20 text-foreground rounded-sm px-0.5">{part}</mark>
) : (
<span key={i}>{part}</span>
)
)}
</>
)
}
export function SearchDialog({ open, onClose }: SearchDialogProps) {
const router = useRouter()
const inputRef = useRef<HTMLInputElement>(null)
const [searchTerm, setSearchTerm] = useState('')
const [selectedIndex, setSelectedIndex] = useState(-1)
const [recentRecords, setRecentRecords] = useState<RecentRecord[]>([])
// Debounced search term for API calls
const [debouncedTerm, setDebouncedTerm] = useState('')
useEffect(() => {
const timer = setTimeout(() => setDebouncedTerm(searchTerm), 300)
return () => clearTimeout(timer)
}, [searchTerm])
// TanStack Query for search results
const { data: searchResults = [], isLoading: isSearching } = useQuery<GlobalSearchResult[]>({
queryKey: queryKeys.globalSearch(debouncedTerm),
queryFn: () => globalSearch(debouncedTerm),
enabled: open && debouncedTerm.length >= 2,
staleTime: 1000 * 30,
})
// Load recent records when dialog opens; clear state when closed
useEffect(() => {
if (open) {
setRecentRecords(getRecentRecords().slice(0, 10))
setSearchTerm('')
setDebouncedTerm('')
setSelectedIndex(-1)
// Focus after animation frame
requestAnimationFrame(() => inputRef.current?.focus())
}
}, [open])
// Reset selected index when results change
useEffect(() => {
setSelectedIndex(-1)
}, [searchResults, recentRecords, searchTerm])
// Build navigable items
const navigableItems: Array<{ path: string; label: string; tableLabel?: string }> = []
if (searchTerm.length >= 2 && searchResults.length > 0) {
for (const result of searchResults) {
navigableItems.push({
path: `/app/${encodeURIComponent(result.tableName)}/${encodeURIComponent(result.recordId)}`,
label: result.recordLabel,
tableLabel: result.tableLabel || result.tableName,
})
}
} else if (searchTerm.length < 2 && recentRecords.length > 0) {
for (const record of recentRecords) {
navigableItems.push({
path: record.path,
label: record.recordLabel,
tableLabel: record.tableLabel,
})
}
}
const handleNavigate = useCallback(
(path: string) => {
onClose()
router.push(path)
},
[router, onClose]
)
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setSelectedIndex((prev) =>
prev < navigableItems.length - 1 ? prev + 1 : prev
)
break
case 'ArrowUp':
e.preventDefault()
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : -1))
break
case 'Enter':
e.preventDefault()
if (selectedIndex >= 0 && selectedIndex < navigableItems.length) {
handleNavigate(navigableItems[selectedIndex].path)
} else if (searchTerm.trim()) {
handleNavigate(`/app/search?q=${encodeURIComponent(searchTerm.trim())}`)
}
break
case 'Escape':
e.preventDefault()
onClose()
break
}
},
[selectedIndex, navigableItems, searchTerm, handleNavigate, onClose]
)
// Group search results by table
const groupedResults: Record<string, GlobalSearchResult[]> = {}
if (searchTerm.length >= 2) {
for (const result of searchResults) {
const key = result.tableLabel || result.tableName
if (!groupedResults[key]) groupedResults[key] = []
groupedResults[key].push(result)
}
}
let runningIndex = 0
const showRecent = searchTerm.length < 2 && recentRecords.length > 0
const showResults = searchTerm.length >= 2
const showEmpty = searchTerm.length >= 2 && !isSearching && searchResults.length === 0
if (!open) return null
return (
<div
className="fixed inset-0 z-[2000] flex items-start justify-center pt-[10vh] sm:pt-[15vh]"
data-qqq-id="search-dialog-backdrop"
>
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
<div
className="relative z-10 w-full max-w-lg rounded-xl border border-border bg-card shadow-lg overflow-hidden"
data-qqq-id="search-dialog"
role="dialog"
aria-label="Search records"
aria-modal="true"
>
{/* Search input */}
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<input
ref={inputRef}
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search records..."
className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground focus:outline-none"
autoFocus
aria-label="Search records"
aria-expanded={true}
aria-haspopup="listbox"
aria-autocomplete="list"
role="combobox"
data-qqq-id="search-dialog-input"
/>
{searchTerm && (
<button
type="button"
onClick={() => setSearchTerm('')}
className="rounded p-0.5 text-muted-foreground hover:text-foreground"
aria-label="Clear search"
>
<X className="h-3.5 w-3.5" />
</button>
)}
<kbd className="hidden sm:inline-flex items-center rounded border border-border px-1.5 py-0.5 text-[10px] font-mono text-muted-foreground">
esc
</kbd>
</div>
{/* Results */}
<div className="max-h-80 overflow-y-auto" role="listbox" aria-label="Search results">
{/* Recent records */}
{showRecent && (
<div>
<div className="flex items-center gap-2 px-4 py-2 text-xs font-medium text-muted-foreground">
<Clock className="h-3 w-3" aria-hidden="true" />
Recently Viewed
</div>
{recentRecords.map((record, index) => (
<button
key={record.path}
onClick={() => handleNavigate(record.path)}
onMouseEnter={() => setSelectedIndex(index)}
className={cn(
'flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors',
selectedIndex === index ? 'bg-accent' : 'hover:bg-accent/50'
)}
role="option"
aria-selected={selectedIndex === index}
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
{getInitials(record.recordLabel)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">
{record.recordLabel}
</div>
<div className="truncate text-xs text-muted-foreground">
{record.tableLabel}
</div>
</div>
</button>
))}
</div>
)}
{/* Searching spinner */}
{showResults && isSearching && (
<div className="flex items-center justify-center px-4 py-8">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="ml-2 text-sm text-muted-foreground">Searching...</span>
</div>
)}
{/* Grouped search results */}
{showResults && !isSearching && !showEmpty && (
Object.entries(groupedResults).map(([tableLabel, results]) => {
const groupItems = results.map((result, resultIdx) => {
const itemIndex = runningIndex
runningIndex++
return (
<button
key={`${result.tableName}-${result.recordId}-${resultIdx}`}
onClick={() =>
handleNavigate(
`/app/${encodeURIComponent(result.tableName)}/${encodeURIComponent(result.recordId)}`
)
}
onMouseEnter={() => setSelectedIndex(itemIndex)}
className={cn(
'flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors',
selectedIndex === itemIndex ? 'bg-accent' : 'hover:bg-accent/50'
)}
role="option"
aria-selected={selectedIndex === itemIndex}
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
{getInitials(result.recordLabel)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">
<HighlightedText text={result.recordLabel} query={searchTerm} />
</div>
<div className="truncate text-xs text-muted-foreground">
{result.tableLabel || result.tableName}
</div>
</div>
</button>
)
})
return (
<div key={tableLabel}>
<div className="px-4 py-2 text-xs font-medium text-muted-foreground">
{tableLabel}
</div>
{groupItems}
</div>
)
})
)}
{/* Empty state */}
{showEmpty && (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
No results found for &ldquo;{searchTerm}&rdquo;
</div>
)}
{/* No recent records and no search */}
{!showRecent && !showResults && (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
Start typing to search records...
</div>
)}
</div>
{/* Footer */}
<div className="border-t border-border px-4 py-2">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<div className="flex gap-4">
<span>
<kbd className="rounded border border-border px-1 py-0.5 font-mono">↑↓</kbd>{' '}navigate
</span>
<span>
<kbd className="rounded border border-border px-1 py-0.5 font-mono">↵</kbd>{' '}
{searchTerm.trim() ? 'open' : 'select'}
</span>
</div>
{searchTerm.trim() && (
<button
onClick={() => handleNavigate(`/app/search?q=${encodeURIComponent(searchTerm.trim())}`)}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 hover:bg-accent hover:text-foreground transition-colors"
>
<ArrowRight className="h-3 w-3" aria-hidden="true" />
View all results
</button>
)}
</div>
</div>
</div>
</div>
)
}
+128
View File
@@ -0,0 +1,128 @@
'use client'
// Skeleton — Reusable skeleton loader variants
// Usage:
// <Skeleton.Text lines={3} />
// <Skeleton.Card />
// <Skeleton.Table rows={5} cols={4} />
import React from 'react'
import { cn } from '@/lib/utils/cn'
interface SkeletonBaseProps {
className?: string
}
// Single animated bar
function Bar({ className }: SkeletonBaseProps) {
return (
<div
className={cn(
'animate-pulse rounded bg-muted',
className
)}
aria-hidden="true"
/>
)
}
// Text lines placeholder
interface TextProps {
lines?: number
className?: string
}
function Text({ lines = 3, className }: TextProps) {
const widths = ['w-full', 'w-4/5', 'w-3/5', 'w-full', 'w-2/3', 'w-1/2']
return (
<div
className={cn('space-y-2', className)}
role="status"
aria-label="Loading text"
aria-busy="true"
>
{Array.from({ length: lines }).map((_, i) => (
<Bar key={i} className={cn('h-4', widths[i % widths.length])} />
))}
</div>
)
}
// Card placeholder
interface CardProps {
className?: string
}
function Card({ className }: CardProps) {
return (
<div
className={cn(
'rounded-xl border border-border bg-card p-5',
className
)}
role="status"
aria-label="Loading card"
aria-busy="true"
>
<div className="animate-pulse space-y-4">
{/* Header line */}
<div className="flex items-center gap-3">
<Bar className="h-8 w-8 rounded-full" />
<div className="flex-1 space-y-2">
<Bar className="h-4 w-1/3" />
<Bar className="h-3 w-1/4" />
</div>
</div>
{/* Body lines */}
<div className="space-y-2">
<Bar className="h-4 w-full" />
<Bar className="h-4 w-5/6" />
<Bar className="h-20 w-full rounded" />
<Bar className="h-4 w-2/3" />
</div>
</div>
</div>
)
}
// Table rows placeholder
interface TableProps {
rows?: number
cols?: number
className?: string
}
function Table({ rows = 5, cols = 4, className }: TableProps) {
return (
<div
className={cn('overflow-hidden rounded-lg border border-border', className)}
role="status"
aria-label="Loading table"
aria-busy="true"
>
{/* Header row */}
<div className="flex animate-pulse gap-4 border-b border-border bg-muted px-4 py-3">
{Array.from({ length: cols }).map((_, i) => (
<Bar key={i} className="h-4 flex-1" />
))}
</div>
{/* Data rows */}
{Array.from({ length: rows }).map((_, row) => (
<div
key={row}
className="flex animate-pulse gap-4 border-b border-border px-4 py-3 last:border-0"
>
{Array.from({ length: cols }).map((_, col) => (
<Bar
key={col}
className={cn('h-4 flex-1', col === 0 ? 'w-1/4' : 'w-auto')}
/>
))}
</div>
))}
</div>
)
}
// Namespace export — Skeleton.Text, Skeleton.Card, Skeleton.Table
export const Skeleton = { Text, Card, Table }
+6
View File
@@ -0,0 +1,6 @@
'use client'
// Toast — Re-exports the Toaster from sonner for use in the providers tree
// Add <Toaster /> to the root providers to enable toast notifications globally
export { Toaster } from 'sonner'
+213
View File
@@ -0,0 +1,213 @@
'use client'
// DynamicForm — renders form fields from metadata using React Hook Form
// This is the CORE REUSABLE FORM component used by EntityForm (Package 3)
// and will be reused by ProcessRun (Package 4).
import React from 'react'
import type { Control, UseFormRegister, FieldErrors } from 'react-hook-form'
import type { QFieldMetaData, QTableSection, QTableMetaData } from '@/types'
import type { PossibleValueContext } from '@/lib/hooks/use-possible-values'
import { cn } from '@/lib/utils/cn'
import { DynamicFormField } from './DynamicFormField'
export interface DynamicFormProps {
// React Hook Form internals — passed in from parent
register: UseFormRegister<Record<string, unknown>>
control: Control<Record<string, unknown>>
errors: FieldErrors<Record<string, unknown>>
// Metadata for rendering
tableMetaData?: QTableMetaData
fields?: QFieldMetaData[]
sections?: QTableSection[]
// Which fields to include (if undefined, all editable non-hidden fields)
fieldNamesToInclude?: string[]
// Context for possible values (table or process)
possibleValueContext?: PossibleValueContext
// Whether the entire form is disabled
disabled?: boolean
// Optional label shown at top
formLabel?: string
className?: string
}
/**
* DynamicForm renders form fields from metadata.
*
* Usage patterns:
* 1. Table-based: pass tableMetaData (uses its sections for layout)
* 2. Process-based: pass fields directly (flat list, no sections)
* 3. Both: pass both tableMetaData and fieldNamesToInclude to filter
*/
export function DynamicForm({
register,
control,
errors,
tableMetaData,
fields,
sections,
fieldNamesToInclude,
possibleValueContext,
disabled = false,
formLabel,
className,
}: DynamicFormProps) {
// Determine the set of fields to render
const resolvedFields: QFieldMetaData[] = []
if (fields) {
// Direct fields list (process mode)
const includeSet = fieldNamesToInclude ? new Set(fieldNamesToInclude) : null
for (const f of fields) {
if (f.isHidden) continue
if (includeSet && !includeSet.has(f.name)) continue
resolvedFields.push(f)
}
} else if (tableMetaData) {
// Table metadata mode — respect sections for layout
const includeSet = fieldNamesToInclude ? new Set(fieldNamesToInclude) : null
const allFields = tableMetaData.fields
const fieldOrder: string[] = []
// Collect field names in section order
const resolvedSections = sections ?? tableMetaData.sections
for (const section of resolvedSections) {
if (section.isHidden) continue
for (const fn of section.fieldNames) {
if (!fieldOrder.includes(fn)) fieldOrder.push(fn)
}
}
// Add any fields not in sections
for (const fn of Object.keys(allFields)) {
if (!fieldOrder.includes(fn)) fieldOrder.push(fn)
}
for (const fn of fieldOrder) {
const f = allFields[fn]
if (!f) continue
if (f.isHidden) continue
if (includeSet && !includeSet.has(fn)) continue
resolvedFields.push(f)
}
}
if (resolvedFields.length === 0) {
return null
}
// If we have table sections, render section-grouped layout
const hasSections =
tableMetaData &&
tableMetaData.sections &&
tableMetaData.sections.filter((s) => !s.isHidden).length > 0 &&
!fields
if (hasSections && tableMetaData) {
const resolvedSections = sections ?? tableMetaData.sections
return (
<div className={cn('space-y-6', className)} data-qqq-id="dynamic-form">
{formLabel && (
<h3 className="text-base font-semibold text-foreground">{formLabel}</h3>
)}
{resolvedSections
.filter((s) => !s.isHidden)
.map((section) => {
const sectionFields = section.fieldNames
.map((fn) => tableMetaData.fields[fn])
.filter((f): f is QFieldMetaData => {
if (!f) return false
if (f.isHidden) return false
if (fieldNamesToInclude && !fieldNamesToInclude.includes(f.name)) return false
return true
})
if (sectionFields.length === 0) return null
const gridCols = section.gridColumns ?? 2
return (
<div
key={section.name}
className="space-y-4"
data-qqq-id={`form-section-${section.name}`}
>
{section.label && (
<div className="border-b border-border pb-2">
<h4 className="text-sm font-medium text-muted-foreground">
{section.label}
</h4>
</div>
)}
<div
className={cn(
'grid gap-4',
gridCols === 1 ? 'grid-cols-1' :
gridCols === 2 ? 'grid-cols-1 sm:grid-cols-2' :
gridCols === 3 ? 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3' :
'grid-cols-1 sm:grid-cols-2 lg:grid-cols-4'
)}
>
{sectionFields.map((f) => (
<div
key={f.name}
className={cn(
f.gridColumns === 1 ? 'col-span-1' :
f.gridColumns === 2 ? 'col-span-1 sm:col-span-2' : undefined
)}
>
<DynamicFormField
field={f}
register={register}
control={control}
errors={errors}
disabled={disabled}
possibleValueContext={possibleValueContext}
/>
</div>
))}
</div>
</div>
)
})}
</div>
)
}
// Flat layout (no sections — process mode or simple tables)
return (
<div className={cn('space-y-4', className)} data-qqq-id="dynamic-form">
{formLabel && (
<h3 className="text-base font-semibold text-foreground">{formLabel}</h3>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{resolvedFields.map((f) => (
<div
key={f.name}
className={cn(
f.gridColumns === 1 ? 'col-span-1' :
f.gridColumns === 2 ? 'col-span-1 sm:col-span-2' : undefined
)}
>
<DynamicFormField
field={f}
register={register}
control={control}
errors={errors}
disabled={disabled}
possibleValueContext={possibleValueContext}
/>
</div>
))}
</div>
</div>
)
}
+405
View File
@@ -0,0 +1,405 @@
'use client'
// DynamicFormField — renders a single form field based on QFieldMetaData type
// Used by DynamicForm to render each field in a metadata-driven form
// Includes help tooltip support via field.helpContents
import React from 'react'
import type { Control, UseFormRegister, FieldError, FieldErrors } from 'react-hook-form'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { HelpCircle } from 'lucide-react'
import type { QFieldMetaData } from '@/types'
import type { PossibleValueContext } from '@/lib/hooks/use-possible-values'
import { cn } from '@/lib/utils/cn'
import { TextField } from './field-types/TextField'
import { NumberField } from './field-types/NumberField'
import { BooleanField } from './field-types/BooleanField'
import { DateField } from './field-types/DateField'
import { DateTimeField } from './field-types/DateTimeField'
import { PasswordField } from './field-types/PasswordField'
import { FileUploadField } from './field-types/FileUploadField'
import { PossibleValueSelect } from './PossibleValueSelect'
interface DynamicFormFieldProps {
field: QFieldMetaData
register: UseFormRegister<Record<string, unknown>>
control: Control<Record<string, unknown>>
errors: FieldErrors<Record<string, unknown>>
disabled?: boolean
possibleValueContext?: PossibleValueContext
}
/** Renders a help tooltip icon next to a field label when helpContents is available */
function FieldHelpTooltip({ field }: { field: QFieldMetaData }) {
const helpContent = field.helpContents?.[0]
if (!helpContent?.content) return null
const helpId = `field-help-content-${field.name}`
return (
<TooltipPrimitive.Provider delayDuration={300}>
<TooltipPrimitive.Root>
<TooltipPrimitive.Trigger asChild>
<button
type="button"
tabIndex={0}
data-qqq-id={`field-help-${field.name}`}
aria-label={`Help for ${field.label}`}
className={cn(
'ml-1 inline-flex items-center rounded-full p-0.5',
'text-muted-foreground hover:text-foreground',
'focus:outline-none focus:ring-2 focus:ring-ring'
)}
>
<HelpCircle className="h-3.5 w-3.5" aria-hidden="true" />
</button>
</TooltipPrimitive.Trigger>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
id={helpId}
side="top"
sideOffset={4}
className={cn(
'z-50 max-w-xs rounded-md border border-border bg-popover px-3 py-2 text-sm shadow-md',
'text-popover-foreground',
'animate-in fade-in-0 zoom-in-95'
)}
>
{helpContent.title && (
<p className="mb-1 font-semibold">{helpContent.title}</p>
)}
<p>{helpContent.content}</p>
{helpContent.links && helpContent.links.length > 0 && (
<div className="mt-1 space-y-0.5">
{helpContent.links.map((link, idx) => (
<a
key={idx}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="block text-xs text-primary hover:underline"
>
{link.label}
</a>
))}
</div>
)}
<TooltipPrimitive.Arrow className="fill-border" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
</TooltipPrimitive.Provider>
)
}
/** Wraps a field rendering with an optional help tooltip and aria-describedby linkage */
function FieldWithHelp({
field,
children,
}: {
field: QFieldMetaData
children: React.ReactNode
}) {
const hasHelp = field.helpContents && field.helpContents.length > 0 && field.helpContents[0]?.content
if (!hasHelp) {
return <>{children}</>
}
return (
<div
className="relative"
aria-describedby={`field-help-content-${field.name}`}
>
{children}
</div>
)
}
export function DynamicFormField({
field,
register,
control,
errors,
disabled = false,
possibleValueContext,
}: DynamicFormFieldProps) {
if (field.isHidden) return null
if (!field.isEditable && !disabled) return null
const fieldId = `field-${field.name}`
const fieldError = errors[field.name] as FieldError | undefined
const isDisabled = disabled || !field.isEditable
const dataQqqId = field.name
const hasHelp = field.helpContents && field.helpContents.length > 0 && field.helpContents[0]?.content
const helpDescribedBy = hasHelp ? `field-help-content-${field.name}` : undefined
// Fields with possibleValues use PossibleValueSelect (async combobox)
if (field.possibleValueSourceName) {
const pvContext: PossibleValueContext = possibleValueContext ?? { type: 'standalone' }
return (
<FieldWithHelp field={field}>
<PossibleValueSelect
id={fieldId}
label={field.label}
name={field.name}
control={control as Control<Record<string, unknown>>}
fieldName={field.name}
context={pvContext}
error={fieldError}
disabled={isDisabled}
required={field.isRequired}
data-qqq-id={dataQqqId}
/>
{hasHelp && <FieldHelpTooltip field={field} />}
</FieldWithHelp>
)
}
// Check for FILE_UPLOAD adornment (overrides type rendering)
const hasFileUpload = field.adornments?.some((a) => a.type === 'FILE_UPLOAD')
if (hasFileUpload || field.type === 'BLOB') {
return (
<FieldWithHelp field={field}>
<FileUploadField
id={fieldId}
label={field.label}
name={field.name}
control={control as Control<Record<string, unknown>>}
error={fieldError}
disabled={isDisabled}
required={field.isRequired}
data-qqq-id={dataQqqId}
/>
{hasHelp && <FieldHelpTooltip field={field} />}
</FieldWithHelp>
)
}
switch (field.type) {
case 'STRING':
return (
<FieldWithHelp field={field}>
<TextField
id={fieldId}
label={field.label}
registration={register(field.name)}
error={fieldError}
disabled={isDisabled}
maxLength={field.maxLength}
required={field.isRequired}
data-qqq-id={dataQqqId}
/>
{hasHelp && <FieldHelpTooltip field={field} />}
</FieldWithHelp>
)
case 'TEXT':
case 'HTML':
return (
<FieldWithHelp field={field}>
<div className="flex flex-col gap-1">
<div className="flex items-center">
<label
htmlFor={fieldId}
className="text-sm font-medium text-foreground"
data-qqq-id={`field-label-${dataQqqId}`}
>
{field.label}
{field.isRequired && (
<span className="ml-1 text-destructive" aria-hidden="true">*</span>
)}
</label>
{hasHelp && <FieldHelpTooltip field={field} />}
</div>
<textarea
id={fieldId}
{...register(field.name)}
disabled={isDisabled}
aria-required={field.isRequired}
aria-invalid={fieldError ? true : undefined}
aria-describedby={
[fieldError ? `${fieldId}-error` : undefined, helpDescribedBy]
.filter(Boolean)
.join(' ') || undefined
}
data-qqq-id={dataQqqId}
rows={4}
className={`w-full rounded-md border px-3 py-2 text-sm text-foreground bg-background focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring disabled:cursor-not-allowed disabled:bg-muted transition-colors duration-150 resize-y ${
fieldError ? 'border-destructive focus:ring-destructive' : 'border-input'
}`}
/>
{fieldError && (
<p id={`${fieldId}-error`} className="mt-1 text-sm text-destructive" role="alert">
{fieldError.message}
</p>
)}
</div>
</FieldWithHelp>
)
case 'INTEGER':
case 'LONG':
return (
<FieldWithHelp field={field}>
<NumberField
id={fieldId}
label={field.label}
registration={register(field.name, { valueAsNumber: true })}
error={fieldError}
disabled={isDisabled}
required={field.isRequired}
step={1}
data-qqq-id={dataQqqId}
/>
{hasHelp && <FieldHelpTooltip field={field} />}
</FieldWithHelp>
)
case 'DECIMAL':
return (
<FieldWithHelp field={field}>
<NumberField
id={fieldId}
label={field.label}
registration={register(field.name, { valueAsNumber: true })}
error={fieldError}
disabled={isDisabled}
required={field.isRequired}
step="any"
data-qqq-id={dataQqqId}
/>
{hasHelp && <FieldHelpTooltip field={field} />}
</FieldWithHelp>
)
case 'BOOLEAN':
return (
<FieldWithHelp field={field}>
<BooleanField
id={fieldId}
label={field.label}
name={field.name}
control={control as Control<Record<string, unknown>>}
error={fieldError}
disabled={isDisabled}
required={field.isRequired}
data-qqq-id={dataQqqId}
/>
{hasHelp && <FieldHelpTooltip field={field} />}
</FieldWithHelp>
)
case 'DATE':
return (
<FieldWithHelp field={field}>
<DateField
id={fieldId}
label={field.label}
registration={register(field.name)}
error={fieldError}
disabled={isDisabled}
required={field.isRequired}
data-qqq-id={dataQqqId}
/>
{hasHelp && <FieldHelpTooltip field={field} />}
</FieldWithHelp>
)
case 'DATE_TIME':
return (
<FieldWithHelp field={field}>
<DateTimeField
id={fieldId}
label={field.label}
registration={register(field.name)}
error={fieldError}
disabled={isDisabled}
required={field.isRequired}
data-qqq-id={dataQqqId}
/>
{hasHelp && <FieldHelpTooltip field={field} />}
</FieldWithHelp>
)
case 'TIME':
return (
<FieldWithHelp field={field}>
<div className="flex flex-col gap-1">
<div className="flex items-center">
<label
htmlFor={fieldId}
className="text-sm font-medium text-foreground"
data-qqq-id={`field-label-${dataQqqId}`}
>
{field.label}
{field.isRequired && (
<span className="ml-1 text-destructive" aria-hidden="true">*</span>
)}
</label>
{hasHelp && <FieldHelpTooltip field={field} />}
</div>
<input
id={fieldId}
type="time"
{...register(field.name)}
disabled={isDisabled}
aria-required={field.isRequired}
aria-invalid={fieldError ? true : undefined}
aria-describedby={
[fieldError ? `${fieldId}-error` : undefined, helpDescribedBy]
.filter(Boolean)
.join(' ') || undefined
}
data-qqq-id={dataQqqId}
className={`w-full rounded-md border px-3 py-2 text-sm text-foreground bg-background focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring disabled:cursor-not-allowed disabled:bg-muted transition-colors duration-150 ${
fieldError ? 'border-destructive focus:ring-destructive' : 'border-input'
}`}
/>
{fieldError && (
<p id={`${fieldId}-error`} className="mt-1 text-sm text-destructive" role="alert">
{fieldError.message}
</p>
)}
</div>
</FieldWithHelp>
)
case 'PASSWORD':
return (
<FieldWithHelp field={field}>
<PasswordField
id={fieldId}
label={field.label}
registration={register(field.name)}
error={fieldError}
disabled={isDisabled}
maxLength={field.maxLength}
required={field.isRequired}
data-qqq-id={dataQqqId}
/>
{hasHelp && <FieldHelpTooltip field={field} />}
</FieldWithHelp>
)
default:
return (
<FieldWithHelp field={field}>
<TextField
id={fieldId}
label={field.label}
registration={register(field.name)}
error={fieldError}
disabled={isDisabled}
maxLength={field.maxLength}
required={field.isRequired}
data-qqq-id={dataQqqId}
/>
{hasHelp && <FieldHelpTooltip field={field} />}
</FieldWithHelp>
)
}
}
+334
View File
@@ -0,0 +1,334 @@
'use client'
// EntityForm — full create/edit form for a record
// Wraps DynamicForm with React Hook Form + Zod validation + save/cancel actions
// Includes unsaved changes guard for both browser navigation and client-side navigation
import React, { useEffect, useMemo, useState, useCallback } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
import { Loader2, Save, X } from 'lucide-react'
import type { QTableMetaData, QRecord } from '@/types'
import type { PossibleValueContext } from '@/lib/hooks/use-possible-values'
import { insertRecord, updateRecord } from '@/lib/api/tables'
import { queryKeys } from '@/lib/query-client'
import { zodSchemaFromTableMetadata, defaultValuesFromRecord } from '@/lib/utils/zod-from-metadata'
import { cn } from '@/lib/utils/cn'
import { toast } from '@/lib/hooks/use-toast'
import { DynamicForm } from './DynamicForm'
import { UnsavedChangesDialog } from './UnsavedChangesDialog'
export interface EntityFormProps {
// Required
tableMetaData: QTableMetaData
// For edit mode: existing record values
record?: QRecord
// Control
isModal?: boolean
isCopy?: boolean
disabled?: boolean
// Override heading
overrideHeading?: string
saveButtonLabel?: string
// Callbacks
onSuccess?: (record: QRecord) => void
onCancel?: () => void
// Default values to pre-populate (overrides record values)
defaultValues?: Record<string, unknown>
// Specific fields to show (if not set, all editable fields shown)
fieldNamesToInclude?: string[]
// Possible value context
possibleValueContext?: PossibleValueContext
className?: string
}
export function EntityForm({
tableMetaData,
record,
isModal = false,
isCopy = false,
disabled = false,
overrideHeading,
saveButtonLabel = 'Save',
onSuccess,
onCancel,
defaultValues: propDefaultValues,
fieldNamesToInclude,
possibleValueContext,
className,
}: EntityFormProps) {
const router = useRouter()
const queryClient = useQueryClient()
const isEdit = Boolean(record) && !isCopy
// Unsaved changes dialog state
const [showUnsavedDialog, setShowUnsavedDialog] = useState(false)
const [pendingNavigation, setPendingNavigation] = useState<(() => void) | null>(null)
// Build Zod schema from metadata (memoized to avoid expensive recomputation)
const schema = useMemo(
() => zodSchemaFromTableMetadata(tableMetaData, fieldNamesToInclude),
[tableMetaData, fieldNamesToInclude]
)
// Build default values (memoized to keep a stable reference for useForm)
const mergedDefaults = useMemo(() => {
const computedDefaults: Record<string, unknown> = record
? defaultValuesFromRecord(tableMetaData, record.values)
: {}
return { ...computedDefaults, ...propDefaultValues }
}, [tableMetaData, record, propDefaultValues])
const {
register,
control,
handleSubmit,
formState: { errors, isDirty, isSubmitting },
reset,
} = useForm<Record<string, unknown>>({
resolver: zodResolver(schema),
defaultValues: mergedDefaults,
})
// Reset when record changes (e.g., navigating between records)
useEffect(() => {
if (record) {
reset(defaultValuesFromRecord(tableMetaData, record.values))
}
}, [record, tableMetaData, reset])
// Browser-level navigation guard (tab close, URL change, refresh)
// Both e.preventDefault() and e.returnValue are required for cross-browser support:
// Chrome/Edge require returnValue to be set, Firefox/Safari rely on preventDefault().
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (isDirty) {
e.preventDefault()
e.returnValue = ''
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [isDirty])
// Navigation helper that checks for unsaved changes before proceeding
const guardedNavigate = useCallback(
(navigateFn: () => void) => {
if (isDirty) {
setPendingNavigation(() => navigateFn)
setShowUnsavedDialog(true)
} else {
navigateFn()
}
},
[isDirty]
)
const handleConfirmLeave = useCallback(() => {
setShowUnsavedDialog(false)
if (pendingNavigation) {
pendingNavigation()
setPendingNavigation(null)
}
}, [pendingNavigation])
const handleCancelLeave = useCallback(() => {
setShowUnsavedDialog(false)
setPendingNavigation(null)
}, [])
// --- Mutations ---
const insertMutation = useMutation({
mutationFn: (values: Record<string, unknown>) =>
insertRecord(tableMetaData.name, values),
onSuccess: (savedRecord) => {
queryClient.invalidateQueries({ queryKey: queryKeys.tableRecords(tableMetaData.name) })
toast.success(`${tableMetaData.label} created successfully.`)
if (onSuccess) {
onSuccess(savedRecord)
} else {
const pk = savedRecord.values[tableMetaData.primaryKeyField]
router.push(`/app/${tableMetaData.name}/${pk}`)
}
},
onError: (err: Error) => {
toast.error(`Failed to create ${tableMetaData.label}: ${err.message}`)
},
})
const updateMutation = useMutation({
mutationFn: (values: Record<string, unknown>) => {
const pk = record!.values[tableMetaData.primaryKeyField] as string | number
return updateRecord(tableMetaData.name, pk, values)
},
onSuccess: (savedRecord) => {
const pk = savedRecord.values[tableMetaData.primaryKeyField] as string | number
queryClient.invalidateQueries({ queryKey: queryKeys.tableRecord(tableMetaData.name, pk) })
queryClient.invalidateQueries({ queryKey: queryKeys.tableRecords(tableMetaData.name) })
toast.success(`${tableMetaData.label} saved successfully.`)
if (onSuccess) {
onSuccess(savedRecord)
} else {
router.push(`/app/${tableMetaData.name}/${pk}`)
}
},
onError: (err: Error) => {
toast.error(`Failed to save ${tableMetaData.label}: ${err.message}`)
},
})
const activeMutation = isEdit ? updateMutation : insertMutation
const mutationError = activeMutation.error as Error | null
const onSubmit = async (values: Record<string, unknown>) => {
await activeMutation.mutateAsync(values)
}
const handleCancel = () => {
const doCancel = () => {
if (onCancel) {
onCancel()
} else if (isEdit && record) {
const pk = record.values[tableMetaData.primaryKeyField]
router.push(`/app/${tableMetaData.name}/${pk}`)
} else {
router.push(`/app/${tableMetaData.name}`)
}
}
guardedNavigate(doCancel)
}
// Determine heading
const heading = overrideHeading ?? (
isEdit
? `Edit ${tableMetaData.label}`
: isCopy
? `Copy ${tableMetaData.label}`
: `Create ${tableMetaData.label}`
)
// Possible value context — default to table context
const pvContext: PossibleValueContext = possibleValueContext ?? {
type: 'table',
tableName: tableMetaData.name,
}
const formContent = (
<form
onSubmit={handleSubmit(onSubmit)}
noValidate
className={cn('flex flex-col gap-6', className)}
data-qqq-id={`entity-form-${tableMetaData.name}`}
>
{/* Heading — only if not modal */}
{!isModal && (
<div className="border-b border-border pb-4">
<h2 className="text-xl font-semibold text-foreground">{heading}</h2>
</div>
)}
{/* Mutation error alert */}
{mutationError && (
<div
role="alert"
className="rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive"
>
<strong>Error: </strong>
{mutationError.message || 'An error occurred while saving.'}
</div>
)}
{/* Fields */}
<DynamicForm
register={register}
control={control}
errors={errors}
tableMetaData={tableMetaData}
fieldNamesToInclude={fieldNamesToInclude}
possibleValueContext={pvContext}
disabled={disabled || isSubmitting}
/>
{/* Actions — sticky on mobile, static on desktop */}
<div
className={cn(
'sticky bottom-0 z-10 bg-background border-t border-border py-3 mt-4 -mx-6 px-6',
'flex items-center justify-end gap-3',
'md:static md:border-t md:mt-6 md:mx-0 md:px-0'
)}
data-qqq-id="entity-form-actions"
>
<button
type="button"
onClick={handleCancel}
disabled={isSubmitting}
data-qqq-id="button-cancel"
className={cn(
'inline-flex items-center gap-2 rounded-md border border-input px-4 py-2 text-sm font-medium',
'text-foreground bg-background hover:bg-accent',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
'transition-colors duration-150'
)}
>
<X className="h-4 w-4" aria-hidden="true" />
Cancel
</button>
<button
type="submit"
disabled={disabled || isSubmitting || (!isDirty && isEdit)}
data-qqq-id="button-save"
className={cn(
'inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium',
'text-primary-foreground bg-primary hover:bg-primary/90',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50',
'transition-colors duration-150'
)}
>
{isSubmitting ? (
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
) : (
<Save className="h-4 w-4" aria-hidden="true" />
)}
{isSubmitting ? 'Saving...' : saveButtonLabel}
</button>
</div>
</form>
)
return (
<>
{isModal ? (
<div data-qqq-id={`entity-form-modal-${tableMetaData.name}`}>
<div className="border-b border-border px-6 py-4">
<h2 className="text-lg font-semibold text-foreground">{heading}</h2>
</div>
<div className="p-6">{formContent}</div>
</div>
) : (
formContent
)}
{/* Unsaved changes confirmation dialog */}
<UnsavedChangesDialog
open={showUnsavedDialog}
onStay={handleCancelLeave}
onLeave={handleConfirmLeave}
/>
</>
)
}
@@ -0,0 +1,277 @@
'use client'
// PossibleValueSelect — async combobox for fields with possibleValueSourceName
// Debounced search hits the backend possible values endpoint
import React, { useState, useRef, useEffect, useCallback } from 'react'
import type { Control, FieldError } from 'react-hook-form'
import { Controller } from 'react-hook-form'
import { Check, ChevronDown, Loader2, X } from 'lucide-react'
import { cn } from '@/lib/utils/cn'
import type { QPossibleValue } from '@/types'
import type { PossibleValueContext } from '@/lib/hooks/use-possible-values'
import {
fetchTablePossibleValues,
fetchProcessPossibleValues,
fetchPossibleValues,
} from '@/lib/api/possible-values'
interface PossibleValueSelectProps {
id: string
label: string
name: string
control: Control<Record<string, unknown>>
fieldName: string
context: PossibleValueContext
error?: FieldError
disabled?: boolean
required?: boolean
placeholder?: string
'data-qqq-id'?: string
}
export function PossibleValueSelect({
id,
label,
name,
control,
fieldName,
context,
error,
disabled = false,
required = false,
placeholder,
'data-qqq-id': dataQqqId,
}: PossibleValueSelectProps) {
const [isOpen, setIsOpen] = useState(false)
const [searchTerm, setSearchTerm] = useState('')
const [options, setOptions] = useState<QPossibleValue[]>([])
const [isLoading, setIsLoading] = useState(false)
const [selectedOption, setSelectedOption] = useState<QPossibleValue | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const fetchOptions = useCallback(
async (term: string) => {
setIsLoading(true)
try {
const request = { searchTerm: term || undefined }
let results: QPossibleValue[]
if (context.type === 'table') {
results = await fetchTablePossibleValues(context.tableName, fieldName, request)
} else if (context.type === 'process') {
results = await fetchProcessPossibleValues(context.processName, fieldName, request)
} else {
results = await fetchPossibleValues(fieldName, request)
}
setOptions(results)
} catch {
setOptions([])
} finally {
setIsLoading(false)
}
},
[context, fieldName]
)
const debouncedFetch = useCallback(
(term: string) => {
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => fetchOptions(term), 300)
},
[fetchOptions]
)
useEffect(() => {
if (isOpen) {
fetchOptions(searchTerm)
}
}, [isOpen]) // eslint-disable-line react-hooks/exhaustive-deps
// Close on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const term = e.target.value
setSearchTerm(term)
debouncedFetch(term)
}
return (
<div className="flex flex-col gap-1">
<label
htmlFor={id}
className="text-sm font-medium text-foreground"
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
>
{label}
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
</label>
<Controller
name={name}
control={control}
render={({ field }) => {
// Sync selected option when field value changes externally
const handleSelect = (option: QPossibleValue) => {
field.onChange(option.id)
setSelectedOption(option)
setIsOpen(false)
setSearchTerm('')
}
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation()
field.onChange(null)
setSelectedOption(null)
setSearchTerm('')
}
const displayValue = selectedOption?.label ?? (field.value ? String(field.value) : '')
return (
<div ref={containerRef} className="relative">
<div
id={id}
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-required={required}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${id}-error` : undefined}
aria-controls={`${id}-listbox`}
data-qqq-id={dataQqqId}
onClick={() => {
if (!disabled) {
setIsOpen((o) => !o)
if (!isOpen) {
setTimeout(() => inputRef.current?.focus(), 50)
}
}
}}
className={cn(
'flex min-h-10 w-full cursor-pointer items-center justify-between rounded-md border px-3 py-2',
'bg-background text-sm',
'focus-within:ring-2 focus-within:ring-ring focus-within:border-ring',
'transition-colors duration-150',
disabled && 'cursor-not-allowed opacity-50 bg-muted',
error ? 'border-destructive' : 'border-input'
)}
>
<span
className={cn(
'flex-1 truncate text-sm',
displayValue
? 'text-foreground'
: 'text-muted-foreground'
)}
>
{displayValue || placeholder || `-- Select ${label} --`}
</span>
<div className="flex items-center gap-1">
{Boolean(field.value) && !disabled && (
<button
type="button"
aria-label={`Clear ${label}`}
onClick={handleClear}
className="rounded p-0.5 text-muted-foreground hover:text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
</button>
)}
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform duration-150',
isOpen && 'rotate-180'
)}
aria-hidden="true"
/>
</div>
</div>
{isOpen && !disabled && (
<div
className={cn(
'absolute left-0 right-0 top-full z-50 mt-1',
'rounded-md border border-border bg-background shadow-lg',
'max-h-60 overflow-hidden'
)}
>
<div className="border-b border-border p-2">
<input
ref={inputRef}
type="text"
value={searchTerm}
onChange={handleSearchChange}
placeholder="Search..."
aria-label={`Search ${label} options`}
className={cn(
'w-full rounded border border-input px-2 py-1 text-sm',
'bg-muted text-foreground',
'focus:outline-none focus:ring-1 focus:ring-ring',
'placeholder:text-muted-foreground'
)}
/>
</div>
<ul
id={`${id}-listbox`}
role="listbox"
aria-label={`${label} options`}
className="max-h-44 overflow-y-auto"
>
{isLoading ? (
<li className="flex items-center justify-center py-4 text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
Loading...
</li>
) : options.length === 0 ? (
<li className="py-4 text-center text-sm text-muted-foreground">
No options found
</li>
) : (
options.map((option) => {
const isSelected = String(field.value) === String(option.id)
return (
<li
key={String(option.id)}
role="option"
aria-selected={isSelected}
onClick={() => handleSelect(option)}
className={cn(
'flex cursor-pointer items-center justify-between px-3 py-2 text-sm',
'hover:bg-accent',
isSelected && 'bg-accent text-primary'
)}
>
<span>{option.label}</span>
{isSelected && (
<Check className="h-4 w-4 text-primary" aria-hidden="true" />
)}
</li>
)
})
)}
</ul>
</div>
)}
</div>
)
}}
/>
{error && (
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
{error.message}
</p>
)}
</div>
)
}
@@ -0,0 +1,95 @@
'use client'
// UnsavedChangesDialog — confirmation dialog shown when navigating away with unsaved form changes
// Uses Radix AlertDialog primitives for accessible modal behavior
import React from 'react'
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
import { AlertTriangle } from 'lucide-react'
import { cn } from '@/lib/utils/cn'
interface UnsavedChangesDialogProps {
open: boolean
onStay: () => void
onLeave: () => void
}
export function UnsavedChangesDialog({ open, onStay, onLeave }: UnsavedChangesDialogProps) {
// Radix AlertDialog manages focus automatically (focus trap + initial focus).
// Manual focus management via useEffect/ref.focus() races with Radix's
// internal focus trap and is intentionally omitted here.
if (!open) return null
return (
<AlertDialogPrimitive.Root open={open} onOpenChange={(isOpen) => { if (!isOpen) onStay() }}>
<AlertDialogPrimitive.Portal>
<AlertDialogPrimitive.Overlay
className="fixed inset-0 z-50 bg-black/50"
onClick={onStay}
/>
<AlertDialogPrimitive.Content
data-qqq-id="unsaved-changes-dialog"
aria-describedby="unsaved-changes-description"
className={cn(
'fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2',
'rounded-xl border border-border bg-background shadow-lg',
'focus:outline-none'
)}
onEscapeKeyDown={onStay}
>
<AlertDialogPrimitive.Title className="sr-only">
Unsaved Changes
</AlertDialogPrimitive.Title>
<div className="p-6">
<div className="flex items-center gap-3 mb-4">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-yellow-100">
<AlertTriangle className="h-5 w-5 text-yellow-600" aria-hidden="true" />
</div>
<h2 className="text-lg font-semibold text-foreground">
Unsaved Changes
</h2>
</div>
<p
id="unsaved-changes-description"
className="text-sm text-muted-foreground"
>
You have unsaved changes. Are you sure you want to leave? Your changes will be lost.
</p>
</div>
<div className="flex items-center justify-end gap-3 rounded-b-xl bg-muted px-6 py-4">
<button
type="button"
onClick={onStay}
data-qqq-id="unsaved-changes-stay"
className={cn(
'inline-flex items-center gap-2 rounded-md border border-input px-4 py-2 text-sm font-medium',
'text-foreground bg-background hover:bg-accent',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'transition-colors duration-150'
)}
>
Stay
</button>
<button
type="button"
onClick={onLeave}
data-qqq-id="unsaved-changes-leave"
className={cn(
'inline-flex items-center gap-2 rounded-md px-4 py-2 text-sm font-medium',
'text-destructive-foreground bg-destructive hover:bg-destructive/90',
'focus:outline-none focus:ring-2 focus:ring-destructive focus:ring-offset-2',
'transition-colors duration-150'
)}
>
Leave
</button>
</div>
</AlertDialogPrimitive.Content>
</AlertDialogPrimitive.Portal>
</AlertDialogPrimitive.Root>
)
}
@@ -0,0 +1,151 @@
'use client'
// BooleanField — toggle switch for boolean form fields
// Supports three-state cycling (null -> true -> false -> null) when field is NOT required.
// When required, uses two-state (true/false) only.
import React, { useCallback } from 'react'
import type { Control, FieldError } from 'react-hook-form'
import { Controller } from 'react-hook-form'
import { Minus } from 'lucide-react'
import { cn } from '@/lib/utils/cn'
interface BooleanFieldProps {
id: string
label: string
name: string
control: Control<Record<string, unknown>>
error?: FieldError
disabled?: boolean
required?: boolean
'data-qqq-id'?: string
}
/**
* Resolves the current boolean state from a field value.
* Returns true, false, or null (for indeterminate/three-state).
*/
function resolveBoolState(value: unknown): boolean | null {
if (value === null || value === undefined) return null
if (value === true || value === 'true') return true
if (value === false || value === 'false') return false
return null
}
/**
* Returns the aria-checked value for the current boolean state.
* Uses role="checkbox" (not "switch") because checkbox supports aria-checked="mixed"
* for the indeterminate/null state in three-state mode.
*/
function ariaCheckedValue(state: boolean | null): 'true' | 'false' | 'mixed' {
if (state === true) return 'true'
if (state === false) return 'false'
return 'mixed'
}
export function BooleanField({
id,
label,
name,
control,
error,
disabled = false,
required = false,
'data-qqq-id': dataQqqId,
}: BooleanFieldProps) {
// Cycle logic depends on whether the field is required
const getNextValue = useCallback(
(current: boolean | null): boolean | null => {
if (required) {
// Two-state: true <-> false
return !current
}
// Three-state: null -> true -> false -> null
if (current === null) return true
if (current === true) return false
return null
},
[required]
)
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Controller
name={name}
control={control}
render={({ field }) => {
const boolState = resolveBoolState(field.value)
const ariaVal = ariaCheckedValue(boolState)
return (
<button
type="button"
role="checkbox"
id={id}
aria-checked={ariaVal}
aria-required={required}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${id}-error` : undefined}
disabled={disabled}
data-qqq-id={dataQqqId}
onClick={() => {
if (!disabled) {
field.onChange(getNextValue(boolState))
}
}}
className={cn(
'relative inline-flex h-6 w-11 shrink-0 cursor-pointer items-center',
'rounded-full border-2 border-transparent',
'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
'transition-colors duration-200',
boolState === true
? 'bg-green-600'
: boolState === false
? 'bg-muted'
: 'bg-muted',
disabled && 'cursor-not-allowed opacity-50'
)}
>
<span
aria-hidden="true"
className={cn(
'pointer-events-none inline-flex h-5 w-5 items-center justify-center rounded-full bg-white shadow-sm',
'transition-transform duration-200',
boolState === true
? 'translate-x-5'
: boolState === false
? 'translate-x-0'
: 'translate-x-2.5'
)}
>
{/* Show dash indicator for null/indeterminate state */}
{boolState === null && (
<Minus className="h-3 w-3 text-muted-foreground" aria-hidden="true" />
)}
</span>
</button>
)
}}
/>
<label
htmlFor={id}
className={cn(
'text-sm font-medium text-foreground',
disabled && 'opacity-50'
)}
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
>
{label}
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
</label>
</div>
{error && (
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
{error.message}
</p>
)}
</div>
)
}
@@ -0,0 +1,64 @@
'use client'
import React from 'react'
import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
import { cn } from '@/lib/utils/cn'
interface DateFieldProps {
id: string
label: string
registration: UseFormRegisterReturn
error?: FieldError
disabled?: boolean
required?: boolean
'data-qqq-id'?: string
}
export function DateField({
id,
label,
registration,
error,
disabled = false,
required = false,
'data-qqq-id': dataQqqId,
}: DateFieldProps) {
return (
<div className="flex flex-col gap-1">
<label
htmlFor={id}
className="text-sm font-medium text-foreground"
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
>
{label}
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
</label>
<input
id={id}
type="date"
{...registration}
disabled={disabled}
aria-required={required}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${id}-error` : undefined}
data-qqq-id={dataQqqId}
className={cn(
'w-full rounded-md border px-3 py-2 text-sm text-foreground',
'bg-background',
'focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring',
'disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground',
'transition-colors duration-150',
error
? 'border-destructive focus:ring-destructive'
: 'border-input'
)}
/>
{error && (
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
{error.message}
</p>
)}
</div>
)
}
@@ -0,0 +1,64 @@
'use client'
import React from 'react'
import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
import { cn } from '@/lib/utils/cn'
interface DateTimeFieldProps {
id: string
label: string
registration: UseFormRegisterReturn
error?: FieldError
disabled?: boolean
required?: boolean
'data-qqq-id'?: string
}
export function DateTimeField({
id,
label,
registration,
error,
disabled = false,
required = false,
'data-qqq-id': dataQqqId,
}: DateTimeFieldProps) {
return (
<div className="flex flex-col gap-1">
<label
htmlFor={id}
className="text-sm font-medium text-foreground"
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
>
{label}
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
</label>
<input
id={id}
type="datetime-local"
{...registration}
disabled={disabled}
aria-required={required}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${id}-error` : undefined}
data-qqq-id={dataQqqId}
className={cn(
'w-full rounded-md border px-3 py-2 text-sm text-foreground',
'bg-background',
'focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring',
'disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground',
'transition-colors duration-150',
error
? 'border-destructive focus:ring-destructive'
: 'border-input'
)}
/>
{error && (
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
{error.message}
</p>
)}
</div>
)
}
@@ -0,0 +1,146 @@
'use client'
import React, { useRef, useState } from 'react'
import type { Control, FieldError } from 'react-hook-form'
import { Controller } from 'react-hook-form'
import { Upload, X, File as FileIcon } from 'lucide-react'
import { cn } from '@/lib/utils/cn'
interface FileUploadFieldProps {
id: string
label: string
name: string
control: Control<Record<string, unknown>>
error?: FieldError
disabled?: boolean
required?: boolean
accept?: string
existingFileName?: string
'data-qqq-id'?: string
}
export function FileUploadField({
id,
label,
name,
control,
error,
disabled = false,
required = false,
accept,
existingFileName,
'data-qqq-id': dataQqqId,
}: FileUploadFieldProps) {
const inputRef = useRef<HTMLInputElement>(null)
const [dragOver, setDragOver] = useState(false)
return (
<div className="flex flex-col gap-1">
<label
className="text-sm font-medium text-foreground"
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
>
{label}
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
</label>
<Controller
name={name}
control={control}
render={({ field }) => {
const currentFile = field.value instanceof File ? (field.value as File) : null
const displayName = currentFile?.name ?? existingFileName
const handleFiles = (files: FileList | null) => {
if (files && files.length > 0) {
field.onChange(files[0])
}
}
return (
<div>
<div
id={id}
role="region"
aria-label={`${label} upload area`}
aria-describedby={error ? `${id}-error` : undefined}
data-qqq-id={dataQqqId}
onDragOver={(e) => { e.preventDefault(); if (!disabled) setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault()
setDragOver(false)
if (!disabled) handleFiles(e.dataTransfer.files)
}}
className={cn(
'flex flex-col items-center justify-center rounded-md border-2 border-dashed p-6 text-center',
'transition-colors duration-150 cursor-pointer',
dragOver && !disabled ? 'border-primary bg-accent' : 'border-input',
disabled && 'cursor-not-allowed opacity-50',
error && 'border-destructive',
'hover:border-primary'
)}
onClick={() => !disabled && inputRef.current?.click()}
onKeyDown={(e) => {
if ((e.key === 'Enter' || e.key === ' ') && !disabled) {
e.preventDefault()
inputRef.current?.click()
}
}}
tabIndex={disabled ? -1 : 0}
>
{displayName ? (
<div className="flex items-center gap-2 text-sm text-foreground">
<FileIcon className="h-5 w-5 text-primary" aria-hidden="true" />
<span className="max-w-[200px] truncate">{displayName}</span>
{!disabled && (
<button
type="button"
aria-label={`Remove ${displayName}`}
onClick={(e) => {
e.stopPropagation()
field.onChange(null)
if (inputRef.current) inputRef.current.value = ''
}}
className="ml-1 rounded p-0.5 text-muted-foreground hover:text-destructive focus:outline-none focus:ring-2 focus:ring-destructive"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
)}
</div>
) : (
<>
<Upload className="mb-2 h-8 w-8 text-muted-foreground" aria-hidden="true" />
<p className="text-sm text-muted-foreground">
<span className="font-medium text-primary">Click to upload</span>{' '}
or drag and drop
</p>
{accept && (
<p className="mt-1 text-xs text-muted-foreground">{accept}</p>
)}
</>
)}
</div>
<input
ref={inputRef}
type="file"
id={`${id}-input`}
accept={accept}
disabled={disabled}
aria-hidden="true"
tabIndex={-1}
className="sr-only"
onChange={(e) => handleFiles(e.target.files)}
/>
</div>
)
}}
/>
{error && (
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
{error.message}
</p>
)}
</div>
)
}
@@ -0,0 +1,71 @@
'use client'
import React from 'react'
import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
import { cn } from '@/lib/utils/cn'
interface NumberFieldProps {
id: string
label: string
registration: UseFormRegisterReturn
error?: FieldError
disabled?: boolean
placeholder?: string
required?: boolean
step?: string | number
'data-qqq-id'?: string
}
export function NumberField({
id,
label,
registration,
error,
disabled = false,
placeholder,
required = false,
step,
'data-qqq-id': dataQqqId,
}: NumberFieldProps) {
return (
<div className="flex flex-col gap-1">
<label
htmlFor={id}
className="text-sm font-medium text-foreground"
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
>
{label}
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
</label>
<input
id={id}
type="number"
step={step}
{...registration}
disabled={disabled}
placeholder={placeholder}
aria-required={required}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${id}-error` : undefined}
data-qqq-id={dataQqqId}
className={cn(
'w-full rounded-md border px-3 py-2 text-sm text-foreground',
'bg-background',
'placeholder:text-muted-foreground',
'focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring',
'disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground',
'transition-colors duration-150',
error
? 'border-destructive focus:ring-destructive'
: 'border-input'
)}
/>
{error && (
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
{error.message}
</p>
)}
</div>
)
}
@@ -0,0 +1,94 @@
'use client'
import React, { useState } from 'react'
import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
import { Eye, EyeOff } from 'lucide-react'
import { cn } from '@/lib/utils/cn'
interface PasswordFieldProps {
id: string
label: string
registration: UseFormRegisterReturn
error?: FieldError
disabled?: boolean
placeholder?: string
maxLength?: number
required?: boolean
'data-qqq-id'?: string
}
export function PasswordField({
id,
label,
registration,
error,
disabled = false,
placeholder,
maxLength,
required = false,
'data-qqq-id': dataQqqId,
}: PasswordFieldProps) {
const [showPassword, setShowPassword] = useState(false)
return (
<div className="flex flex-col gap-1">
<label
htmlFor={id}
className="text-sm font-medium text-foreground"
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
>
{label}
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
</label>
<div className="relative">
<input
id={id}
type={showPassword ? 'text' : 'password'}
{...registration}
disabled={disabled}
placeholder={placeholder}
maxLength={maxLength}
aria-required={required}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${id}-error` : undefined}
data-qqq-id={dataQqqId}
className={cn(
'w-full rounded-md border px-3 py-2 pr-10 text-sm text-foreground',
'bg-background',
'placeholder:text-muted-foreground',
'focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring',
'disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground',
'transition-colors duration-150',
error
? 'border-destructive focus:ring-destructive'
: 'border-input'
)}
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
disabled={disabled}
aria-label={showPassword ? 'Hide password' : 'Show password'}
className={cn(
'absolute right-3 top-1/2 -translate-y-1/2',
'text-muted-foreground hover:text-foreground',
'focus:outline-none focus:ring-2 focus:ring-ring rounded',
'disabled:cursor-not-allowed disabled:opacity-50'
)}
>
{showPassword ? (
<EyeOff className="h-4 w-4" aria-hidden="true" />
) : (
<Eye className="h-4 w-4" aria-hidden="true" />
)}
</button>
</div>
{error && (
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
{error.message}
</p>
)}
</div>
)
}
@@ -0,0 +1,79 @@
'use client'
import React from 'react'
import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
import { cn } from '@/lib/utils/cn'
import type { QPossibleValue } from '@/types'
interface SelectFieldProps {
id: string
label: string
registration: UseFormRegisterReturn
options: QPossibleValue[]
error?: FieldError
disabled?: boolean
required?: boolean
placeholder?: string
'data-qqq-id'?: string
}
export function SelectField({
id,
label,
registration,
options,
error,
disabled = false,
required = false,
placeholder,
'data-qqq-id': dataQqqId,
}: SelectFieldProps) {
return (
<div className="flex flex-col gap-1">
<label
htmlFor={id}
className="text-sm font-medium text-foreground"
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
>
{label}
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
</label>
<select
id={id}
{...registration}
disabled={disabled}
aria-required={required}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${id}-error` : undefined}
data-qqq-id={dataQqqId}
className={cn(
'w-full rounded-md border px-3 py-2 text-sm text-foreground',
'bg-background',
'focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring',
'disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground',
'transition-colors duration-150',
error
? 'border-destructive focus:ring-destructive'
: 'border-input'
)}
>
{!required && (
<option value="">
{placeholder ?? `-- Select ${label} --`}
</option>
)}
{options.map((option) => (
<option key={String(option.id)} value={String(option.id)}>
{option.label}
</option>
))}
</select>
{error && (
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
{error.message}
</p>
)}
</div>
)
}
@@ -0,0 +1,71 @@
'use client'
import React from 'react'
import type { UseFormRegisterReturn, FieldError } from 'react-hook-form'
import { cn } from '@/lib/utils/cn'
interface TextFieldProps {
id: string
label: string
registration: UseFormRegisterReturn
error?: FieldError
disabled?: boolean
placeholder?: string
maxLength?: number
required?: boolean
'data-qqq-id'?: string
}
export function TextField({
id,
label,
registration,
error,
disabled = false,
placeholder,
maxLength,
required = false,
'data-qqq-id': dataQqqId,
}: TextFieldProps) {
return (
<div className="flex flex-col gap-1">
<label
htmlFor={id}
className="text-sm font-medium text-foreground"
data-qqq-id={dataQqqId ? `field-label-${dataQqqId}` : undefined}
>
{label}
{required && <span className="ml-1 text-destructive" aria-hidden="true">*</span>}
</label>
<input
id={id}
type="text"
{...registration}
disabled={disabled}
placeholder={placeholder}
maxLength={maxLength}
aria-required={required}
aria-invalid={error ? true : undefined}
aria-describedby={error ? `${id}-error` : undefined}
data-qqq-id={dataQqqId}
className={cn(
'w-full rounded-md border px-3 py-2 text-sm text-foreground',
'bg-background',
'placeholder:text-muted-foreground',
'focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring',
'disabled:cursor-not-allowed disabled:bg-muted disabled:text-muted-foreground',
'transition-colors duration-150',
error
? 'border-destructive focus:ring-destructive'
: 'border-input'
)}
/>
{error && (
<p id={`${id}-error`} className="mt-1 text-sm text-destructive" role="alert">
{error.message}
</p>
)}
</div>
)
}
+82
View File
@@ -0,0 +1,82 @@
// Tests for Banner component
import React from 'react'
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import { userEvent } from '@testing-library/user-event'
import BannerComponent from './Banner'
import type { Banner } from '@/types'
describe('Banner', () => {
const banners: Record<string, Banner> = {
QFMD_TOP_OF_SITE: {
text: 'This is a test environment',
severity: 'warning',
dismissible: true,
},
}
it('should render banner text', () => {
render(<BannerComponent banners={banners} />)
expect(screen.getByText('This is a test environment')).toBeInTheDocument()
})
it('should have role="alert"', () => {
render(<BannerComponent banners={banners} />)
expect(screen.getByRole('alert')).toBeInTheDocument()
})
it('should show dismiss button for dismissible banners', () => {
render(<BannerComponent banners={banners} />)
expect(screen.getByRole('button', { name: /dismiss/i })).toBeInTheDocument()
})
it('should hide banner after dismiss', async () => {
const user = userEvent.setup()
render(<BannerComponent banners={banners} />)
await user.click(screen.getByRole('button', { name: /dismiss/i }))
expect(screen.queryByText('This is a test environment')).not.toBeInTheDocument()
})
it('should not show dismiss button for non-dismissible banners', () => {
const nonDismissibleBanners: Record<string, Banner> = {
QFMD_TOP_OF_SITE: {
text: 'Persistent banner',
severity: 'info',
dismissible: false,
},
}
render(<BannerComponent banners={nonDismissibleBanners} />)
expect(screen.queryByRole('button', { name: /dismiss/i })).not.toBeInTheDocument()
})
it('should call onDismiss callback when dismissed', async () => {
const user = userEvent.setup()
const onDismiss = vi.fn()
render(<BannerComponent banners={banners} onDismiss={onDismiss} />)
await user.click(screen.getByRole('button', { name: /dismiss/i }))
expect(onDismiss).toHaveBeenCalledWith('QFMD_TOP_OF_SITE')
})
it('should render nothing for empty banners', () => {
const { container } = render(<BannerComponent banners={{}} />)
expect(container.firstChild).toBeNull()
})
it('should have data-qqq-id on banner element', () => {
render(<BannerComponent banners={banners} />)
expect(screen.getByRole('alert').closest('[data-qqq-id]')).toBeTruthy()
})
it('should render multiple banners', () => {
const multiBanners: Record<string, Banner> = {
banner1: { text: 'First banner', severity: 'info', dismissible: false },
banner2: { text: 'Second banner', severity: 'warning', dismissible: true },
}
render(<BannerComponent banners={multiBanners} />)
expect(screen.getAllByRole('alert')).toHaveLength(2)
})
})
Loaded 100 of 230 files, more files were not shown because too many files have changed in this diff. Show more