diff --git a/app/admin/page.tsx b/app/admin/page.tsx index ca548ab..a8057cb 100644 --- a/app/admin/page.tsx +++ b/app/admin/page.tsx @@ -1,68 +1,82 @@ -'use client' +"use client"; -import { useState, useEffect } from 'react' -import { TeamHierarchyTree } from '@/components/TeamHierarchyTree' -import { useAuth } from '@/hooks/useAuth' -import { redirect, useSearchParams } from 'next/navigation' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' -import { useOrganisation } from '@/components/organisation-switcher' -import { Building2 } from 'lucide-react' -import { useOrganisations } from '@/hooks/useOrganisations' -import { StakeholderManagement } from '@/components/StakeholderManagement' +import { useState, useEffect } from "react"; +import { TeamHierarchyTree } from "@/components/TeamHierarchyTree"; +import { useAuth } from "@/hooks/useAuth"; +import { redirect, useSearchParams } from "next/navigation"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useOrganisation } from "@/components/organisation-switcher"; +import { Building2 } from "lucide-react"; +import { useOrganisations } from "@/hooks/useOrganisations"; +import { StakeholderManagement } from "@/components/StakeholderManagement"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, -} from "@/components/ui/select" +} from "@/components/ui/select"; export default function AdminPage() { - const { user, loading: authLoading, isAdmin } = useAuth() - const { selectedOrganisation } = useOrganisation() - const { organisations, loading: orgsLoading, fetchAllOrganisations } = useOrganisations() - const [organisationId, setOrganisationId] = useState(null) - const searchParams = useSearchParams() - const tabParam = searchParams.get('tab') - const [activeTab, setActiveTab] = useState('team-hierarchy') + const { user, loading: authLoading, isAdmin } = useAuth(); + const { selectedOrganisation } = useOrganisation(); + const { + organisations, + loading: orgsLoading, + fetchAllOrganisations, + } = useOrganisations(); + const [organisationId, setOrganisationId] = useState(null); + const searchParams = useSearchParams(); + const tabParam = searchParams?.get("tab") ?? "team-hierarchy"; + const [activeTab, setActiveTab] = useState("team-hierarchy"); useEffect(() => { // Fetch all organizations for admin view if (isAdmin) { - fetchAllOrganisations().catch(error => { - console.error('Failed to fetch all organisations:', error) - }) + fetchAllOrganisations().catch((error) => { + console.error("Failed to fetch all organisations:", error); + }); } - }, [isAdmin, fetchAllOrganisations]) + }, [isAdmin, fetchAllOrganisations]); useEffect(() => { if (selectedOrganisation) { - setOrganisationId(selectedOrganisation.id) + setOrganisationId(selectedOrganisation.id); } - }, [selectedOrganisation]) + }, [selectedOrganisation]); // Set the active tab based on the URL parameter useEffect(() => { if (tabParam) { - setActiveTab(tabParam) + setActiveTab(tabParam); } - }, [tabParam]) + }, [tabParam]); if (authLoading || orgsLoading) { - return
Loading...
+ return ( +
+ Loading... +
+ ); } // If user is not logged in, redirect to login page if (!user) { - redirect('/login') - return null + redirect("/login"); + return null; } // If user is not an admin, redirect to organization page if (!isAdmin) { - redirect('/organisation') - return null + redirect("/organisation"); + return null; } return ( @@ -71,14 +85,17 @@ export default function AdminPage() {

Admin Dashboard

- + {!organisationId ? ( @@ -103,7 +120,12 @@ export default function AdminPage() { ) : ( - + Teams Stakeholders @@ -169,5 +191,5 @@ export default function AdminPage() { )} - ) -} \ 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 index b34459d..917bf57 100644 --- a/app/organisation/[organisationId]/decision/[id]/view/page.tsx +++ b/app/organisation/[organisationId]/decision/[id]/view/page.tsx @@ -1,29 +1,35 @@ -'use client' +"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' +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

+

+ This decision has been published and can no longer be edited +

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

- This decision has been superseded by{' '} - @@ -31,42 +37,47 @@ function SupersededBanner({ supersedingDecisionId, supersedingDecisionTitle, org

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

{decision.title}

- + {decision.isPublished() && } {supersededByRelationship && ( - )}
- ) + ); } - diff --git a/app/organisation/[organisationId]/decision/create/page.tsx b/app/organisation/[organisationId]/decision/create/page.tsx index b48183d..61cf6d6 100644 --- a/app/organisation/[organisationId]/decision/create/page.tsx +++ b/app/organisation/[organisationId]/decision/create/page.tsx @@ -1,28 +1,36 @@ -'use client' +"use client"; -import { useEffect, useRef } from 'react' -import { useRouter, useParams } from 'next/navigation' -import { useOrganisationDecisions } from '@/hooks/useOrganisationDecisions' -import { useAuth } from '@/hooks/useAuth' +import { useEffect, useRef } from "react"; +import { useRouter, useParams } from "next/navigation"; +import { useOrganisationDecisions } from "@/hooks/useOrganisationDecisions"; +import { useAuth } from "@/hooks/useAuth"; export default function DecisionPage() { - const router = useRouter() - const params = useParams() - const organisationId = params.organisationId as string - const { createDecision } = useOrganisationDecisions(organisationId) - const { user } = useAuth() - const hasCreatedDecision = useRef(false) + const router = useRouter(); + const params = useParams(); + const { user } = useAuth(); + const organisationId = (params?.organisationId as string) || ""; + const { createDecision } = useOrganisationDecisions(organisationId); + const hasCreatedDecision = useRef(false); useEffect(() => { - if (user) { // we need to wait until the logged in user is available - if (!hasCreatedDecision.current) { // we only want to run createDecision once + if (!params?.organisationId) return; + + if (user) { + // we need to wait until the logged in user is available + if (!hasCreatedDecision.current) { + // we only want to run createDecision once hasCreatedDecision.current = true; createDecision().then((decision) => { - router.push(`${decision.id}/edit`) - }) + router.push(`${decision.id}/edit`); + }); } } - }, [user, createDecision, router]) + }, [user, createDecision, router, params?.organisationId]); + + if (!params?.organisationId) { + return
Invalid organisation ID
; + } - return
Creating decision...
-} \ No newline at end of file + return
Creating decision...
; +} diff --git a/app/organisation/[organisationId]/page.tsx b/app/organisation/[organisationId]/page.tsx index 593e60d..19ffcd4 100644 --- a/app/organisation/[organisationId]/page.tsx +++ b/app/organisation/[organisationId]/page.tsx @@ -1,16 +1,30 @@ -'use client' +"use client"; -import React, { useState, useEffect, useMemo, useCallback } from 'react'; -import { useOrganisation } from '@/components/organisation-switcher'; -import { useOrganisationDecisions } from '@/hooks/useOrganisationDecisions'; +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 { + 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"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; interface DecisionCardProps { decision: Decision; @@ -18,19 +32,20 @@ interface DecisionCardProps { organisationId: string; } -type SortOrder = 'newest' | 'oldest' | 'title-asc' | 'title-desc'; +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); - + const organisationId = (params?.organisationId as string) || ""; + 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(''); + 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) => { @@ -55,27 +70,33 @@ export default function OrganisationDecisionsList() { let filtered = decisions; // Apply status filter - if (statusFilter !== 'all') { - filtered = filtered.filter(d => d.status === statusFilter); + 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()) + 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': + 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': + case "title-desc": return b.title.localeCompare(a.title); default: return 0; @@ -89,10 +110,10 @@ export default function OrganisationDecisionsList() { in_progress: [] as Decision[], blocked: [] as Decision[], published: [] as Decision[], - superseded: [] as Decision[] + superseded: [] as Decision[], }; - filteredAndSortedDecisions.forEach(decision => { + filteredAndSortedDecisions.forEach((decision) => { if (decision.status in groups) { groups[decision.status as keyof typeof groups].push(decision); } @@ -101,20 +122,33 @@ export default function OrganisationDecisionsList() { return groups; }, [filteredAndSortedDecisions]); + if (!params?.organisationId) { + return
Invalid organisation ID
; + } + if (loading) return
Loading decisions...
; - if (error) return
Error loading decisions: {error.message}
; + if (error) + return ( +
+ Error loading decisions: {error.message} +
+ ); if (!selectedOrganisation) return

...

; const handleDelete = async (decisionId: string) => { try { await deleteDecision(decisionId); - console.log('Decision deleted:', decisionId); + console.log("Decision deleted:", decisionId); } catch (error) { - console.error('Error deleting decision:', error); + console.error("Error deleting decision:", error); } }; - const DecisionCard = ({ decision, showEditButton = true, organisationId }: DecisionCardProps) => { + const DecisionCard = ({ + decision, + showEditButton = true, + organisationId, + }: DecisionCardProps) => { return (
- +
@@ -142,20 +180,28 @@ export default function OrganisationDecisionsList() {
- Updated {decision.updatedAt?.toLocaleDateString() || decision.createdAt.toLocaleDateString()} + + Updated{" "} + {decision.updatedAt?.toLocaleDateString() || + decision.createdAt.toLocaleDateString()} +
{showEditButton ? ( ) : ( @@ -173,9 +219,15 @@ export default function OrganisationDecisionsList() { ); }; - const DecisionGroup = ({ title, decisions }: { title: string, decisions: Decision[] }) => { - if (statusFilter !== 'all' && !decisions.length) return null; - + const DecisionGroup = ({ + title, + decisions, + }: { + title: string; + decisions: Decision[]; + }) => { + if (statusFilter !== "all" && !decisions.length) return null; + return (

{title}

@@ -184,11 +236,14 @@ export default function OrganisationDecisionsList() { ))} - {statusFilter === 'all' && !decisions.length && ( + {statusFilter === "all" && !decisions.length && (

No {title.toLowerCase()} decisions

)}
@@ -199,7 +254,10 @@ export default function OrganisationDecisionsList() { return (

- {selectedOrganisation.name}'s Decisions + + {selectedOrganisation.name} + + 's Decisions

{/* Filters and Search Bar */} @@ -225,7 +283,10 @@ export default function OrganisationDecisionsList() { Published - setSortOrder(value as SortOrder)} + > @@ -265,14 +326,25 @@ export default function OrganisationDecisionsList() {
) : ( <> - - - - + + + + )}
); } - diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 1d62890..af45835 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -1,16 +1,15 @@ -"use client" +"use client"; -import * as React from "react" -import { - Sparkles, - ListTodo, - Users, -} from "lucide-react" -import { useParams } from 'next/navigation' -import Link from 'next/link' +import * as React from "react"; +import { Sparkles, ListTodo, Users } from "lucide-react"; +import { useParams } from "next/navigation"; +import Link from "next/link"; -import { NavUser } from "./nav-user" -import { OrganisationSwitcher, useOrganisation } from "@/components/organisation-switcher" +import { NavUser } from "./nav-user"; +import { + OrganisationSwitcher, + useOrganisation, +} from "@/components/organisation-switcher"; import { Sidebar, SidebarContent, @@ -23,23 +22,25 @@ import { SidebarMenuItem, SidebarMenuButton, useSidebar, -} from "@/components/ui/sidebar" -import { useAuth } from "@/hooks/useAuth" +} from "@/components/ui/sidebar"; +import { useAuth } from "@/hooks/useAuth"; export function AppSidebar(props: React.ComponentProps) { const { user, isAdmin } = useAuth(); const { selectedOrganisation } = useOrganisation(); const params = useParams(); - const organisationId = params.organisationId as string; + const organisationId = (params?.organisationId as string) || ""; const { state } = useSidebar(); const isCollapsed = state === "collapsed"; // Create user data object from authenticated user - const userData = user ? { - name: user.displayName || 'Anonymous', - email: user.email || '', - avatar: user.photoURL || '', - } : null; + const userData = user + ? { + name: user.displayName || "Anonymous", + email: user.email || "", + avatar: user.photoURL || "", + } + : null; if (!userData) return null; @@ -57,7 +58,10 @@ export function AppSidebar(props: React.ComponentProps) { - + {!isCollapsed && Decision list} @@ -67,7 +71,10 @@ export function AppSidebar(props: React.ComponentProps) { - + {!isCollapsed && New decision} @@ -83,7 +90,10 @@ export function AppSidebar(props: React.ComponentProps) { - + {!isCollapsed && Teams} @@ -100,6 +110,5 @@ export function AppSidebar(props: React.ComponentProps) { - ) + ); } - diff --git a/components/decision-relationships-list.tsx b/components/decision-relationships-list.tsx index d01eb6b..d36ce12 100644 --- a/components/decision-relationships-list.tsx +++ b/components/decision-relationships-list.tsx @@ -1,36 +1,54 @@ -import { Decision, DecisionRelationshipType } from '@/lib/domain/Decision' -import { Button } from '@/components/ui/button' -import { AddDecisionRelationshipDialog } from '@/components/add-decision-relationship-dialog' -import { useDecisionRelationships, SelectedDecisionDetails } from '@/hooks/useDecisionRelationships' -import { Plus, X } from 'lucide-react' -import Link from 'next/link' +import { Decision, DecisionRelationshipType } from "@/lib/domain/Decision"; +import { Button } from "@/components/ui/button"; +import { AddDecisionRelationshipDialog } from "@/components/add-decision-relationship-dialog"; +import { + useDecisionRelationships, + SelectedDecisionDetails, +} from "@/hooks/useDecisionRelationships"; +import { Plus, X } from "lucide-react"; +import Link from "next/link"; +import { cn } from "@/lib/utils"; interface DecisionRelationshipItemProps { targetDecision: Decision; type: DecisionRelationshipType; - onRemove: (type: DecisionRelationshipType, targetDecision: Decision) => Promise; + onRemove: ( + type: DecisionRelationshipType, + targetDecision: Decision, + ) => Promise; } -function DecisionRelationshipItem({ targetDecision, type, onRemove }: DecisionRelationshipItemProps) { +function DecisionRelationshipItem({ + targetDecision, + type, + onRemove, +}: DecisionRelationshipItemProps) { + const isWasBlockedBy = type === "was_blocked_by"; + return (
{targetDecision.title}
- + {!isWasBlockedBy && ( + + )}
); } @@ -46,26 +64,38 @@ export function DecisionRelationshipsList({ relationshipType, title, }: DecisionRelationshipsListProps) { - const { addRelationship, removeRelationship } = useDecisionRelationships(fromDecision); + const { addRelationship, removeRelationship } = + useDecisionRelationships(fromDecision); const getRelationshipsForType = (type: DecisionRelationshipType) => { - const relationships = fromDecision.getRelationshipsByType(type); - return relationships.map(relationship => ({ + let relationships = []; + + // If we're showing blocked_by relationships, also include was_blocked_by + if (type === "blocked_by") { + relationships = [ + ...fromDecision.getRelationshipsByType("blocked_by"), + ...fromDecision.getRelationshipsByType("was_blocked_by"), + ]; + } else { + relationships = fromDecision.getRelationshipsByType(type); + } + + return relationships.map((relationship) => ({ targetDecision: Decision.create({ id: relationship.targetDecision.id, title: relationship.targetDecisionTitle, - description: '', - cost: 'low', + description: "", + cost: "low", createdAt: new Date(), - reversibility: 'hat', + reversibility: "hat", stakeholders: [], - driverStakeholderId: '', + driverStakeholderId: "", organisationId: fromDecision.organisationId, teamIds: [], projectIds: [], - supportingMaterials: [] + supportingMaterials: [], }), - type: relationship.type + type: relationship.type, })); }; @@ -73,20 +103,31 @@ export function DecisionRelationshipsList({ await addRelationship(details, relationshipType); }; - const handleRemove = async (type: DecisionRelationshipType, targetDecision: Decision) => { + const handleRemove = async ( + type: DecisionRelationshipType, + targetDecision: Decision, + ) => { await removeRelationship(type, targetDecision); }; - const getRelationshipDescriptionForAddDialog = (type: DecisionRelationshipType): string => { + const getRelationshipDescriptionForAddDialog = ( + type: DecisionRelationshipType, + ): string => { switch (type) { - case 'blocked_by': - return 'Select a decision that blocks this decision'; - case 'blocks': - return 'Select a decision that this decision blocks'; - case 'supersedes': - return 'Select a decision that this decision supersedes'; - case 'superseded_by': - return 'Select a decision that supersedes this decision'; + case "blocked_by": + return "Select a decision that blocks this decision"; + case "blocks": + return "Select a decision that this decision blocks"; + case "supersedes": + return "Select a decision that this decision supersedes"; + case "superseded_by": + return "Select a decision that supersedes this decision"; + case "did_block": + return "Select a decision that this decision blocked"; + case "was_blocked_by": + return "Select a decision that blocked this decision"; + default: + return `Select a decision to create a ${type} relationship`; } }; @@ -99,7 +140,9 @@ export function DecisionRelationshipsList({

{title}