diff --git a/frappe-ui-react b/frappe-ui-react index 285675696..5e655f9d7 160000 --- a/frappe-ui-react +++ b/frappe-ui-react @@ -1 +1 @@ -Subproject commit 2856756967c7bbba18bbb1c247b06d1797e45b95 +Subproject commit 5e655f9d7131d549d53aa5d4b2949fecaed460c9 diff --git a/frontend/packages/app/src/pages/project-details/tabs/risks/context.ts b/frontend/packages/app/src/pages/project-details/tabs/risks/context.ts index d7233cbf7..29f1ebe02 100644 --- a/frontend/packages/app/src/pages/project-details/tabs/risks/context.ts +++ b/frontend/packages/app/src/pages/project-details/tabs/risks/context.ts @@ -7,7 +7,13 @@ import { createContext, useContextSelector } from "use-context-selector"; * Internal dependencies. */ import { RISK_STATUSES, type RiskStatus } from "./constants"; -import type { RiskFilters, RiskItem, RiskVisibleColumns } from "./types"; +import type { + RiskFilters, + RiskItem, + RiskSort, + RiskVisibleColumns, + UserDetails, +} from "./types"; export interface RisksContextProps { state: { @@ -16,14 +22,17 @@ export interface RisksContextProps { error: unknown; filters: RiskFilters; visibleColumns: RiskVisibleColumns; + sort: RiskSort | null; isCreateRiskOpen: boolean; editRiskName: string | null; createRiskInitialStatus: RiskStatus | ""; deleteRiskName: string | null; + allOwnersWithDetails: Record; }; actions: { setFilters: (filters: Partial) => void; setVisibleColumns: (cols: Partial) => void; + setSort: (sort: RiskSort | null) => void; updateRiskStatus: (name: string, status: RiskStatus) => Promise; openCreateRisk: () => void; closeCreateRisk: () => void; @@ -54,14 +63,17 @@ export const RisksContext = createContext({ advanced: [], }, visibleColumns: defaultVisibleColumns, + sort: null, isCreateRiskOpen: false, editRiskName: null, createRiskInitialStatus: "", deleteRiskName: null, + allOwnersWithDetails: {}, }, actions: { setFilters: noop, setVisibleColumns: noop, + setSort: noop, updateRiskStatus: async () => {}, openCreateRisk: noop, closeCreateRisk: noop, diff --git a/frontend/packages/app/src/pages/project-details/tabs/risks/header.tsx b/frontend/packages/app/src/pages/project-details/tabs/risks/header.tsx index 11ee4880a..d8af5a12a 100644 --- a/frontend/packages/app/src/pages/project-details/tabs/risks/header.tsx +++ b/frontend/packages/app/src/pages/project-details/tabs/risks/header.tsx @@ -8,19 +8,24 @@ import { Plus } from "lucide-react"; * Internal dependencies. */ import { useRisks } from "./context"; +import { RisksToolbar } from "./toolbar/toolbar"; export function RisksHeader() { const openCreateRisk = useRisks((c) => c.actions.openCreateRisk); return ( -
-

Risks

-
+ <> +
+

Risks

+
+ + + ); } diff --git a/frontend/packages/app/src/pages/project-details/tabs/risks/index.tsx b/frontend/packages/app/src/pages/project-details/tabs/risks/index.tsx index 7f891328c..04991c4d1 100644 --- a/frontend/packages/app/src/pages/project-details/tabs/risks/index.tsx +++ b/frontend/packages/app/src/pages/project-details/tabs/risks/index.tsx @@ -2,6 +2,8 @@ * External dependencies. */ import { useSearchParams } from "react-router-dom"; +import { mergeClassNames as cn } from "@next-pms/design-system"; +import { Spinner } from "@next-pms/design-system/components"; /** * Internal dependencies. @@ -31,6 +33,7 @@ function RisksContent() { const deleteRiskName = useRisks((c) => c.state.deleteRiskName); const closeCreateRisk = useRisks((c) => c.actions.closeCreateRisk); const closeDeleteRisk = useRisks((c) => c.actions.closeDeleteRisk); + const isLoading = useRisks((c) => c.state.isLoading); return ( <> @@ -46,9 +49,21 @@ function RisksContent() { {riskId ? ( ) : ( -
+
- {activeView === "kanban" ? : } +
+ {activeView === "kanban" ? : } +
+ {isLoading && ( + + )}
)} diff --git a/frontend/packages/app/src/pages/project-details/tabs/risks/kanban/kanbanView.tsx b/frontend/packages/app/src/pages/project-details/tabs/risks/kanban/kanbanView.tsx index 18bfb8a01..89fb4d02b 100644 --- a/frontend/packages/app/src/pages/project-details/tabs/risks/kanban/kanbanView.tsx +++ b/frontend/packages/app/src/pages/project-details/tabs/risks/kanban/kanbanView.tsx @@ -34,33 +34,27 @@ export function RisksKanbanView() { (c) => c.actions.openCreateRiskWithStatus, ); const toast = useToasts(); + const [localData, setLocalData] = useState(data); + + useEffect(() => { + if (!data || data.length === 0) return; + setLocalData(data); + }, [data]); const [items, setItems] = useState(emptyGroups); const byId = useMemo(() => { const map = new Map(); - for (const risk of data) { + for (const risk of localData) { map.set(risk.name, risk); } return map; - }, [data]); + }, [localData]); useEffect(() => { - setItems((current) => { - const fromServer = groupIdsByStatus(data); - const merged = {} as RiskIdsByStatus; - for (const status of RISK_STATUSES) { - const serverSet = new Set(fromServer[status]); - // Preserve existing drag-and-drop order; drop items that moved away - const kept = current[status].filter((id) => serverSet.has(id)); - const keptSet = new Set(kept); - // Append any items newly added on the server side - const added = fromServer[status].filter((id) => !keptSet.has(id)); - merged[status as RiskStatus] = [...kept, ...added]; - } - return merged; - }); - }, [data]); + if (!localData) return; + setItems(groupIdsByStatus(localData)); + }, [localData]); return ( @@ -84,7 +78,7 @@ export function RisksKanbanView() { try { await updateRiskStatus(riskId, newStatus); } catch { - setItems(groupIdsByStatus(data)); + setItems(groupIdsByStatus(localData)); const risk = byId.get(riskId); toast.error( `Error updating status for ${risk?.risk_category ?? riskId}`, diff --git a/frontend/packages/app/src/pages/project-details/tabs/risks/list/listView.tsx b/frontend/packages/app/src/pages/project-details/tabs/risks/list/listView.tsx index 177b79307..31bbc1f57 100644 --- a/frontend/packages/app/src/pages/project-details/tabs/risks/list/listView.tsx +++ b/frontend/packages/app/src/pages/project-details/tabs/risks/list/listView.tsx @@ -1,6 +1,7 @@ /** * External dependencies. */ +import { useEffect, useState } from "react"; import { Accordion } from "@base-ui/react/accordion"; /** @@ -9,12 +10,21 @@ import { Accordion } from "@base-ui/react/accordion"; import { RISK_LIST_COLUMNS } from "../constants"; import { useRisks } from "../context"; import { RiskGroup } from "./listViewGroup"; +import { RiskItem } from "../types"; export function RisksListView() { const data = useRisks((c) => c.state.data); + const [localData, setLocalData] = useState(data); - const openRisks = data.filter((r) => !r.status || r.status !== "Mitigated"); - const mitigatedRisks = data.filter((r) => r.status === "Mitigated"); + useEffect(() => { + if (!data || data.length === 0) return; + setLocalData(data); + }, [data]); + + const openRisks = localData.filter( + (r) => !r.status || r.status !== "Mitigated", + ); + const mitigatedRisks = localData.filter((r) => r.status === "Mitigated"); return (
diff --git a/frontend/packages/app/src/pages/project-details/tabs/risks/provider.tsx b/frontend/packages/app/src/pages/project-details/tabs/risks/provider.tsx index 0f47b7ae8..3a4396448 100644 --- a/frontend/packages/app/src/pages/project-details/tabs/risks/provider.tsx +++ b/frontend/packages/app/src/pages/project-details/tabs/risks/provider.tsx @@ -10,7 +10,7 @@ import { useFrappeUpdateDoc } from "frappe-react-sdk"; */ import { RISK_DETAIL_PARAM, RISK_STATUSES, type RiskStatus } from "./constants"; import { RisksContext, type RisksContextProps } from "./context"; -import type { RiskFilters, RiskVisibleColumns } from "./types"; +import type { RiskFilters, RiskSort, RiskVisibleColumns } from "./types"; import { useRisksData } from "./useRisksData"; const defaultFilters: RiskFilters = { @@ -35,9 +35,16 @@ export function RisksProvider({ children }: PropsWithChildren) { RiskStatus | "" >(""); const [deleteRiskName, setDeleteRiskName] = useState(null); + const [sort, setSortState] = useState(null); const [, setSearchParams] = useSearchParams(); - const { data, isLoading, error, mutate: refreshRiskList } = useRisksData(); + const { + data, + isLoading, + error, + mutate: refreshRiskList, + allOwnersWithDetails, + } = useRisksData(filters, sort); const { updateDoc } = useFrappeUpdateDoc(); @@ -52,6 +59,11 @@ export function RisksProvider({ children }: PropsWithChildren) { [], ); + const setSort = useCallback((s: RiskSort | null) => { + setSortState(s); + void refreshRiskList(); + }, []); + const updateRiskStatus = useCallback( async (name: string, status: RiskStatus) => { await updateDoc("Risk", name, { status }); @@ -114,14 +126,17 @@ export function RisksProvider({ children }: PropsWithChildren) { error, filters, visibleColumns, + sort, isCreateRiskOpen, editRiskName, createRiskInitialStatus, deleteRiskName, + allOwnersWithDetails, }, actions: { setFilters, setVisibleColumns, + setSort, updateRiskStatus, openCreateRisk, closeCreateRisk, @@ -139,12 +154,15 @@ export function RisksProvider({ children }: PropsWithChildren) { error, filters, visibleColumns, + sort, isCreateRiskOpen, editRiskName, createRiskInitialStatus, deleteRiskName, + allOwnersWithDetails, setFilters, setVisibleColumns, + setSort, updateRiskStatus, openCreateRisk, closeCreateRisk, diff --git a/frontend/packages/app/src/pages/project-details/tabs/risks/toolbar/columnsDropdown.tsx b/frontend/packages/app/src/pages/project-details/tabs/risks/toolbar/columnsDropdown.tsx new file mode 100644 index 000000000..f002b751a --- /dev/null +++ b/frontend/packages/app/src/pages/project-details/tabs/risks/toolbar/columnsDropdown.tsx @@ -0,0 +1,60 @@ +/** + * External dependencies. + */ +import { useMemo } from "react"; +import { MultiSelect } from "@rtcamp/frappe-ui-react"; +import type { MultiSelectOption } from "@rtcamp/frappe-ui-react"; + +/** + * Internal dependencies. + */ +import { RISK_STATUSES } from "../constants"; +import type { RiskStatus } from "../constants"; +import { RiskStatusBadge } from "../riskStatusBadge"; +import type { RiskVisibleColumns } from "../types"; + +interface ColumnsDropdownProps { + visibleColumns: RiskVisibleColumns; + setVisibleColumns: (partial: Partial) => void; +} + +const COLUMN_OPTIONS: MultiSelectOption[] = RISK_STATUSES.map((status) => ({ + value: status, + label: status, +})); + +export function ColumnsDropdown({ + visibleColumns, + setVisibleColumns, +}: ColumnsDropdownProps) { + const selectedValues = useMemo( + () => + RISK_STATUSES.filter((status) => visibleColumns[status]).map( + (status) => status, + ), + [visibleColumns], + ); + + const handleChange = (newValues: string[]) => { + const partial = Object.fromEntries( + RISK_STATUSES.map((status) => [status, newValues.includes(status)]), + ) as unknown as RiskVisibleColumns; + setVisibleColumns(partial); + }; + + return ( +
+ ( + + )} + popupClassName="w-full" + /> +
+ ); +} diff --git a/frontend/packages/app/src/pages/project-details/tabs/risks/toolbar/sortButton.tsx b/frontend/packages/app/src/pages/project-details/tabs/risks/toolbar/sortButton.tsx new file mode 100644 index 000000000..9ba017d36 --- /dev/null +++ b/frontend/packages/app/src/pages/project-details/tabs/risks/toolbar/sortButton.tsx @@ -0,0 +1,101 @@ +/** + * External dependencies. + */ +import { useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { Popover } from "@base-ui/react"; +import { ArrowDown, ArrowUp } from "@rtcamp/frappe-ui-react/icons"; +import { ArrowUpDown } from "lucide-react"; + +/** + * Internal dependencies. + */ +import { RISK_VIEW_PARAM } from "../constants"; +import { useRisks } from "../context"; + +const SORT_FIELDS: { field: string; label: string }[] = [ + { field: "modified", label: "Last updated on" }, + { field: "status", label: "Status" }, + { field: "risk_category", label: "Risk category" }, + { field: "risk_level", label: "Risk level" }, +]; + +export function SortButton() { + const [open, setOpen] = useState(false); + const [searchParams] = useSearchParams(); + const isKanban = searchParams.get(RISK_VIEW_PARAM) === "kanban"; + const sort = useRisks((c) => c.state.sort); + const setSort = useRisks((c) => c.actions.setSort); + + const sortFields = isKanban + ? SORT_FIELDS.filter((f) => f.field !== "status") + : SORT_FIELDS; + + const handleFieldClick = (field: string) => { + if (sort?.field === field) { + setSort({ field, order: sort.order === "asc" ? "desc" : "asc" }); + } else { + setSort({ field, order: "desc" }); + } + }; + + const isActive = sort !== null; + + return ( + + + + Sort + {isActive && ( + + {sortFields.find((f) => f.field === sort.field)?.label} + + )} + + + + + {sortFields.map(({ field, label }) => { + const isSelected = sort?.field === field; + return ( + + ); + })} + {isActive && ( + <> +
+ + + )} + + + + + ); +} diff --git a/frontend/packages/app/src/pages/project-details/tabs/risks/toolbar/toolbar.tsx b/frontend/packages/app/src/pages/project-details/tabs/risks/toolbar/toolbar.tsx new file mode 100644 index 000000000..29ef7e969 --- /dev/null +++ b/frontend/packages/app/src/pages/project-details/tabs/risks/toolbar/toolbar.tsx @@ -0,0 +1,101 @@ +/** + * External dependencies. + */ +import { useMemo } from "react"; +import { useSearchParams } from "react-router-dom"; +import { Filter, Select } from "@rtcamp/frappe-ui-react"; + +/** + * Internal dependencies. + */ +import { RISK_STATUSES, RISK_VIEW_PARAM } from "../constants"; +import type { RiskStatus } from "../constants"; +import { useRisks } from "../context"; +import { ColumnsDropdown } from "./columnsDropdown"; +import { SortButton } from "./sortButton"; + +const RISK_LEVEL_OPTIONS = [ + { label: "All", value: "" }, + { label: "Low", value: "Low" }, + { label: "Medium", value: "Medium" }, + { label: "High", value: "High" }, +]; + +const STATUS_OPTIONS = [ + { label: "All", value: "" }, + ...RISK_STATUSES.map((s) => ({ label: s, value: s })), +]; + +export function RisksToolbar() { + const [searchParams] = useSearchParams(); + const isKanban = searchParams.get(RISK_VIEW_PARAM) === "kanban"; + + const filters = useRisks((c) => c.state.filters); + const allOwnersWithDetails = useRisks((c) => c.state.allOwnersWithDetails); + const visibleColumns = useRisks((c) => c.state.visibleColumns); + const setFilters = useRisks((c) => c.actions.setFilters); + const setVisibleColumns = useRisks((c) => c.actions.setVisibleColumns); + + const ownerOptions = useMemo(() => { + return [ + { label: "All owners", value: "" }, + ...Object.entries(allOwnersWithDetails).map(([email, details]) => ({ + label: details?.full_name ?? email, + value: email, + })), + ]; + }, [allOwnersWithDetails]); + + return ( +
+ {/* Left: quick filters */} +
+ setFilters({ status: (v ?? "") as RiskStatus | "" })} + options={STATUS_OPTIONS} + /> +