import { DEFAULT_FEATURES, DEFAULT_ISSUE_TYPES, DEFAULT_STATUS_COLOUR, ISSUE_STATUS_MAX_LENGTH, ISSUE_TYPE_MAX_LENGTH, type SprintRecord, } from "@sprint/shared"; import { useQueryClient } from "@tanstack/react-query"; import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; // import { Link } from "react-router-dom"; import { toast } from "sonner"; import { AddMember } from "@/components/add-member"; // import { FreeTierLimit } from "@/components/free-tier-limit"; import OrgIcon from "@/components/org-icon"; import { OrganisationForm } from "@/components/organisation-form"; import { OrganisationSelect } from "@/components/organisation-select"; import { ProjectForm } from "@/components/project-form"; import { ProjectSelect } from "@/components/project-select"; import { useSelection } from "@/components/selection-provider"; import { useAuthenticatedSession } from "@/components/session-provider"; import SmallSprintDisplay from "@/components/small-sprint-display"; import SmallUserDisplay from "@/components/small-user-display"; import { SprintForm } from "@/components/sprint-form"; import StatusTag from "@/components/status-tag"; import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; import ColourPicker from "@/components/ui/colour-picker"; import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import Icon, { type IconName, iconNames } from "@/components/ui/icon"; import { IconButton } from "@/components/ui/icon-button"; import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { useDeleteOrganisation, useDeleteProject, useDeleteSprint, // useIssues, useOrganisationMembers, useOrganisationMemberTimeTracking, useOrganisations, useProjects, useRemoveOrganisationMember, useReplaceIssueStatus, useReplaceIssueType, useSprints, useUpdateOrganisation, useUpdateOrganisationMemberRole, } from "@/lib/query/hooks"; import { queryKeys } from "@/lib/query/keys"; import { apiClient } from "@/lib/server"; import { capitalise, formatDuration, unCamelCase } from "@/lib/utils"; import { Switch } from "./ui/switch"; // const FREE_TIER_LIMITS = { // organisationsPerUser: 1, // projectsPerOrganisation: 1, // issuesPerOrganisation: 100, // membersPerOrganisation: 5, // } as const; function Organisations({ trigger }: { trigger?: ReactNode }) { const { user } = useAuthenticatedSession(); const queryClient = useQueryClient(); const { selectedOrganisationId, selectedProjectId } = useSelection(); const { data: organisationsData = [] } = useOrganisations(); const { data: projectsData = [] } = useProjects(selectedOrganisationId); const { data: sprints = [] } = useSprints(selectedProjectId); const { data: membersData = [] } = useOrganisationMembers(selectedOrganisationId); // const { data: issues = [] } = useIssues(selectedProjectId); const updateOrganisation = useUpdateOrganisation(); const updateMemberRole = useUpdateOrganisationMemberRole(); const removeMember = useRemoveOrganisationMember(); const deleteOrganisation = useDeleteOrganisation(); const deleteProject = useDeleteProject(); const deleteSprint = useDeleteSprint(); const replaceIssueStatus = useReplaceIssueStatus(); const replaceIssueType = useReplaceIssueType(); // const isPro = user.plan === "pro"; // const orgCount = organisationsData.length; // const projectCount = projectsData.length; // const issueCount = issues.length; // const memberCount = membersData.length; const organisations = useMemo( () => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)), [organisationsData], ); const projects = useMemo( () => [...projectsData].sort((a, b) => a.Project.name.localeCompare(b.Project.name)), [projectsData], ); const selectedOrganisation = useMemo( () => organisations.find((org) => org.Organisation.id === selectedOrganisationId) ?? null, [organisations, selectedOrganisationId], ); const selectedProject = useMemo( () => projects.find((proj) => proj.Project.id === selectedProjectId) ?? null, [projects, selectedProjectId], ); const invalidateOrganisations = () => queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() }); const invalidateProjects = () => queryClient.invalidateQueries({ queryKey: queryKeys.projects.byOrganisation(selectedOrganisationId ?? 0), }); const invalidateMembers = useCallback( () => queryClient.invalidateQueries({ queryKey: queryKeys.organisations.members(selectedOrganisationId ?? 0), }), [queryClient, selectedOrganisationId], ); const invalidateSprints = () => queryClient.invalidateQueries({ queryKey: queryKeys.sprints.byProject(selectedProjectId ?? 0) }); // time tracking state - must be before membersWithTimeTracking useMemo const [fromDate, setFromDate] = useState(() => { // default to same day of previous month const now = new Date(); const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate()); return prevMonth; }); const { data: timeTrackingData = [] } = useOrganisationMemberTimeTracking(selectedOrganisationId, fromDate); const members = useMemo(() => { const roleOrder: Record = { owner: 0, admin: 1, member: 2 }; return [...membersData].sort((a, b) => { const roleA = roleOrder[a.OrganisationMember.role] ?? 3; const roleB = roleOrder[b.OrganisationMember.role] ?? 3; if (roleA !== roleB) return roleA - roleB; return a.User.name.localeCompare(b.User.name); }); }, [membersData]); const membersWithTimeTracking = useMemo(() => { const timePerUser = new Map(); for (const session of timeTrackingData) { const current = timePerUser.get(session.userId) ?? 0; timePerUser.set(session.userId, current + (session.workTimeMs ?? 0)); } const membersWithTime = members.map((member) => ({ ...member, totalTimeMs: timePerUser.get(member.User.id) ?? 0, })); const roleOrder: Record = { owner: 0, admin: 1, member: 2 }; return membersWithTime.sort((a, b) => { if (b.totalTimeMs !== a.totalTimeMs) { return b.totalTimeMs - a.totalTimeMs; } const roleA = roleOrder[a.OrganisationMember.role] ?? 3; const roleB = roleOrder[b.OrganisationMember.role] ?? 3; if (roleA !== roleB) return roleA - roleB; return a.User.name.localeCompare(b.User.name); }); }, [members, timeTrackingData]); const downloadTimeTrackingData = (format: "csv" | "json") => { if (!selectedOrganisation) return; const userData = new Map< number, { userId: number; name: string; username: string; totalTimeMs: number; sessions: typeof timeTrackingData; } >(); for (const member of members) { userData.set(member.User.id, { userId: member.User.id, name: member.User.name, username: member.User.username, totalTimeMs: 0, sessions: [], }); } for (const session of timeTrackingData) { const user = userData.get(session.userId); if (user) { user.totalTimeMs += session.workTimeMs; user.sessions.push(session); } } const data = Array.from(userData.values()).sort((a, b) => b.totalTimeMs - a.totalTimeMs); // generate CSV or JSON if (format === "csv") { const headers = ["User ID", "Name", "Username", "Total Time (ms)", "Total Time (formatted)", "Hours"]; const rows = data.map((user) => [ user.userId, user.name, user.username, user.totalTimeMs, formatDuration(user.totalTimeMs), (user.totalTimeMs / 3600000).toFixed(2), ]); const csv = [headers.join(","), ...rows.map((row) => row.map((cell) => `"${cell}"`).join(","))].join( "\n", ); // download const blob = new Blob([csv], { type: "text/csv" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${selectedOrganisation.Organisation.slug}-time-tracking-${fromDate.toISOString().split("T")[0]}.csv`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } else { const json = JSON.stringify( { organisation: selectedOrganisation.Organisation.name, fromDate: fromDate.toISOString(), generatedAt: new Date().toISOString(), members: data.map((user) => ({ ...user, totalTimeFormatted: formatDuration(user.totalTimeMs), hours: Number((user.totalTimeMs / 3600000).toFixed(2)), })), }, null, 2, ); // download const blob = new Blob([json], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${selectedOrganisation.Organisation.slug}-time-tracking-${fromDate.toISOString().split("T")[0]}.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } toast.success(`Downloaded time tracking data as ${format.toUpperCase()}`); }; const downloadOrganisationExport = async () => { if (!selectedOrganisation) return; try { const { data, error } = await apiClient.organisationExport({ query: { id: selectedOrganisation.Organisation.id }, }); if (error || !data) { throw new Error(error ?? "failed to export organisation"); } const json = JSON.stringify(data, null, 2); const blob = new Blob([json], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${selectedOrganisation.Organisation.slug}-export.json`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); toast.success(`Downloaded ${selectedOrganisation.Organisation.name} export`); } catch (err) { console.error(err); toast.error(`Error exporting ${selectedOrganisation.Organisation.name}: ${String(err)}`, { dismissible: false, }); } }; const [open, setOpen] = useState(false); const [activeTab, setActiveTab] = useState("info"); const [statuses, setStatuses] = useState>({}); const [isCreatingStatus, setIsCreatingStatus] = useState(false); const [newStatusName, setNewStatusName] = useState(""); const [newStatusColour, setNewStatusColour] = useState(DEFAULT_STATUS_COLOUR); const [statusError, setStatusError] = useState(null); const [statusToRemove, setStatusToRemove] = useState(null); const [issuesUsingStatus, setIssuesUsingStatus] = useState(0); const [reassignToStatus, setReassignToStatus] = useState(""); // issue types state type IssueTypeConfig = { icon: string; color: string }; const [issueTypes, setIssueTypes] = useState>({}); const [isCreatingType, setIsCreatingType] = useState(false); const [newTypeName, setNewTypeName] = useState(""); const [newTypeIcon, setNewTypeIcon] = useState("checkBox"); const [newTypeColour, setNewTypeColour] = useState(DEFAULT_ISSUE_TYPES.Task.color); const [typeError, setTypeError] = useState(null); const [typeToRemove, setTypeToRemove] = useState(null); const [issuesUsingType, setIssuesUsingType] = useState(0); const [reassignToType, setReassignToType] = useState(""); // edit/delete state for organisations, projects, and sprints const [editOrgOpen, setEditOrgOpen] = useState(false); const [editProjectOpen, setEditProjectOpen] = useState(false); const [editSprintOpen, setEditSprintOpen] = useState(false); const [editingSprint, setEditingSprint] = useState(null); const [confirmDialog, setConfirmDialog] = useState<{ open: boolean; title: string; message: string; confirmText: string; processingText: string; variant: "default" | "destructive"; onConfirm: () => Promise; }>({ open: false, title: "", message: "", confirmText: "", processingText: "", variant: "default", onConfirm: async () => {}, }); const isAdmin = selectedOrganisation?.OrganisationMember.role === "owner" || selectedOrganisation?.OrganisationMember.role === "admin"; const isOwner = selectedOrganisation?.OrganisationMember.role === "owner"; const canDeleteProject = isOwner || (selectedProject && selectedProject.Project.creatorId === user?.id); const formatDate = (value: Date | string) => new Date(value).toLocaleDateString(undefined, { month: "short", day: "numeric" }); const getSprintDateRange = (sprint: SprintRecord) => { if (!sprint.startDate || !sprint.endDate) return ""; return `${formatDate(sprint.startDate)} - ${formatDate(sprint.endDate)}`; }; const isCurrentSprint = (sprint: SprintRecord) => { if (!sprint.startDate || !sprint.endDate) return false; const today = new Date(); const start = new Date(sprint.startDate); const end = new Date(sprint.endDate); return start <= today && today <= end; }; const handleRoleChange = (memberUserId: number, memberName: string, currentRole: string) => { if (!selectedOrganisation) return; const action = currentRole === "admin" ? "demote" : "promote"; const newRole = currentRole === "admin" ? "member" : "admin"; setConfirmDialog({ open: true, title: action === "promote" ? "Promote Member" : "Demote Member", message: `Are you sure you want to ${action} ${memberName} to ${newRole}?`, confirmText: action === "promote" ? "Promote" : "Demote", processingText: action === "promote" ? "Promoting..." : "Demoting...", variant: action === "demote" ? "destructive" : "default", onConfirm: async () => { try { await updateMemberRole.mutateAsync({ organisationId: selectedOrganisation.Organisation.id, userId: memberUserId, role: newRole, }); closeConfirmDialog(); toast.success(`${capitalise(action)}d ${memberName} to ${newRole} successfully`, { dismissible: false, }); } catch (err) { console.error(err); toast.error(`Error ${action.slice(0, -1)}ing ${memberName} to ${newRole}: ${String(err)}`, { dismissible: false, }); } }, }); }; const closeConfirmDialog = () => { setConfirmDialog((prev) => ({ ...prev, open: false })); }; const handleRemoveMember = (memberUserId: number, memberName: string) => { if (!selectedOrganisation) return; setConfirmDialog({ open: true, title: "Remove Member", message: `Are you sure you want to remove ${memberName} from this organisation?`, confirmText: "Remove", processingText: "Removing...", variant: "destructive", onConfirm: async () => { try { await removeMember.mutateAsync({ organisationId: selectedOrganisation.Organisation.id, userId: memberUserId, }); closeConfirmDialog(); toast.success(`Removed ${memberName} from ${selectedOrganisation.Organisation.name} successfully`, { dismissible: false, }); } catch (err) { console.error(err); toast.error( `Error removing member from ${selectedOrganisation.Organisation.name}: ${String(err)}`, { dismissible: false, }, ); } }, }); }; useEffect(() => { if (selectedOrganisation) { setStatuses(selectedOrganisation.Organisation.statuses); const orgIssueTypes = selectedOrganisation.Organisation.issueTypes as Record; setIssueTypes(orgIssueTypes ?? {}); } }, [selectedOrganisation]); const updateStatuses = async ( newStatuses: Record, statusRemoved?: { name: string; colour: string }, statusAdded?: { name: string; colour: string }, statusMoved?: { name: string; colour: string; currentIndex: number; nextIndex: number }, ) => { if (!selectedOrganisation) return; try { await updateOrganisation.mutateAsync({ id: selectedOrganisation.Organisation.id, statuses: newStatuses, }); setStatuses(newStatuses); if (statusAdded) { toast.success( <> Created status successfully , { dismissible: false, }, ); } else if (statusRemoved) { toast.success( <> Removed status successfully , { dismissible: false, }, ); } else if (statusMoved) { toast.success( <> Moved from position {statusMoved.currentIndex + 1} to {statusMoved.nextIndex + 1} successfully , { dismissible: false, }, ); } await invalidateOrganisations(); } catch (err) { console.error("error updating statuses:", err); if (statusAdded) { toast.error( <> Error adding to{" "} {selectedOrganisation.Organisation.name}: {String(err)} , { dismissible: false, }, ); } else if (statusRemoved) { toast.error( <> Error removing from{" "} {selectedOrganisation.Organisation.name}: {String(err)} , { dismissible: false, }, ); } else if (statusMoved) { toast.error( <> Error moving from position {statusMoved.currentIndex + 1} to {statusMoved.nextIndex + 1}{" "} {selectedOrganisation.Organisation.name}: {String(err)} , { dismissible: false, }, ); } } }; const handleCreateStatus = async () => { const trimmed = newStatusName.trim().toUpperCase(); if (!trimmed) return; if (trimmed.length > ISSUE_STATUS_MAX_LENGTH) { setStatusError(`status must be <= ${ISSUE_STATUS_MAX_LENGTH} characters`); return; } if (Object.keys(statuses).includes(trimmed)) { setNewStatusName(""); setIsCreatingStatus(false); setStatusError(null); return; } const newStatuses = { ...statuses }; newStatuses[trimmed] = newStatusColour; await updateStatuses(newStatuses, undefined, { name: trimmed, colour: newStatusColour }); setNewStatusName(""); setNewStatusColour(DEFAULT_STATUS_COLOUR); setIsCreatingStatus(false); setStatusError(null); }; const handleRemoveStatusClick = async (status: string) => { if (Object.keys(statuses).length <= 1 || !selectedOrganisation) return; try { const { data, error } = await apiClient.issuesStatusCount({ query: { organisationId: selectedOrganisation.Organisation.id, status }, }); if (error) throw new Error(error); const statusCounts = (data ?? []) as { status: string; count: number }[]; const count = statusCounts.find((item) => item.status === status)?.count ?? 0; if (count > 0) { setStatusToRemove(status); setIssuesUsingStatus(count); const remaining = Object.keys(statuses).filter((item) => item !== status); setReassignToStatus(remaining[0] || ""); return; } const nextStatuses = Object.keys(statuses).filter((item) => item !== status); await updateStatuses( Object.fromEntries(nextStatuses.map((statusKey) => [statusKey, statuses[statusKey]])), { name: status, colour: statuses[status] }, ); } catch (err) { console.error("error checking status usage:", err); toast.error( <> Error checking status usage for :{" "} {String(err)} , { dismissible: false, }, ); } }; const moveStatus = async (status: string, direction: "up" | "down") => { const currentIndex = Object.keys(statuses).indexOf(status); if (currentIndex === -1) return; const nextIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1; if (nextIndex < 0 || nextIndex >= Object.keys(statuses).length) return; const nextStatuses = [...Object.keys(statuses)]; [nextStatuses[currentIndex], nextStatuses[nextIndex]] = [ nextStatuses[nextIndex], nextStatuses[currentIndex], ]; await updateStatuses( Object.fromEntries(nextStatuses.map((status) => [status, statuses[status]])), undefined, undefined, { name: status, colour: statuses[status], currentIndex, nextIndex }, ); }; const confirmRemoveStatus = async () => { if (!statusToRemove || !reassignToStatus || !selectedOrganisation) return; try { await replaceIssueStatus.mutateAsync({ organisationId: selectedOrganisation.Organisation.id, oldStatus: statusToRemove, newStatus: reassignToStatus, }); const newStatuses = Object.keys(statuses).filter((item) => item !== statusToRemove); await updateStatuses(Object.fromEntries(newStatuses.map((status) => [status, statuses[status]])), { name: statusToRemove, colour: statuses[statusToRemove], }); setStatusToRemove(null); setReassignToStatus(""); } catch (error) { console.error("error replacing status:", error); toast.error( <> Error removing from {selectedOrganisation.Organisation.name}: {String(error)} , { dismissible: false, }, ); } }; // issue types functions const updateIssueTypes = async ( newIssueTypes: Record, typeRemoved?: { name: string; icon: string; color: string }, typeAdded?: { name: string; icon: string; color: string }, typeMoved?: { name: string; icon: string; color: string; currentIndex: number; nextIndex: number }, ) => { if (!selectedOrganisation) return; try { await updateOrganisation.mutateAsync({ id: selectedOrganisation.Organisation.id, issueTypes: newIssueTypes, }); setIssueTypes(newIssueTypes); if (typeAdded) { toast.success( Created {typeAdded.name} type successfully , { dismissible: false }, ); } else if (typeRemoved) { toast.success( Removed {typeRemoved.name} type successfully , { dismissible: false }, ); } else if (typeMoved) { toast.success( Moved {typeMoved.name} from position {typeMoved.currentIndex + 1} to {typeMoved.nextIndex + 1} , { dismissible: false }, ); } await invalidateOrganisations(); } catch (err) { console.error("error updating issue types:", err); if (typeAdded) { toast.error( Error adding {typeAdded.name} to {selectedOrganisation.Organisation.name}: {String(err)} , { dismissible: false }, ); } else if (typeRemoved) { toast.error( Error removing {typeRemoved.name} from {selectedOrganisation.Organisation.name}: {String(err)} , { dismissible: false }, ); } } }; const handleCreateType = async () => { const trimmed = newTypeName.trim(); if (!trimmed) return; if (trimmed.length > ISSUE_TYPE_MAX_LENGTH) { setTypeError(`type name must be <= ${ISSUE_TYPE_MAX_LENGTH} characters`); return; } if (Object.keys(issueTypes).includes(trimmed)) { setNewTypeName(""); setIsCreatingType(false); setTypeError(null); return; } const newIssueTypes = { ...issueTypes }; newIssueTypes[trimmed] = { icon: newTypeIcon, color: newTypeColour }; await updateIssueTypes(newIssueTypes, undefined, { name: trimmed, icon: newTypeIcon, color: newTypeColour, }); setNewTypeName(""); setNewTypeIcon("checkBox"); setNewTypeColour(DEFAULT_ISSUE_TYPES.Task.color); setIsCreatingType(false); setTypeError(null); }; const handleRemoveTypeClick = async (typeName: string) => { if (Object.keys(issueTypes).length <= 1 || !selectedOrganisation) return; try { const { data, error } = await apiClient.issuesTypeCount({ query: { organisationId: selectedOrganisation.Organisation.id, type: typeName }, }); if (error) throw new Error(error); const typeCount = (data ?? { count: 0 }) as { count: number }; const count = typeCount.count ?? 0; if (count > 0) { setTypeToRemove(typeName); setIssuesUsingType(count); const remaining = Object.keys(issueTypes).filter((t) => t !== typeName); setReassignToType(remaining[0] || ""); return; } const nextTypes = Object.keys(issueTypes).filter((t) => t !== typeName); await updateIssueTypes(Object.fromEntries(nextTypes.map((t) => [t, issueTypes[t]])), { name: typeName, ...issueTypes[typeName], }); } catch (err) { console.error("error checking type usage:", err); toast.error( Error checking type usage for{" "} {typeName}: {String(err)} , { dismissible: false }, ); } }; const moveType = async (typeName: string, direction: "up" | "down") => { const keys = Object.keys(issueTypes); const currentIndex = keys.indexOf(typeName); if (currentIndex === -1) return; const nextIndex = direction === "up" ? currentIndex - 1 : currentIndex + 1; if (nextIndex < 0 || nextIndex >= keys.length) return; const nextKeys = [...keys]; [nextKeys[currentIndex], nextKeys[nextIndex]] = [nextKeys[nextIndex], nextKeys[currentIndex]]; await updateIssueTypes( Object.fromEntries(nextKeys.map((t) => [t, issueTypes[t]])), undefined, undefined, { name: typeName, ...issueTypes[typeName], currentIndex, nextIndex }, ); }; const confirmRemoveType = async () => { if (!typeToRemove || !reassignToType || !selectedOrganisation) return; try { await replaceIssueType.mutateAsync({ organisationId: selectedOrganisation.Organisation.id, oldType: typeToRemove, newType: reassignToType, }); const nextTypes = Object.keys(issueTypes).filter((t) => t !== typeToRemove); await updateIssueTypes(Object.fromEntries(nextTypes.map((t) => [t, issueTypes[t]])), { name: typeToRemove, ...issueTypes[typeToRemove], }); setTypeToRemove(null); setReassignToType(""); } catch (error) { console.error("error replacing type:", error); toast.error( Error removing{" "} {typeToRemove} from {selectedOrganisation.Organisation.name}: {String(error)} , { dismissible: false }, ); } }; useEffect(() => { if (!open || !selectedOrganisationId) return; void invalidateMembers(); }, [open, invalidateMembers, selectedOrganisationId]); return ( {trigger || ( )} Organisations
{selectedOrganisation ? (
Info Users Projects Issues Features
{" "}

{selectedOrganisation.Organisation.name}

Slug: {selectedOrganisation.Organisation.slug}

Role: {selectedOrganisation.OrganisationMember.role}

{selectedOrganisation.Organisation.description ? (

{selectedOrganisation.Organisation.description}

) : (

No description

)}
{/* Free tier limits section */} {/* {!isPro && (

Plan Limits

)} */} {isAdmin && (
{isOwner && ( )}
)}
{ await invalidateOrganisations(); }} />

{members.length} Member{members.length !== 1 ? "s" : ""}

{isAdmin && (
date && setFromDate(date)} autoFocus /> downloadTimeTrackingData("csv")}> Download CSV downloadTimeTrackingData("json")}> Download JSON
)}
{membersWithTimeTracking.map((member) => (
{member.OrganisationMember.role}
{isAdmin && ( {formatDuration(member.totalTimeMs)} )} {isAdmin && member.OrganisationMember.role !== "owner" && member.User.id !== user.id && ( <> handleRoleChange( member.User.id, member.User.name, member.OrganisationMember.role, ) } variant={member.OrganisationMember.role === "admin" ? "yellow" : "green"} > {member.OrganisationMember.role === "admin" ? ( ) : ( )} handleRemoveMember(member.User.id, member.User.name)} > )}
))}
{isAdmin && ( <> {/* {!isPro && (
= FREE_TIER_LIMITS.membersPerOrganisation} />
)} */} m.User.username)} onSuccess={(user) => { toast.success( `${user.name} added to ${selectedOrganisation.Organisation.name} successfully`, { dismissible: false, }, ); void invalidateMembers(); }} trigger={ } /> )}
{selectedProject ? ( <>

{selectedProject.Project.name}

Key: {selectedProject.Project.key}

Creator: {selectedProject.User.name}

{isAdmin && (
{canDeleteProject && ( )}
)} ) : (

Select a project to view details.

)}
{selectedProject ? (
{sprints.map((sprintItem) => { const dateRange = getSprintDateRange(sprintItem); const isCurrent = isCurrentSprint(sprintItem); return (
{dateRange && ( {dateRange} )} {isAdmin && ( { setEditingSprint(sprintItem); setEditSprintOpen(true); }} className="hover:bg-primary-foreground" > Edit { setConfirmDialog({ open: true, title: "Delete Sprint", message: `Are you sure you want to delete "${sprintItem.name}"? Issues assigned to this sprint will become unassigned.`, confirmText: "Delete", processingText: "Deleting...", variant: "destructive", onConfirm: async () => { try { await deleteSprint.mutateAsync(sprintItem.id); closeConfirmDialog(); toast.success(`Deleted sprint "${sprintItem.name}"`); await invalidateSprints(); } catch (error) { console.error(error); } }, }); }} className="hover:bg-destructive/10" > Delete )}
); })} {isAdmin && ( Create sprint } sprints={sprints} /> )}
) : (

Select a project to view sprints.

)}
{selectedProject && ( <> { await invalidateProjects(); }} /> { setEditSprintOpen(open); if (!open) setEditingSprint(null); }} completeAction={async () => { await invalidateSprints(); }} /> )}
{/* Issue Types section */}

Issue Types

{Object.keys(issueTypes).map((typeName, index) => { const typeConfig = issueTypes[typeName]; return (
{index + 1} {typeName}
{isAdmin && ( void moveType(typeName, "up")} className="hover:bg-primary-foreground" > Move up void moveType(typeName, "down")} className="hover:bg-primary-foreground" > Move down void handleRemoveTypeClick(typeName)} className="hover:bg-destructive/10" > Remove )}
); })}
{isAdmin && (isCreatingType ? ( <>
{ setNewTypeName(e.target.value); if (typeError) setTypeError(null); }} placeholder="Type name" className="flex-1 w-0 min-w-0" onKeyDown={(e) => { if (e.key === "Enter") { void handleCreateType(); } else if (e.key === "Escape") { setIsCreatingType(false); setNewTypeName(""); setTypeError(null); } }} autoFocus /> void handleCreateType()} disabled={newTypeName.trim().length > ISSUE_TYPE_MAX_LENGTH} >
{typeError &&

{typeError}

} ) : ( ))}
{/* Issue Statuses section */}

Issue Statuses

{Object.keys(statuses).map((status, index) => (
{index + 1}
{isAdmin && ( void moveStatus(status, "up")} className="hover:bg-primary-foreground" > Move up void moveStatus(status, "down")} className="hover:bg-primary-foreground" > Move down void handleRemoveStatusClick(status)} className="hover:bg-destructive/10" > Remove )}
))}
{isAdmin && (isCreatingStatus ? ( <>
{ setNewStatusName(e.target.value); if (statusError) setStatusError(null); }} placeholder="Status name" className="flex-1 w-0 min-w-0" onKeyDown={(e) => { if (e.key === "Enter") { void handleCreateStatus(); } else if (e.key === "Escape") { setIsCreatingStatus(false); setNewStatusName(""); setStatusError(null); } }} autoFocus /> void handleCreateStatus()} disabled={newStatusName.trim().length > ISSUE_STATUS_MAX_LENGTH} >
{statusError &&

{statusError}

} ) : ( ))}

Features

{/* {!isPro && (
Feature toggling is only available on Pro.{" "} Upgrade to customize features.
)} */}
{Object.keys(DEFAULT_FEATURES).map((feature) => (
{ if (!selectedOrganisation) return; const newFeatures = selectedOrganisation.Organisation.features; newFeatures[feature] = checked; await updateOrganisation.mutateAsync({ id: selectedOrganisation.Organisation.id, features: newFeatures, }); toast.success( `${capitalise(unCamelCase(feature))} ${ checked ? "enabled" : "disabled" } for ${selectedOrganisation.Organisation.name}`, ); await invalidateOrganisations(); }} color={"#ff0000"} /> {unCamelCase(feature)}
))}
) : (

No organisations yet.

)} setConfirmDialog((prev) => ({ ...prev, open }))} onConfirm={confirmDialog.onConfirm} title={confirmDialog.title} processingText={confirmDialog.processingText} message={confirmDialog.message} confirmText={confirmDialog.confirmText} variant={confirmDialog.variant} /> {/* Status removal dialog with reassignment */} { if (!open) { setStatusToRemove(null); setReassignToStatus(""); } }} > Remove Status

Are you sure you want to remove the{" "} {statusToRemove ? ( ) : null}{" "} status? {issuesUsingStatus} issues are using it. Which status would you like these issues to use instead?

{/* Type removal dialog with reassignment */} { if (!open) { setTypeToRemove(null); setReassignToType(""); } }} > Remove Type

Are you sure you want to remove the{" "} {typeToRemove && issueTypes[typeToRemove] ? ( {typeToRemove} ) : null}{" "} type? {issuesUsingType} issues are using it. Which type would you like these issues to use instead?

); } export default Organisations;