mirror of
https://github.com/hex248/sprint.git
synced 2026-02-07 18:23:03 +00:00
338 lines
14 KiB
TypeScript
338 lines
14 KiB
TypeScript
/** biome-ignore-all lint/correctness/useExhaustiveDependencies: <> */
|
|
|
|
import type {
|
|
IssueResponse,
|
|
OrganisationMemberResponse,
|
|
OrganisationResponse,
|
|
ProjectResponse,
|
|
UserRecord,
|
|
} from "@issue/shared";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import AccountDialog from "@/components/account-dialog";
|
|
import { CreateIssue } from "@/components/create-issue";
|
|
import { IssueDetailPane } from "@/components/issue-detail-pane";
|
|
import { IssuesTable } from "@/components/issues-table";
|
|
import LogOutButton from "@/components/log-out-button";
|
|
import { OrganisationSelect } from "@/components/organisation-select";
|
|
import OrganisationsDialog from "@/components/organisations-dialog";
|
|
import { ProjectSelect } from "@/components/project-select";
|
|
import { ServerConfigurationDialog } from "@/components/server-configuration-dialog";
|
|
import { useAuthenticatedSession } from "@/components/session-provider";
|
|
import SmallUserDisplay from "@/components/small-user-display";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { ResizablePanel, ResizablePanelGroup, ResizableSeparator } from "@/components/ui/resizable";
|
|
import { issue, organisation, project } from "@/lib/server";
|
|
|
|
const BREATHING_ROOM = 1;
|
|
|
|
export default function App() {
|
|
const { user } = useAuthenticatedSession();
|
|
|
|
const organisationsRef = useRef(false);
|
|
const [organisations, setOrganisations] = useState<OrganisationResponse[]>([]);
|
|
const [selectedOrganisation, setSelectedOrganisation] = useState<OrganisationResponse | null>(null);
|
|
|
|
const [projects, setProjects] = useState<ProjectResponse[]>([]);
|
|
const [selectedProject, setSelectedProject] = useState<ProjectResponse | null>(null);
|
|
|
|
const [issues, setIssues] = useState<IssueResponse[]>([]);
|
|
const [selectedIssue, setSelectedIssue] = useState<IssueResponse | null>(null);
|
|
|
|
const [members, setMembers] = useState<UserRecord[]>([]);
|
|
|
|
const refetchOrganisations = async (options?: { selectOrganisationId?: number }) => {
|
|
try {
|
|
await organisation.byUser({
|
|
userId: user.id,
|
|
onSuccess: (data) => {
|
|
const organisations = data as OrganisationResponse[];
|
|
organisations.sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name));
|
|
setOrganisations(organisations);
|
|
|
|
let selected: OrganisationResponse | null = null;
|
|
|
|
if (options?.selectOrganisationId) {
|
|
const created = organisations.find(
|
|
(o) => o.Organisation.id === options.selectOrganisationId,
|
|
);
|
|
if (created) {
|
|
selected = created;
|
|
}
|
|
} else {
|
|
const savedId = localStorage.getItem("selectedOrganisationId");
|
|
if (savedId) {
|
|
const saved = organisations.find((o) => o.Organisation.id === Number(savedId));
|
|
if (saved) {
|
|
selected = saved;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!selected) {
|
|
selected = organisations[0] || null;
|
|
}
|
|
|
|
setSelectedOrganisation(selected);
|
|
},
|
|
onError: (error) => {
|
|
console.error("error fetching organisations:", error);
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.error("error fetching organisations:", err);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (organisationsRef.current) return;
|
|
organisationsRef.current = true;
|
|
void refetchOrganisations();
|
|
}, [user.id]);
|
|
|
|
const refetchProjects = async (organisationId: number, options?: { selectProjectId?: number }) => {
|
|
try {
|
|
await project.byOrganisation({
|
|
organisationId,
|
|
onSuccess: (data) => {
|
|
const projects = data as ProjectResponse[];
|
|
projects.sort((a, b) => a.Project.name.localeCompare(b.Project.name));
|
|
setProjects(projects);
|
|
|
|
let selected: ProjectResponse | null = null;
|
|
|
|
if (options?.selectProjectId) {
|
|
const created = projects.find((p) => p.Project.id === options.selectProjectId);
|
|
if (created) {
|
|
selected = created;
|
|
}
|
|
} else {
|
|
const savedId = localStorage.getItem("selectedProjectId");
|
|
if (savedId) {
|
|
const saved = projects.find((p) => p.Project.id === Number(savedId));
|
|
if (saved) {
|
|
selected = saved;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!selected) {
|
|
selected = projects[0] || null;
|
|
}
|
|
|
|
setSelectedProject(selected);
|
|
},
|
|
onError: (error) => {
|
|
console.error("error fetching projects:", error);
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.error("error fetching projects:", err);
|
|
setProjects([]);
|
|
}
|
|
};
|
|
|
|
const refetchMembers = async (organisationId: number) => {
|
|
try {
|
|
await organisation.members({
|
|
organisationId,
|
|
onSuccess: (data: OrganisationMemberResponse[]) => {
|
|
setMembers(data.map((m) => m.User));
|
|
},
|
|
onError: (error) => {
|
|
console.error("error fetching members:", error);
|
|
setMembers([]);
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.error("error fetching members:", err);
|
|
setMembers([]);
|
|
}
|
|
};
|
|
|
|
// fetch projects when organisation is selected
|
|
useEffect(() => {
|
|
setProjects([]);
|
|
setSelectedProject(null);
|
|
setSelectedIssue(null);
|
|
setIssues([]);
|
|
setMembers([]);
|
|
if (!selectedOrganisation) {
|
|
return;
|
|
}
|
|
|
|
void refetchProjects(selectedOrganisation.Organisation.id);
|
|
void refetchMembers(selectedOrganisation.Organisation.id);
|
|
}, [selectedOrganisation]);
|
|
|
|
const refetchIssues = async () => {
|
|
try {
|
|
await issue.byProject({
|
|
projectId: selectedProject?.Project.id || 0,
|
|
onSuccess: (data) => {
|
|
const issues = data as IssueResponse[];
|
|
issues.reverse(); // newest at the bottom, but if the order has been rearranged, respect that
|
|
setIssues(issues);
|
|
},
|
|
onError: (error) => {
|
|
console.error("error fetching issues:", error);
|
|
setIssues([]);
|
|
},
|
|
});
|
|
} catch (err) {
|
|
console.error("error fetching issues:", err);
|
|
setIssues([]);
|
|
}
|
|
};
|
|
|
|
const handleIssueDelete = async (issueId: number) => {
|
|
setSelectedIssue(null);
|
|
setIssues((prev) => prev.filter((issue) => issue.Issue.id !== issueId));
|
|
await refetchIssues();
|
|
};
|
|
|
|
// fetch issues when project is selected
|
|
useEffect(() => {
|
|
if (!selectedProject) return;
|
|
|
|
void refetchIssues();
|
|
}, [selectedProject]);
|
|
|
|
return (
|
|
<main className={`w-full h-screen flex flex-col gap-${BREATHING_ROOM} p-${BREATHING_ROOM}`}>
|
|
{/* header area */}
|
|
<div className="flex gap-12 items-center justify-between">
|
|
<div className={`flex gap-${BREATHING_ROOM} items-center`}>
|
|
{/* organisation selection */}
|
|
<OrganisationSelect
|
|
organisations={organisations}
|
|
selectedOrganisation={selectedOrganisation}
|
|
onSelectedOrganisationChange={(org) => {
|
|
setSelectedOrganisation(org);
|
|
localStorage.setItem("selectedOrganisationId", `${org?.Organisation.id}`);
|
|
}}
|
|
onCreateOrganisation={async (organisationId) => {
|
|
await refetchOrganisations({ selectOrganisationId: organisationId });
|
|
}}
|
|
showLabel
|
|
/>
|
|
|
|
{/* project selection - only shown when organisation is selected */}
|
|
{selectedOrganisation && (
|
|
<ProjectSelect
|
|
projects={projects}
|
|
selectedProject={selectedProject}
|
|
organisationId={selectedOrganisation?.Organisation.id}
|
|
onSelectedProjectChange={(project) => {
|
|
setSelectedProject(project);
|
|
localStorage.setItem("selectedProjectId", `${project?.Project.id}`);
|
|
setSelectedIssue(null);
|
|
}}
|
|
onCreateProject={async (projectId) => {
|
|
if (!selectedOrganisation) return;
|
|
await refetchProjects(selectedOrganisation.Organisation.id, {
|
|
selectProjectId: projectId,
|
|
});
|
|
}}
|
|
showLabel
|
|
/>
|
|
)}
|
|
{selectedOrganisation && selectedProject && (
|
|
<CreateIssue
|
|
projectId={selectedProject?.Project.id}
|
|
members={members}
|
|
statuses={selectedOrganisation.Organisation.statuses}
|
|
completeAction={async () => {
|
|
if (!selectedProject) return;
|
|
await refetchIssues();
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
<div className={`flex gap-${BREATHING_ROOM} items-center`}>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger className="text-sm">
|
|
<SmallUserDisplay user={user} />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align={"end"}>
|
|
<DropdownMenuItem asChild className="flex items-end justify-end">
|
|
<AccountDialog />
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem asChild className="flex items-end justify-end">
|
|
<OrganisationsDialog
|
|
organisations={organisations}
|
|
selectedOrganisation={selectedOrganisation}
|
|
setSelectedOrganisation={setSelectedOrganisation}
|
|
refetchOrganisations={refetchOrganisations}
|
|
/>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem asChild className="flex items-end justify-end">
|
|
<ServerConfigurationDialog
|
|
trigger={
|
|
<Button
|
|
variant="ghost"
|
|
className="flex w-full items-center justify-end text-end px-2 py-1 m-0 h-auto"
|
|
title="Server Configuration"
|
|
>
|
|
Server Configuration
|
|
</Button>
|
|
}
|
|
/>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem className="flex items-end justify-end p-0 m-0">
|
|
<LogOutButton noStyle className={"flex w-full justify-end"} />
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</div>
|
|
|
|
{/* main body */}
|
|
{selectedOrganisation && selectedProject && issues.length > 0 && (
|
|
<ResizablePanelGroup className={`flex-1`}>
|
|
<ResizablePanel id={"left"} minSize={400}>
|
|
{/* issues list (table) */}
|
|
<IssuesTable
|
|
issuesData={issues}
|
|
columns={{ description: false }}
|
|
statuses={selectedOrganisation.Organisation.statuses}
|
|
issueSelectAction={(issue) => {
|
|
if (issue.Issue.id === selectedIssue?.Issue.id) setSelectedIssue(null);
|
|
else setSelectedIssue(issue);
|
|
}}
|
|
className="border w-full flex-shrink"
|
|
/>
|
|
</ResizablePanel>
|
|
|
|
{/* issue detail pane */}
|
|
{selectedIssue && selectedOrganisation && (
|
|
<>
|
|
<ResizableSeparator />
|
|
<ResizablePanel id={"right"} defaultSize={"30%"} minSize={360} maxSize={"60%"}>
|
|
<div className="border">
|
|
<IssueDetailPane
|
|
project={selectedProject}
|
|
issueData={selectedIssue}
|
|
members={members}
|
|
statuses={selectedOrganisation.Organisation.statuses}
|
|
close={() => setSelectedIssue(null)}
|
|
onIssueUpdate={refetchIssues}
|
|
onIssueDelete={handleIssueDelete}
|
|
/>
|
|
</div>
|
|
</ResizablePanel>
|
|
</>
|
|
)}
|
|
</ResizablePanelGroup>
|
|
)}
|
|
</main>
|
|
);
|
|
}
|