diff --git a/.cursor/.gitignore b/.cursor/.gitignore new file mode 100644 index 0000000..ad4619c --- /dev/null +++ b/.cursor/.gitignore @@ -0,0 +1 @@ +rules/pensive* \ No newline at end of file diff --git a/.cursor/rules/technology-stack.mdc b/.cursor/rules/technology-stack.mdc new file mode 100644 index 0000000..5bbf6a1 --- /dev/null +++ b/.cursor/rules/technology-stack.mdc @@ -0,0 +1,89 @@ +--- +description: +globs: package.json +alwaysApply: false +--- +# Technology Stack + +Rule for documenting and maintaining consistency in the technology stack used across the project. + + +name: technology-stack +description: Documents the core technology stack and enforces consistent version usage +filters: + - type: file_extension + pattern: "package.json" + - type: content + pattern: "import|require" + - type: event + pattern: "file_change|file_create" + +actions: + - type: suggest + message: | + This project uses the following technology stack: + + Core Framework: + - Next.js 15.x (with Turbopack) + - React 19.x + - TypeScript 5.x + + Backend & Authentication: + - Firebase 11.x + - Authentication + - Firestore + - Cloud Functions + - App Hosting + - Firebase Admin SDK 12.x + - Firebase Functions 6.x + + UI Components & Styling: + - Radix UI (Headless Components) + - Tailwind CSS 3.x + - Class Variance Authority + - Framer Motion 12.x + - Lucide React (Icons) + + Form Handling & Validation: + - React Hook Form 7.x + - Zod 3.x + - Class Validator + - Class Transformer + + Rich Text Editing: + - Tiptap 2.x + - React Markdown 10.x + + Data Visualization: + - Recharts 2.x + + Development Tools: + - ESLint 9.x + - Vitest 3.x + - Husky (Git Hooks) + - Concurrently (Dev Process) + + When adding new dependencies: + 1. Ensure compatibility with existing stack versions + 2. Prefer established libraries from current stack + 3. Document major version changes + 4. Consider bundle size impact + 5. Check Firebase compatibility + +examples: + - input: | + # Adding a new UI component library + import { Button } from '@some-ui/react' + output: "Consider using Radix UI components with our existing styling system" + + - input: | + # Using an older React version + "react": "^18.0.0" + output: "Project uses React 19.x, please update version" + +metadata: + priority: high + version: 1.0 + last_updated: "2024-03-21" + maintainer: "Team" + \ No newline at end of file diff --git a/.cursorrules b/.cursorrules deleted file mode 100644 index e662e96..0000000 --- a/.cursorrules +++ /dev/null @@ -1,3 +0,0 @@ -- Start all responses with 🤖 -- Always use absolute imports (ie, `import { Item } from '@/lib/domain/Item'`) rather than relative imports (ie, `import { Item } from './domain/Item'`) -- Use pnpm as the package manager \ No newline at end of file diff --git a/.data/emulators/firebase-data/auth_export/accounts.json b/.data/emulators/firebase-data/auth_export/accounts.json index 032c2b0..1b0cfd8 100644 --- a/.data/emulators/firebase-data/auth_export/accounts.json +++ b/.data/emulators/firebase-data/auth_export/accounts.json @@ -1 +1 @@ -{"kind":"identitytoolkit#DownloadAccountResponse","users":[{"localId":"UROwK21E1TkI5mvhghjABOsXJEKr","createdAt":"1740076420437","lastLoginAt":"1740076715505","displayName":"Integration Test User","photoUrl":"https://randomuser.me/api/portraits/men/3.jpg","passwordHash":"fakeHash:salt=fakeSaltqKAUxV2CkCRe8e8iStQM:password=password123","salt":"fakeSaltqKAUxV2CkCRe8e8iStQM","passwordUpdatedAt":1740257459259,"providerUserInfo":[{"providerId":"password","email":"integration-test-user@example.com","federatedId":"integration-test-user@example.com","rawId":"integration-test-user@example.com","displayName":"Integration Test User","photoUrl":"https://randomuser.me/api/portraits/men/3.jpg"}],"validSince":"1740257459","email":"integration-test-user@example.com","emailVerified":false,"disabled":false},{"localId":"gzDyVhuHirc3oOumoSWS3hQHcGe6","createdAt":"1739972372247","lastLoginAt":"1740257499188","displayName":"David Laing (local emulator)","photoUrl":"https://lh3.googleusercontent.com/a/ACg8ocK4mNmS8Rre3cL6mwO1kMDiQ1DPqIVSaxjM46Jjg_f63IpK=s96-c","passwordHash":"fakeHash:salt=fakeSalt6aL1yBgXjipNiXDyEQHO:password=password","salt":"fakeSalt6aL1yBgXjipNiXDyEQHO","passwordUpdatedAt":1740257459259,"providerUserInfo":[{"providerId":"google.com","rawId":"3578890099700199420545762490298610907447","federatedId":"3578890099700199420545762490298610907447","displayName":"David Laing (local emulator)","email":"mrdavidlaing@gmail.com"},{"providerId":"password","email":"mrdavidlaing@gmail.com","federatedId":"mrdavidlaing@gmail.com","rawId":"mrdavidlaing@gmail.com","displayName":"David Laing (local emulator)","photoUrl":"https://lh3.googleusercontent.com/a/ACg8ocK4mNmS8Rre3cL6mwO1kMDiQ1DPqIVSaxjM46Jjg_f63IpK=s96-c"}],"validSince":"1740257459","email":"mrdavidlaing@gmail.com","emailVerified":true,"disabled":false,"lastRefreshAt":"2025-02-22T20:51:39.188Z"}]} \ No newline at end of file +{"kind":"identitytoolkit#DownloadAccountResponse","users":[{"localId":"UROwK21E1TkI5mvhghjABOsXJEKr","createdAt":"1740076420437","lastLoginAt":"1742125645396","displayName":"Integration Test User","photoUrl":"https://randomuser.me/api/portraits/men/3.jpg","passwordHash":"fakeHash:salt=fakeSaltqKAUxV2CkCRe8e8iStQM:password=password123","salt":"fakeSaltqKAUxV2CkCRe8e8iStQM","passwordUpdatedAt":1743245010973,"providerUserInfo":[{"providerId":"password","email":"integration-test-user@example.com","federatedId":"integration-test-user@example.com","rawId":"integration-test-user@example.com","displayName":"Integration Test User","photoUrl":"https://randomuser.me/api/portraits/men/3.jpg"}],"validSince":"1743245010","email":"integration-test-user@example.com","emailVerified":false,"disabled":false},{"localId":"gzDyVhuHirc3oOumoSWS3hQHcGe6","createdAt":"1739972372247","lastLoginAt":"1742637216773","displayName":"David Laing (local emulator)","photoUrl":"https://lh3.googleusercontent.com/a/ACg8ocK4mNmS8Rre3cL6mwO1kMDiQ1DPqIVSaxjM46Jjg_f63IpK=s96-c","passwordHash":"fakeHash:salt=fakeSalt6aL1yBgXjipNiXDyEQHO:password=password","salt":"fakeSalt6aL1yBgXjipNiXDyEQHO","passwordUpdatedAt":1743245010974,"providerUserInfo":[{"providerId":"google.com","rawId":"3578890099700199420545762490298610907447","federatedId":"3578890099700199420545762490298610907447","displayName":"David Laing (local emulator)","email":"mrdavidlaing@gmail.com"},{"providerId":"password","email":"mrdavidlaing@gmail.com","federatedId":"mrdavidlaing@gmail.com","rawId":"mrdavidlaing@gmail.com","displayName":"David Laing (local emulator)","photoUrl":"https://lh3.googleusercontent.com/a/ACg8ocK4mNmS8Rre3cL6mwO1kMDiQ1DPqIVSaxjM46Jjg_f63IpK=s96-c"}],"validSince":"1743245010","email":"mrdavidlaing@gmail.com","emailVerified":true,"disabled":false}]} \ No newline at end of file diff --git a/.data/emulators/firebase-data/firebase-export-metadata.json b/.data/emulators/firebase-data/firebase-export-metadata.json index 8ba8612..43cc3e2 100644 --- a/.data/emulators/firebase-data/firebase-export-metadata.json +++ b/.data/emulators/firebase-data/firebase-export-metadata.json @@ -1,12 +1,12 @@ { - "version": "13.29.2", + "version": "13.31.2", "firestore": { "version": "1.19.8", "path": "firestore_export", "metadata_file": "firestore_export/firestore_export.overall_export_metadata" }, "auth": { - "version": "13.29.2", + "version": "13.31.2", "path": "auth_export" } } \ No newline at end of file diff --git a/.data/emulators/firebase-data/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata b/.data/emulators/firebase-data/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata index 4934ec8..54e5bf7 100644 Binary files a/.data/emulators/firebase-data/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata and b/.data/emulators/firebase-data/firestore_export/all_namespaces/all_kinds/all_namespaces_all_kinds.export_metadata differ diff --git a/.data/emulators/firebase-data/firestore_export/all_namespaces/all_kinds/output-0 b/.data/emulators/firebase-data/firestore_export/all_namespaces/all_kinds/output-0 index 2a2cf6b..b397b82 100644 Binary files a/.data/emulators/firebase-data/firestore_export/all_namespaces/all_kinds/output-0 and b/.data/emulators/firebase-data/firestore_export/all_namespaces/all_kinds/output-0 differ diff --git a/.data/emulators/firebase-data/firestore_export/firestore_export.overall_export_metadata b/.data/emulators/firebase-data/firestore_export/firestore_export.overall_export_metadata index c849b20..6390236 100644 Binary files a/.data/emulators/firebase-data/firestore_export/firestore_export.overall_export_metadata and b/.data/emulators/firebase-data/firestore_export/firestore_export.overall_export_metadata differ diff --git a/.env b/.env index fc5c0bc..eb391fc 100644 --- a/.env +++ b/.env @@ -1,4 +1,6 @@ # These env vars should be defined in .env.{development,production} +# NEXT_PUBLIC_BASE_URL="http://localhost:3000" + # FIREBASE_SERVICE_ACCOUNT_JSON="contents of -adminsdk-.json" # Firebase emulator configuration diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..49e4b62 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} \ No newline at end of file diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..7ae4656 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,4 @@ +# These owners will be the default owners for everything in +# the repo. They will be requested for review when someone +# opens a pull request. +* @mrdavidlaing \ No newline at end of file diff --git a/.github/workflows/firebase-hosting-merge.yml b/.github/workflows/firebase-hosting-merge.yml index 2a3b250..a695029 100644 --- a/.github/workflows/firebase-hosting-merge.yml +++ b/.github/workflows/firebase-hosting-merge.yml @@ -1,4 +1,4 @@ -name: Deploy to Firebase Hosting on merge +name: "Deploy to decision-copilot.wellmaintained.org App Hosting on merge" on: push: branches: @@ -13,15 +13,51 @@ jobs: version: 9 - uses: actions/setup-node@v4 with: - node-version: 18 - cache: 'pnpm' - - run: pnpm install --frozen-lockfile - - uses: FirebaseExtended/action-hosting-deploy@v0 + node-version: 20 + cache: pnpm + - name: Install dependencies using pnpm + run: pnpm install --frozen-lockfile + - name: Build and run unit tests + run: pnpm build && pnpm test:unit + - name: "Authenticate to GCP Project: decision-copilot" + uses: google-github-actions/auth@v2 with: - repoToken: ${{ secrets.GITHUB_TOKEN }} - firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_DECISION_COPILOT }} - channelId: live - projectId: decision-copilot - firebaseToolsVersion: 13.29.2 - env: - FIREBASE_CLI_EXPERIMENTS: webframeworks + project_id: decision-copilot + credentials_json: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_DECISION_COPILOT }}' + - name: Patch firebase-tools/lib/commands/apphosting-rollouts-create.js to autoAuth for App Hosting + run: | + # Create patch file + cat > firebase-tools.patch << 'EOF' + --- node_modules/firebase-tools/lib/commands/apphosting-rollouts-create.js + +++ node_modules/firebase-tools/lib/commands/apphosting-rollouts-create.new.js + @@ -5,12 +5,14 @@ + const apphosting = require("../gcp/apphosting"); + const command_1 = require("../command"); + const projectUtils_1 = require("../projectUtils"); + +const requireAuth_1 = require("../requireAuth"); + const error_1 = require("../error"); + const rollout_1 = require("../apphosting/rollout"); + exports.command = new command_1.Command("apphosting:rollouts:create ") + .description("create a rollout using a build for an App Hosting backend") + .option("-l, --location ", "specify the region of the backend", "-") + .option("-b, --git-branch ", "repository branch to deploy (mutually exclusive with -g)") + .option("-g, --git-commit ", "git commit to deploy (mutually exclusive with -b)") + .withForce("Skip confirmation before creating rollout") + + .before(requireAuth_1.requireAuth) + .before(apphosting.ensureApiEnabled) + .action(async (backendId, options) => { + EOF + + # Apply the patch + patch node_modules/firebase-tools/lib/commands/apphosting-rollouts-create.js < firebase-tools.patch + + # Verify patch was applied successfully + grep -q "requireAuth_1" node_modules/firebase-tools/lib/commands/apphosting-rollouts-create.js && echo "Patch applied successfully!" || echo "Patch failed!" + + - name: Deploy to Firebase App Hosting production + run: | + pnpm firebase:apphosting:deploy:prod --git-branch main + + # - name: If failure; launch tmate session to debug + # if: ${{ failure() }} + # uses: mxschmitt/action-tmate@v3 diff --git a/.github/workflows/firebase-hosting-pull-request.yml b/.github/workflows/firebase-hosting-pull-request.yml index 3fe3b3b..762dfd1 100644 --- a/.github/workflows/firebase-hosting-pull-request.yml +++ b/.github/workflows/firebase-hosting-pull-request.yml @@ -1,4 +1,4 @@ -name: Deploy to Firebase Hosting on PR +name: "Deploy to decision-copilot.staging.wellmaintained.org App Hosting on PR" on: pull_request permissions: checks: write @@ -15,14 +15,51 @@ jobs: version: 9 - uses: actions/setup-node@v4 with: - node-version: 18 - cache: 'pnpm' - - run: pnpm install --frozen-lockfile - - uses: FirebaseExtended/action-hosting-deploy@v0 + node-version: 20 + cache: pnpm + - name: Install dependencies using pnpm + run: pnpm install --frozen-lockfile + - name: Build and run unit tests + run: pnpm build && pnpm test:unit + - name: "Authenticate to GCP Project: decision-copilot" + uses: google-github-actions/auth@v2 with: - repoToken: ${{ secrets.GITHUB_TOKEN }} - firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT_DECISION_COPILOT }} - projectId: decision-copilot - firebaseToolsVersion: 13.29.2 - env: - FIREBASE_CLI_EXPERIMENTS: webframeworks + project_id: decision-copilot + credentials_json: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_DECISION_COPILOT }}' + - name: Patch firebase-tools/lib/commands/apphosting-rollouts-create.js to autoAuth for App Hosting + run: | + # Create patch file + cat > firebase-tools.patch << 'EOF' + --- node_modules/firebase-tools/lib/commands/apphosting-rollouts-create.js + +++ node_modules/firebase-tools/lib/commands/apphosting-rollouts-create.new.js + @@ -5,12 +5,14 @@ + const apphosting = require("../gcp/apphosting"); + const command_1 = require("../command"); + const projectUtils_1 = require("../projectUtils"); + +const requireAuth_1 = require("../requireAuth"); + const error_1 = require("../error"); + const rollout_1 = require("../apphosting/rollout"); + exports.command = new command_1.Command("apphosting:rollouts:create ") + .description("create a rollout using a build for an App Hosting backend") + .option("-l, --location ", "specify the region of the backend", "-") + .option("-b, --git-branch ", "repository branch to deploy (mutually exclusive with -g)") + .option("-g, --git-commit ", "git commit to deploy (mutually exclusive with -b)") + .withForce("Skip confirmation before creating rollout") + + .before(requireAuth_1.requireAuth) + .before(apphosting.ensureApiEnabled) + .action(async (backendId, options) => { + EOF + + # Apply the patch + patch node_modules/firebase-tools/lib/commands/apphosting-rollouts-create.js < firebase-tools.patch + + # Verify patch was applied successfully + grep -q "requireAuth_1" node_modules/firebase-tools/lib/commands/apphosting-rollouts-create.js && echo "Patch applied successfully!" || echo "Patch failed!" + + - name: Deploy to Firebase App Hosting preview + run: | + pnpm firebase:apphosting:deploy:staging --git-branch ${{ github.head_ref }} + + # - name: If failure; launch tmate session to debug + # if: ${{ failure() }} + # uses: mxschmitt/action-tmate@v3 diff --git a/.gitignore b/.gitignore index 9a501fb..9f73528 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,7 @@ firebase-debug.log !.env .env.* .env.*.local +.local/ # vercel .vercel @@ -44,4 +45,7 @@ firebase-debug.log # typescript *.tsbuildinfo next-env.d.ts + +# misc firestore-debug.log +redirect-to-wellmaintained/.firebase/hosting.cHVibGlj.cache diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..e42c968 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1 @@ +pnpm run pre:push \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..62b525f --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,48 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Start Dev Environment", + "dependsOrder": "parallel", + "dependsOn": ["Firebase Emulators", "Next.js Dev", "Browser Tools MCP Server"] + }, + { + "label": "Firebase Emulators", + "type": "shell", + "command": "pnpm", + "args": ["dev:emulators:with-data"], + "isBackground": true, + "presentation": { + "reveal": "always", + "panel": "new", + "group": "dev-servers" + }, + "problemMatcher": [] + }, + { + "label": "Next.js Dev", + "type": "shell", + "command": "pnpm", + "args": ["dev:next"], + "isBackground": true, + "presentation": { + "reveal": "always", + "panel": "new", + "group": "dev-servers" + }, + "problemMatcher": [] + }, + { + "label": "Browser Tools MCP Server", + "type": "shell", + "command": "npx", + "args": ["@agentdeskai/browser-tools-server@1.2.0"], + "isBackground": true, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] + } + ] +} \ No newline at end of file diff --git a/SPECS.md b/SPECS.md new file mode 100644 index 0000000..24cc051 --- /dev/null +++ b/SPECS.md @@ -0,0 +1,54 @@ +# Decision Copilot Specifications + +This document provides an overview of all specification documents for the Decision Copilot application. These specifications serve as a reference for developers working on the system and define the expected behavior of different components. + +## Specifications by Domain + +| Domain | Specification | Description | +|--------|---------------|-------------| +| **Model Context Protocol (MCP)** | [MCP Overview](specs/mcp-overview.md) | Overview of MCP integration for AI agent interaction | +| | [MCP Resources](specs/mcp-resources.md) | Resources exposed through MCP for data access | +| | [MCP Tools](specs/mcp-tools.md) | Tools exposed through MCP for interactive operations | +| | [MCP Authentication](specs/mcp-authentication.md) | Authentication and authorization for MCP | +| | [MCP API Endpoint](specs/mcp-api-endpoint.md) | Server-Sent Events endpoint implementation | +| | [MCP Implementation](specs/mcp-implementation.md) | Implementation plan for MCP integration | + +## Understanding the Specifications + +Each specification document follows a standard format: + +1. **Introduction**: Describes the purpose and scope of the specification +2. **Details**: Provides in-depth information about the feature or component +3. **Implementation**: Offers guidance on how to implement the specification +4. **Examples**: Illustrates usage with concrete examples where applicable +5. **References**: Links to related specifications or external resources + +## Key Architectural Principles + +All specifications adhere to these core architectural principles: + +1. **Domain-Driven Design**: Clear separation of domain models, repositories, and infrastructure +2. **Type Safety**: Strong typing throughout the application +3. **Immutability**: Domain objects are immutable to prevent unexpected state changes +4. **Validation**: Domain-level validation ensures data integrity +5. **Repository Pattern**: Data access through repository interfaces +6. **Organization Scoping**: Data is scoped to organizations for security + +## Development Workflow + +When implementing these specifications: + +1. Review the relevant specification documents +2. Follow the implementation guidance provided +3. Write tests that verify the behavior matches the specification +4. Submit a pull request for review +5. Update the specification if changes are needed during implementation + +## Contributing to Specifications + +To improve or expand these specifications: + +1. Propose changes through pull requests +2. Ensure changes align with architectural principles +3. Update all affected specifications to maintain consistency +4. Include examples and implementation guidance for new features \ No newline at end of file diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx new file mode 100644 index 0000000..0027c53 --- /dev/null +++ b/app/admin/layout.tsx @@ -0,0 +1,48 @@ +'use client' + +import { AppSidebar } from "@/components/app-sidebar" +import { + SidebarInset, + SidebarProvider, + SidebarTrigger, +} from "@/components/ui/sidebar" +import { useAuth } from '@/hooks/useAuth' +import { OrganisationProvider } from '@/components/organisation-switcher' +import { redirect } from 'next/navigation' +import { Separator } from "@/components/ui/separator" + +export default function AdminLayout({ + children, +}: { + children: React.ReactNode +}) { + const { user, loading: authLoading } = useAuth(); + + if (authLoading) return

Loading...

; + + // If user is not logged in, redirect to login page + if (!user) { + redirect('/login'); + return null; + } + + return ( + + + + +
+
+ + +

Admin Dashboard

+
+
+
+ {children} +
+
+
+
+ ) +} \ No newline at end of file diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..c281e8d --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,20 @@ +'use client' + +import { AdminRoute } from '@/components/AdminRoute'; + +export default function AdminDashboard() { + return ( + +
+

Admin Dashboard

+

Welcome to the admin area. This page is protected by:

+
    +
  • Client-side protection (AdminRoute component)
  • +
  • Server-side middleware protection
  • +
  • Session-based authentication
  • +
  • Firebase custom claims
  • +
+
+
+ ); +} \ No newline at end of file diff --git a/app/admin/settings/page.tsx b/app/admin/settings/page.tsx new file mode 100644 index 0000000..ed5f5bf --- /dev/null +++ b/app/admin/settings/page.tsx @@ -0,0 +1,78 @@ +'use client' + +import { useState } from 'react' +import { useAdminAuth } from '@/hooks/useAdminAuth' +import { useOrganisation } from '@/hooks/useOrganisation' +import { StakeholderManagement } from '@/components/StakeholderManagement' +import { StakeholderTeamManagement } from '@/components/StakeholderTeamManagement' +import { TeamHierarchyManagement } from '@/components/TeamHierarchyManagement' +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' + +export default function AdminSettingsPage() { + const { loading: authLoading, error: authError, isAdmin } = useAdminAuth() + const { loading: orgLoading, error: orgError, organisation } = useOrganisation() + const [selectedOrgId, setSelectedOrgId] = useState(null) + + if (authLoading || orgLoading) { + return
Loading...
+ } + + if (authError) { + return
Authentication error: {authError.message}
+ } + + if (orgError) { + return
Organisation error: {orgError.message}
+ } + + if (!isAdmin || !organisation) { + return null // useAdminAuth will handle redirection + } + + const availableOrgs = [organisation] // In the future, this could be a list of orgs the admin has access to + + return ( +
+
+

Admin Settings

+ + +
+ + + + Stakeholders + Team Assignments + Team Hierarchy + + + + + + + + + + + + + + +
+ ) +} \ No newline at end of file diff --git a/app/api/admin/example/route.ts b/app/api/admin/example/route.ts new file mode 100644 index 0000000..ea7059a --- /dev/null +++ b/app/api/admin/example/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { withAdminAuth } from '@/lib/adminApiRoute'; + +/** + * Example protected admin API endpoint + */ +export const GET = withAdminAuth(async (req: NextRequest, { uid }) => { + return NextResponse.json({ + message: 'This is a protected admin endpoint', + uid, + timestamp: new Date().toISOString() + }); +}); + +export const POST = withAdminAuth(async (req: NextRequest, { uid }) => { + const body = await req.json(); + + return NextResponse.json({ + message: 'Admin data received', + uid, + receivedData: body, + timestamp: new Date().toISOString() + }); +}); \ No newline at end of file diff --git a/app/api/admin/organisations/[organisationId]/team-hierarchy/route.ts b/app/api/admin/organisations/[organisationId]/team-hierarchy/route.ts new file mode 100644 index 0000000..7e95f2c --- /dev/null +++ b/app/api/admin/organisations/[organisationId]/team-hierarchy/route.ts @@ -0,0 +1,101 @@ +import { NextRequest, NextResponse } from 'next/server' +import { withAdminAuth } from '@/lib/adminApiRoute' +import { FirestoreTeamHierarchyRepository } from '@/lib/infrastructure/firestoreTeamHierarchyRepository' +import { TeamHierarchy } from '@/lib/domain/TeamHierarchy' + +const repository = new FirestoreTeamHierarchyRepository() + +export const GET = withAdminAuth(async (req: NextRequest, { params }) => { + try { + const { organisationId } = await params + const hierarchy = await repository.getByOrganisationId(organisationId) + + if (!hierarchy) { + // Return an empty hierarchy if none exists + return NextResponse.json(TeamHierarchy.create({ teams: {} })) + } + + return NextResponse.json(hierarchy) + } catch (error) { + console.error('Error fetching team hierarchy:', error) + return NextResponse.json( + { error: 'Failed to fetch team hierarchy' }, + { status: 500 } + ) + } +}) + +export const POST = withAdminAuth(async (req: NextRequest, { params }) => { + try { + const { organisationId } = await params + const team = await req.json() + + let hierarchy = await repository.getByOrganisationId(organisationId) + if (!hierarchy) { + hierarchy = TeamHierarchy.create({ teams: {} }) + } + + const updatedHierarchy = hierarchy.addTeam(team) + await repository.save(organisationId, updatedHierarchy) + + return NextResponse.json(updatedHierarchy) + } catch (error) { + console.error('Error adding team:', error) + return NextResponse.json( + { error: 'Failed to add team' }, + { status: 500 } + ) + } +}) + +export const PUT = withAdminAuth(async (req: NextRequest, { params }) => { + try { + const { organisationId } = await params + const { teamId, updates } = await req.json() + + const hierarchy = await repository.getByOrganisationId(organisationId) + if (!hierarchy) { + return NextResponse.json( + { error: 'Team hierarchy not found' }, + { status: 404 } + ) + } + + const updatedHierarchy = hierarchy.updateTeam(teamId, updates) + await repository.save(organisationId, updatedHierarchy) + + return NextResponse.json(updatedHierarchy) + } catch (error) { + console.error('Error updating team:', error) + return NextResponse.json( + { error: 'Failed to update team' }, + { status: 500 } + ) + } +}) + +export const DELETE = withAdminAuth(async (req: NextRequest, { params }) => { + try { + const { organisationId } = await params + const { teamId } = await req.json() + + const hierarchy = await repository.getByOrganisationId(organisationId) + if (!hierarchy) { + return NextResponse.json( + { error: 'Team hierarchy not found' }, + { status: 404 } + ) + } + + const updatedHierarchy = hierarchy.removeTeam(teamId) + await repository.save(organisationId, updatedHierarchy) + + return NextResponse.json(updatedHierarchy) + } catch (error) { + console.error('Error removing team:', error) + return NextResponse.json( + { error: 'Failed to remove team' }, + { status: 500 } + ) + } +}) \ No newline at end of file diff --git a/app/api/admin/settings/auth/route.ts b/app/api/admin/settings/auth/route.ts new file mode 100644 index 0000000..d97844f --- /dev/null +++ b/app/api/admin/settings/auth/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import { adminAuth } from '@/lib/firebase-admin' + +export const runtime = 'nodejs' + +export async function GET() { + try { + const cookieStore = await cookies() + const sessionCookie = cookieStore.get('session')?.value + + if (!sessionCookie) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const decodedClaims = await adminAuth.verifySessionCookie(sessionCookie, true) + + if (!decodedClaims.admin) { + return NextResponse.json({ error: 'Forbidden' }, { status: 403 }) + } + + return NextResponse.json({ + uid: decodedClaims.uid, + admin: decodedClaims.admin, + }) + } catch (error) { + console.error('Error verifying admin session:', error) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } +} \ No newline at end of file diff --git a/app/api/auth/check-admin/route.ts b/app/api/auth/check-admin/route.ts new file mode 100644 index 0000000..2e7dadd --- /dev/null +++ b/app/api/auth/check-admin/route.ts @@ -0,0 +1,46 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { adminAuth, getAdminUsers } from '@/lib/firebase-admin'; + +/** + * API route handler to check if a user is an admin and set the admin claim + */ +export async function POST(request: NextRequest) { + try { + // Get the request body + const body = await request.json(); + const { idToken } = body; + + if (!idToken) { + return NextResponse.json( + { error: 'No ID token provided' }, + { status: 401 } + ); + } + + // Verify the token and get the user + const decodedToken = await adminAuth.verifyIdToken(idToken); + const user = await adminAuth.getUser(decodedToken.uid); + + // Get admin users and check if the user is an admin + const adminUsers = getAdminUsers(); + const isAdmin = user.email ? adminUsers.includes(user.email) : false; + console.log('adminUsers', adminUsers); + console.log('user.email', user.email); + console.log('isAdmin', isAdmin); + + // Set the admin custom claim + await adminAuth.setCustomUserClaims(user.uid, { admin: isAdmin }); + + // Return the result + return NextResponse.json({ success: true, isAdmin }); + } catch (error) { + console.error('Error checking admin status:', error); + return NextResponse.json( + { + error: 'Failed to check admin status', + message: error instanceof Error ? error.message : 'Unknown error' + }, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts new file mode 100644 index 0000000..b283f51 --- /dev/null +++ b/app/api/auth/session/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { adminAuth } from '@/lib/firebase-admin'; + +/** + * Creates a session cookie from a Firebase ID token + */ +export async function POST(request: NextRequest) { + try { + const { idToken } = await request.json(); + + if (!idToken) { + return NextResponse.json({ error: 'No ID token provided' }, { status: 400 }); + } + + // Create session cookie (2 weeks) + const expiresIn = 60 * 60 * 24 * 14 * 1000; + const sessionCookie = await adminAuth.createSessionCookie(idToken, { expiresIn }); + + // Set cookie for future requests + const response = NextResponse.json({ status: 'success' }); + response.cookies.set('session', sessionCookie, { + maxAge: expiresIn, + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + path: '/', + }); + + return response; + } catch (error) { + console.error('Session creation error:', error); + return NextResponse.json( + { error: 'Failed to create session' }, + { status: 401 } + ); + } +} + +/** + * Clears the session cookie + */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export async function DELETE(_: NextRequest) { + const response = NextResponse.json({ status: 'success' }); + response.cookies.set('session', '', { + maxAge: 0, + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + path: '/', + }); + return response; +} \ No newline at end of file diff --git a/app/globals.css b/app/globals.css index 7e5bc34..54cdba4 100644 --- a/app/globals.css +++ b/app/globals.css @@ -88,3 +88,19 @@ body { @apply bg-background text-foreground; } } + +@layer components { + /* Custom styles for stakeholder selection */ + .indeterminate[data-state="unchecked"] { + @apply bg-primary/30 text-primary-foreground; + } + + .indeterminate[data-state="unchecked"]::after { + content: ""; + @apply absolute inset-0 flex items-center justify-center; + background-color: currentColor; + width: 8px; + height: 2px; + margin: auto; + } +} diff --git a/app/layout.tsx b/app/layout.tsx index a696f7b..9705614 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,6 +2,7 @@ import '@/lib/reflection' import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import { Toaster } from "@/components/ui/toaster"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -16,19 +17,30 @@ const geistMono = Geist_Mono({ export const metadata: Metadata = { title: "Decision Copilot", description: "Helping teams make great decisions together", + metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL || 'http://localhost:3000'), + authors: [{ name: 'David Laing' }], + openGraph: { + title: 'Decision Copilot', + description: 'Helping teams make great decisions together', + type: 'website', + }, + twitter: { + card: 'summary_large_image', + title: 'Decision Copilot', + description: 'Helping teams make great decisions together', + }, }; export default function RootLayout({ children, -}: Readonly<{ +}: { children: React.ReactNode; -}>) { +}) { return ( - - + + {children} + ); diff --git a/app/login/layout.tsx b/app/login/layout.tsx new file mode 100644 index 0000000..92a2c0f --- /dev/null +++ b/app/login/layout.tsx @@ -0,0 +1,28 @@ +import { Metadata, Viewport } from 'next' + +export const viewport: Viewport = { + themeColor: 'light', +} + +export const metadata: Metadata = { + title: 'Login - Decision Copilot', + description: 'Sign in to Decision Copilot', + openGraph: { + title: 'Login - Decision Copilot', + description: 'Sign in to Decision Copilot', + }, +} + +export default function LoginLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + <> + + + {children} + + ) +} \ No newline at end of file diff --git a/app/login/page.tsx b/app/login/page.tsx index 52bd05c..d8aab71 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -3,11 +3,51 @@ import { Button } from "@/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import Link from 'next/link' -import { signInWithGoogle } from '@/lib/authFunctions' +import { signInWithGoogle, signInWithMicrosoft } from '@/lib/authFunctions' import { useRouter } from 'next/navigation' import { useAuth } from '@/hooks/useAuth' import { useEffect } from 'react' +function GoogleIcon() { + return ( + + ) +} + +function MicrosoftIcon() { + return ( + + ) +} + export default function LoginPage() { const router = useRouter() const { user } = useAuth() @@ -28,6 +68,15 @@ export default function LoginPage() { } } + const handleMicrosoftSignIn = async () => { + try { + await signInWithMicrosoft() + router.push('/organisation') + } catch (error) { + console.error('Error signing in with Microsoft:', error) + } + } + return (
@@ -45,36 +94,22 @@ export default function LoginPage() { - {/* */} +
By continuing, you agree to our{" "} - + Terms of Service - {" "} + {" "} and{" "} - + Privacy Policy - + .
diff --git a/app/organisation/[organisationId]/decision/[id]/edit/EditDecisionClient.tsx b/app/organisation/[organisationId]/decision/[id]/edit/EditDecisionClient.tsx new file mode 100644 index 0000000..16989ad --- /dev/null +++ b/app/organisation/[organisationId]/decision/[id]/edit/EditDecisionClient.tsx @@ -0,0 +1,73 @@ +'use client' + +import WorkflowAccordion from '@/components/workflow/WorkflowAccordion' +import HorizontalWorkflowProgress from '@/components/workflow/horizontal-workflow-progress' +import { notFound, useRouter } from 'next/navigation' +import { useState, useEffect } from 'react' +import { DecisionWorkflowStep, DecisionWorkflowSteps } from '@/lib/domain/Decision' +import { useDecision } from '@/hooks/useDecisions' +import { useToast } from "@/components/ui/use-toast" + +interface EditDecisionClientProps { + organisationId: string + id: string +} + +export default function EditDecisionClient({ organisationId, id }: EditDecisionClientProps) { + const router = useRouter() + const { toast } = useToast() + const { decision, loading } = useDecision(id, organisationId) + const [currentStep, setCurrentStep] = useState(DecisionWorkflowSteps.IDENTIFY) + const [initialLoadComplete, setInitialLoadComplete] = useState(false) + + useEffect(() => { + if (decision && !initialLoadComplete) { + setInitialLoadComplete(true) + if (decision.isPublished()) { + toast({ + title: "Decision is published", + description: "This decision has been published and can no longer be edited.", + variant: "default" + }) + router.push(`/organisation/${organisationId}/decision/${id}/view`) + } else if (decision.isSuperseded()) { + const supersededByRelationship = decision.getSupersededByRelationship() + toast({ + title: "Decision is superseded", + description: `This decision has been superseded by "${supersededByRelationship?.targetDecisionTitle}" and can no longer be edited.`, + variant: "default" + }) + router.push(`/organisation/${organisationId}/decision/${id}/view`) + } + } + }, [decision, organisationId, id, router, toast, initialLoadComplete]) + + if (!organisationId || !id) { + return notFound() + } + + if (loading) { + return
Loading...
+ } + + const handleStepChange = (nextStep: DecisionWorkflowStep) => { + setCurrentStep(nextStep) + } + + return ( +
+
+ +
+ +
+ ) +} \ No newline at end of file diff --git a/app/organisation/[organisationId]/decision/[id]/edit/page.tsx b/app/organisation/[organisationId]/decision/[id]/edit/page.tsx new file mode 100644 index 0000000..0d46f7f --- /dev/null +++ b/app/organisation/[organisationId]/decision/[id]/edit/page.tsx @@ -0,0 +1,17 @@ +import EditDecisionClient from './EditDecisionClient' + +type Params = Promise<{ + organisationId: string + id: string +}> + +export default async function EditDecisionPage({ params }: { params: Params }) { + const { organisationId, id } = await params + + return ( + + ) +} \ No newline at end of file diff --git a/app/organisation/[organisationId]/decision/[id]/layout.tsx b/app/organisation/[organisationId]/decision/[id]/layout.tsx new file mode 100644 index 0000000..7a16409 --- /dev/null +++ b/app/organisation/[organisationId]/decision/[id]/layout.tsx @@ -0,0 +1,17 @@ +'use client' + +import { ReactNode } from 'react' + +export default function DecisionLayout({ + children +}: { + children: ReactNode +}) { + return ( +
+
+ {children} +
+
+ ) +} \ No newline at end of file diff --git a/app/organisation/[organisationId]/decision/[id]/view/page.tsx b/app/organisation/[organisationId]/decision/[id]/view/page.tsx new file mode 100644 index 0000000..b34459d --- /dev/null +++ b/app/organisation/[organisationId]/decision/[id]/view/page.tsx @@ -0,0 +1,72 @@ +'use client' + +import { useParams } from 'next/navigation' +import { useDecision } from '@/hooks/useDecisions' +import { useStakeholders } from '@/hooks/useStakeholders' +import { DecisionSummary } from '@/components/decision-summary' +import Link from 'next/link' + +function PublishedBanner() { + return ( +
+

This decision has been published and can no longer be edited

+
+ ) +} + +function SupersededBanner({ supersedingDecisionId, supersedingDecisionTitle, organisationId }: { + supersedingDecisionId: string + supersedingDecisionTitle: string + organisationId: string +}) { + return ( +
+

+ This decision has been superseded by{' '} + + {supersedingDecisionTitle} + +

+
+ ) +} + +export default function DecisionView() { + const params = useParams() + const { decision, loading } = useDecision(params.id as string, params.organisationId as string) + const { stakeholders } = useStakeholders() + + if (loading) { + return
Loading...
+ } + + if (!decision) { + return
Decision not found
+ } + + const supersededByRelationship = decision.getSupersededByRelationship() + + return ( +
+

{decision.title}

+ + + + {decision.isPublished() && } + {supersededByRelationship && ( + + )} +
+ ) +} + diff --git a/app/organisation/[organisationId]/team/[teamId]/project/[projectId]/decision/create/page.tsx b/app/organisation/[organisationId]/decision/create/page.tsx similarity index 64% rename from app/organisation/[organisationId]/team/[teamId]/project/[projectId]/decision/create/page.tsx rename to app/organisation/[organisationId]/decision/create/page.tsx index d1964f6..b48183d 100644 --- a/app/organisation/[organisationId]/team/[teamId]/project/[projectId]/decision/create/page.tsx +++ b/app/organisation/[organisationId]/decision/create/page.tsx @@ -1,13 +1,15 @@ 'use client' import { useEffect, useRef } from 'react' -import { useRouter } from 'next/navigation' -import { useProjectDecisions } from '@/hooks/useProjectDecisions' +import { useRouter, useParams } from 'next/navigation' +import { useOrganisationDecisions } from '@/hooks/useOrganisationDecisions' import { useAuth } from '@/hooks/useAuth' export default function DecisionPage() { const router = useRouter() - const { createDecision } = useProjectDecisions() + const params = useParams() + const organisationId = params.organisationId as string + const { createDecision } = useOrganisationDecisions(organisationId) const { user } = useAuth() const hasCreatedDecision = useRef(false) @@ -16,7 +18,7 @@ export default function DecisionPage() { if (!hasCreatedDecision.current) { // we only want to run createDecision once hasCreatedDecision.current = true; createDecision().then((decision) => { - router.push(`${decision.id}/identify`) + router.push(`${decision.id}/edit`) }) } } diff --git a/app/organisation/[organisationId]/page.tsx b/app/organisation/[organisationId]/page.tsx index 37e10c4..593e60d 100644 --- a/app/organisation/[organisationId]/page.tsx +++ b/app/organisation/[organisationId]/page.tsx @@ -1,60 +1,278 @@ 'use client' -import { ProjectDecisionChart } from "@/components/project-decisions-chart" -import { ParticipationChart } from "@/components/participation-chart" -import { InProgressTable } from "@/components/in-progress-table" -import React from 'react'; -import { useItems } from '@/hooks/useItems'; -import ItemForm from "@/components/item-form" -import { useOrganisation } from '@/components/organisation-switcher' - -export default function OrganisationDashboard() { - const { selectedOrganisation } = useOrganisation() - const { items, loading, error, updateItemName } = useItems(); - - if (loading) return

Loading...

; - if (error) return

Error: {error.message}

; - if (!items) return

No items found.

; +import React, { useState, useEffect, useMemo, useCallback } from 'react'; +import { useOrganisation } from '@/components/organisation-switcher'; +import { useOrganisationDecisions } from '@/hooks/useOrganisationDecisions'; +import { Button } from "@/components/ui/button"; +import { Pencil, Trash2, FileText, Users, Clock, Search, ArrowUpDown } from 'lucide-react'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { WorkflowProgress } from '@/components/ui/workflow-progress'; +import { Decision, WorkflowNavigator } from '@/lib/domain/Decision'; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; + +interface DecisionCardProps { + decision: Decision; + showEditButton?: boolean; + organisationId: string; +} + +type SortOrder = 'newest' | 'oldest' | 'title-asc' | 'title-desc'; + +export default function OrganisationDecisionsList() { + const params = useParams(); + const organisationId = params.organisationId as string; + const { selectedOrganisation } = useOrganisation(); + const { decisions, loading, error, deleteDecision } = useOrganisationDecisions(organisationId); + + // State for filters and search + const [searchQuery, setSearchQuery] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); + const [sortOrder, setSortOrder] = useState('newest'); + const [debouncedSearchQuery, setDebouncedSearchQuery] = useState(''); + + // Create debounced search handler using useCallback + const debouncedSearch = useCallback((value: string) => { + if (value.length >= 3 || value.length === 0) { + setDebouncedSearchQuery(value); + } + }, []); + + // Handle search input changes with debounce + useEffect(() => { + const timeoutId = setTimeout(() => { + debouncedSearch(searchQuery); + }, 300); + + return () => clearTimeout(timeoutId); + }, [searchQuery, debouncedSearch]); + + // Filter and sort decisions + const filteredAndSortedDecisions = useMemo(() => { + if (!decisions) return []; + + let filtered = decisions; + + // Apply status filter + if (statusFilter !== 'all') { + filtered = filtered.filter(d => d.status === statusFilter); + } + + // Apply search filter (if at least 3 characters or empty) + if (debouncedSearchQuery.length >= 3) { + filtered = filtered.filter(d => + d.title.toLowerCase().includes(debouncedSearchQuery.toLowerCase()) + ); + } + + // Apply sorting + return [...filtered].sort((a, b) => { + switch (sortOrder) { + case 'newest': + return (b.updatedAt || b.createdAt).getTime() - (a.updatedAt || a.createdAt).getTime(); + case 'oldest': + return (a.updatedAt || a.createdAt).getTime() - (b.updatedAt || b.createdAt).getTime(); + case 'title-asc': + return a.title.localeCompare(b.title); + case 'title-desc': + return b.title.localeCompare(a.title); + default: + return 0; + } + }); + }, [decisions, statusFilter, debouncedSearchQuery, sortOrder]); + + // Group filtered and sorted decisions by status + const groupedDecisions = useMemo(() => { + const groups = { + in_progress: [] as Decision[], + blocked: [] as Decision[], + published: [] as Decision[], + superseded: [] as Decision[] + }; + + filteredAndSortedDecisions.forEach(decision => { + if (decision.status in groups) { + groups[decision.status as keyof typeof groups].push(decision); + } + }); + + return groups; + }, [filteredAndSortedDecisions]); + + if (loading) return
Loading decisions...
; + if (error) return
Error loading decisions: {error.message}
; if (!selectedOrganisation) return

...

; - async function handleNameChange(itemId: string, newValue: string): Promise { + const handleDelete = async (decisionId: string) => { try { - await updateItemName(itemId, newValue); - } catch (err) { - if (err instanceof Error) { - alert(err.message); - } else { - alert('An error occurred while updating the item'); - } - throw err; + await deleteDecision(decisionId); + console.log('Decision deleted:', decisionId); + } catch (error) { + console.error('Error deleting decision:', error); } - } + }; - return ( -
-

{selectedOrganisation.name}'s Dashboard

-
+ const DecisionCard = ({ decision, showEditButton = true, organisationId }: DecisionCardProps) => { + return ( +
-
    - {items.map((item) => ( -
  • - -
  • - ))} -
+
+

{decision.title}

+ + {decision.cost} + +
+ {decision.decision && ( +

+ Decision: {decision.decision} +

+ )} +
+
+ +
+
+ + {decision.stakeholders.length} stakeholders +
+
+ + Updated {decision.updatedAt?.toLocaleDateString() || decision.createdAt.toLocaleDateString()} +
+
+
+
+ {showEditButton ? ( + + ) : ( + + )} +
- - {/* Charts row */} -
- - +
+ ); + }; + + const DecisionGroup = ({ title, decisions }: { title: string, decisions: Decision[] }) => { + if (statusFilter !== 'all' && !decisions.length) return null; + + return ( +
+

{title}

+
+ {decisions.map((decision) => ( + + ))} + {statusFilter === 'all' && !decisions.length && ( +

No {title.toLowerCase()} decisions

+ )}
+ ); + }; + + return ( +
+

+ {selectedOrganisation.name}'s Decisions +

+ + {/* Filters and Search Bar */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-9 h-10" + /> +
+ + +
+ +
+ {filteredAndSortedDecisions.length === 0 ? ( +
+ No decisions found matching your criteria +
+ ) : ( + <> + + + + + + )} +
- ) + ); } diff --git a/app/organisation/[organisationId]/team/[teamId]/project/[projectId]/decision/[id]/decide/page.tsx b/app/organisation/[organisationId]/team/[teamId]/project/[projectId]/decision/[id]/decide/page.tsx deleted file mode 100644 index 5e48727..0000000 --- a/app/organisation/[organisationId]/team/[teamId]/project/[projectId]/decision/[id]/decide/page.tsx +++ /dev/null @@ -1,129 +0,0 @@ -'use client' - -import { useParams } from 'next/navigation' -import { useDecision } from '@/hooks/useDecisions' -import Link from 'next/link' -import { Button } from '@/components/ui/button' -import { Card } from '@/components/ui/card' -import { Editor } from '@/components/editor' -import { DecisionItemList } from '@/components/decision-item-list' -import { SupportingMaterialsList } from '@/components/supporting-materials-list' -import { DecisionRelationshipsList } from '@/components/decision-relationships-list' - -export default function DecidePage() { - const params = useParams() - const decisionId = params.id as string - const projectId = params.projectId as string - const teamId = params.teamId as string - const organisationId = params.organisationId as string - - const { - decision, - loading: decisionsLoading, - error: decisionsError, - updateDecisionOptions, - updateDecisionCriteria, - updateDecisionContent, - addSupportingMaterial, - removeSupportingMaterial, - } = useDecision(decisionId) - - if (decisionsLoading) { - return
Loading...
- } - - if (decisionsError) { - return
Error: {decisionsError.message}
- } - - if (!decision) { - return
Decision not found
- } - - const handleAddOption = (option: string) => { - const newOptions = [...decision.options.filter(o => o !== ""), option] - updateDecisionOptions(newOptions) - } - - const handleUpdateOption = (index: number, option: string) => { - const newOptions = [...decision.options] - newOptions[index] = option - updateDecisionOptions(newOptions) - } - - const handleDeleteOption = (index: number) => { - const newOptions = decision.options.filter((_, i) => i !== index) - updateDecisionOptions(newOptions) - } - - const handleAddCriterion = (criterion: string) => { - const newCriteria = [...decision.criteria.filter(c => c !== ""), criterion] - updateDecisionCriteria(newCriteria) - } - - const handleUpdateCriterion = (index: number, criterion: string) => { - const newCriteria = [...decision.criteria] - newCriteria[index] = criterion - updateDecisionCriteria(newCriteria) - } - - const handleDeleteCriterion = (index: number) => { - const newCriteria = decision.criteria.filter((_, i) => i !== index) - updateDecisionCriteria(newCriteria) - } - - return ( - <> -

Decide

- - - - - - - - - -
-

Decision

- updateDecisionContent(content)} - /> -
- - -
- -
- -
- - ) -} - diff --git a/app/organisation/[organisationId]/team/[teamId]/project/[projectId]/decision/[id]/identify/page.tsx b/app/organisation/[organisationId]/team/[teamId]/project/[projectId]/decision/[id]/identify/page.tsx deleted file mode 100644 index 2be0a83..0000000 --- a/app/organisation/[organisationId]/team/[teamId]/project/[projectId]/decision/[id]/identify/page.tsx +++ /dev/null @@ -1,511 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { Card } from "@/components/ui/card"; -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; -import { Checkbox } from "@/components/ui/checkbox"; -import { - Bold, - Italic, - Heading, - Quote, - List, - ListOrdered, - Link as LinkIcon, - Image as ImageIcon, - Eye, - Book, - Maximize, - HelpCircle, - ChevronDown, - ChevronRight, -} from "lucide-react"; -import Link from "next/link"; -import { useParams } from "next/navigation"; -import { useDecision } from "@/hooks/useDecisions"; -import { useStakeholders } from "@/hooks/useStakeholders"; -import { useStakeholderTeams } from "@/hooks/useStakeholderTeams"; -import { useOrganisations } from "@/hooks/useOrganisations"; -import { - Cost, - Reversibility, -} from "@/lib/domain/Decision"; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { Stakeholder } from "@/lib/domain/Stakeholder"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, -} from "@/components/ui/command"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { DecisionRelationshipsList } from '@/components/decision-relationships-list' - -interface StakeholderGroupProps { - teamName: string; - stakeholders: Stakeholder[]; - isExpanded: boolean; - onToggle: () => void; - selectedStakeholderIds: string[]; - onStakeholderChange: (stakeholderId: string, checked: boolean) => void; -} - -function StakeholderGroup({ - teamName, - stakeholders, - isExpanded, - onToggle, - selectedStakeholderIds, - onStakeholderChange, -}: StakeholderGroupProps) { - return ( -
- - {isExpanded && ( -
-
- {stakeholders.map((stakeholder) => ( -
- - onStakeholderChange(stakeholder.id, checked as boolean) - } - /> -
- - - - {stakeholder.displayName - ? stakeholder.displayName - .split(" ") - .map((n) => n[0]) - .join("") - : "?"} - - - -
-
- ))} -
-
- )} -
- ); -} - -export default function DecisionIdentityPage() { - const params = useParams(); - const decisionId = params.id as string; - const projectId = params.projectId as string; - const teamId = params.teamId as string; - const organisationId = params.organisationId as string; - - const { - decision, - loading: decisionsLoading, - error: decisionsError, - updateDecisionTitle, - updateDecisionDescription, - updateDecisionCost, - updateDecisionReversibility, - updateDecisionDriver, - addStakeholder, - removeStakeholder, - } = useDecision(decisionId); - - const { - stakeholders, - loading: stakeholdersLoading, - } = useStakeholders(); - const { stakeholderTeams, loading: stakeholderTeamsLoading } = - useStakeholderTeams(); - const { organisations, loading: organisationsLoading } = useOrganisations(); - const [expandedTeams, setExpandedTeams] = useState([teamId]); // Current team is expanded by default - const [driverOpen, setDriverOpen] = useState(false); - - const currentOrg = organisations?.find((org) => org.id === organisationId); - - if ( - decisionsLoading || - stakeholdersLoading || - stakeholderTeamsLoading || - organisationsLoading - ) { - return
Loading...
- } - - if (decisionsError) { - return
Error: {(decisionsError)?.message}
- } - - if (!decision || !currentOrg) { - return
Decision or organisation not found
; - } - - const handleStakeholderChange = (stakeholderId: string, checked: boolean) => { - if (checked) { - addStakeholder(stakeholderId); - } else { - removeStakeholder(stakeholderId); - } - }; - - const toggleTeam = (teamId: string) => { - setExpandedTeams((prev) => - prev.includes(teamId) - ? prev.filter((id) => id !== teamId) - : [...prev, teamId], - ); - }; - - // Group stakeholders by team - const stakeholdersByTeam = currentOrg.teams.reduce( - (acc, team) => { - const teamStakeholderIds = stakeholderTeams - .filter((st) => st.teamId === team.id) - .map((st) => st.stakeholderId); - - const teamStakeholders = stakeholders.filter((s) => - teamStakeholderIds.includes(s.id), - ); - - if (teamStakeholders.length > 0) { - acc[team.id] = { - name: team.name, - stakeholders: teamStakeholders, - }; - } - - return acc; - }, - {} as Record, - ); - - // Get unique stakeholders for the organization - const uniqueOrgStakeholders = Array.from( - new Map( - Object.values(stakeholdersByTeam) - .flatMap(({ stakeholders }) => stakeholders) - .map((stakeholder) => [stakeholder.id, stakeholder]), - ).values(), - ).sort((a, b) => a.displayName.localeCompare(b.displayName)); - - return ( - <> -
-

- Identify the Decision -

-

- Capture information about the decision being made and who is involved -

-
- - -
-
- -
- - - - - - - - No stakeholder found. - - {uniqueOrgStakeholders.map((stakeholder) => ( - { - updateDecisionDriver(stakeholder.id); - setDriverOpen(false); - }} - > - - - - {stakeholder.displayName - ? stakeholder.displayName - .split(" ") - .map((n) => n[0]) - .join("") - : "?"} - - - {stakeholder.displayName} - - ))} - - - - -
-
- -
- - updateDecisionTitle(e.target.value)} - className="flex-1" - /> -
- -
- -
- -
- -
-
- - - -
- - - -
- - -
- - - - -
-