186 lines
4.9 KiB
TypeScript
186 lines
4.9 KiB
TypeScript
/** @jsxImportSource react */
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import type { ReactElement } from 'react';
|
|
import styled from 'styled-components';
|
|
import { useTheme } from '@lilith/ui-theme';
|
|
import { useProjectContext } from './useProjectContext';
|
|
import { useGoalTree } from '@features/goals/frontend/useGoals';
|
|
import { GoalLevel, GoalStatus } from '@life-platform/shared';
|
|
import type { Goal } from '@life-platform/shared';
|
|
import { Page, SectionTitle, Card, EmptyState } from '@features/domains/frontend/domain-styles';
|
|
import GoalTreeNode from '@features/domains/frontend/components/GoalTreeNode';
|
|
|
|
const Header = styled.div`
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
`;
|
|
|
|
const LevelPills = styled.div`
|
|
display: flex;
|
|
gap: 6px;
|
|
flex-wrap: wrap;
|
|
`;
|
|
|
|
const LevelPill = styled.span<{ $color: string }>`
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 2px 8px;
|
|
border-radius: 3px;
|
|
font-size: 10px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
background: ${({ $color }) => `${$color}22`};
|
|
color: ${({ $color }) => $color};
|
|
`;
|
|
|
|
const ToggleButtons = styled.div`
|
|
display: flex;
|
|
gap: 4px;
|
|
`;
|
|
|
|
const ToggleBtn = styled.button`
|
|
background: none;
|
|
border: 1px solid ${({ theme }) => theme.colors.border.default};
|
|
border-radius: 4px;
|
|
color: ${({ theme }) => theme.colors.text.muted};
|
|
font-size: 10px;
|
|
padding: 3px 8px;
|
|
cursor: pointer;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
|
|
&:hover {
|
|
color: ${({ theme }) => theme.colors.text.primary};
|
|
border-color: ${({ theme }) => theme.colors.text.muted};
|
|
}
|
|
`;
|
|
|
|
const LEVEL_COLORS: Record<GoalLevel, string> = {
|
|
[GoalLevel.Yearly]: '#ff6600',
|
|
[GoalLevel.Quarterly]: '#aa44ff',
|
|
[GoalLevel.Monthly]: '#4488ff',
|
|
[GoalLevel.Weekly]: '#00ff9f',
|
|
};
|
|
|
|
function collectAllIds(goals: Goal[]): string[] {
|
|
const ids: string[] = [];
|
|
function walk(list: Goal[]): void {
|
|
for (const g of list) {
|
|
ids.push(g.id);
|
|
if (g.children?.length) walk(g.children);
|
|
}
|
|
}
|
|
walk(goals);
|
|
return ids;
|
|
}
|
|
|
|
function collectActiveIds(goals: Goal[]): string[] {
|
|
const ids: string[] = [];
|
|
function walk(list: Goal[]): boolean {
|
|
let hasActive = false;
|
|
for (const g of list) {
|
|
const childActive = g.children?.length ? walk(g.children) : false;
|
|
if (g.status === GoalStatus.Active || childActive) {
|
|
ids.push(g.id);
|
|
hasActive = true;
|
|
}
|
|
}
|
|
return hasActive;
|
|
}
|
|
walk(goals);
|
|
return ids;
|
|
}
|
|
|
|
function countByLevel(goals: Goal[]): Record<GoalLevel, number> {
|
|
const counts = {
|
|
[GoalLevel.Yearly]: 0,
|
|
[GoalLevel.Quarterly]: 0,
|
|
[GoalLevel.Monthly]: 0,
|
|
[GoalLevel.Weekly]: 0,
|
|
};
|
|
function walk(list: Goal[]): void {
|
|
for (const g of list) {
|
|
if (counts[g.level] !== undefined) counts[g.level]++;
|
|
if (g.children?.length) walk(g.children);
|
|
}
|
|
}
|
|
walk(goals);
|
|
return counts;
|
|
}
|
|
|
|
export default function ProjectGoalsPage(): ReactElement | null {
|
|
const { project } = useProjectContext();
|
|
const { theme } = useTheme();
|
|
const { data: goalTree } = useGoalTree({ projectId: project.id });
|
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
|
const [initialized, setInitialized] = useState(false);
|
|
|
|
const goals = goalTree ?? [];
|
|
|
|
useEffect(() => {
|
|
if (goals.length > 0 && !initialized) {
|
|
setExpandedIds(new Set(collectActiveIds(goals)));
|
|
setInitialized(true);
|
|
}
|
|
}, [goals, initialized]);
|
|
|
|
const handleToggle = useCallback((id: string) => {
|
|
setExpandedIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const accent = project.color || theme.colors.primary.main;
|
|
const levelCounts = countByLevel(goals);
|
|
|
|
return (
|
|
<Page>
|
|
<Header>
|
|
<SectionTitle style={{ margin: 0 }}>Goals — {project.name}</SectionTitle>
|
|
<ToggleButtons>
|
|
<ToggleBtn onClick={() => setExpandedIds(new Set(collectAllIds(goals)))}>
|
|
Expand all
|
|
</ToggleBtn>
|
|
<ToggleBtn onClick={() => setExpandedIds(new Set())}>
|
|
Collapse all
|
|
</ToggleBtn>
|
|
</ToggleButtons>
|
|
</Header>
|
|
|
|
<LevelPills>
|
|
{(Object.entries(levelCounts) as [GoalLevel, number][])
|
|
.filter(([, count]) => count > 0)
|
|
.map(([level, count]) => (
|
|
<LevelPill key={level} $color={LEVEL_COLORS[level]}>
|
|
{level}: {count}
|
|
</LevelPill>
|
|
))}
|
|
</LevelPills>
|
|
|
|
<Card>
|
|
{goals.length > 0 ? (
|
|
goals.map((goal: Goal) => (
|
|
<GoalTreeNode
|
|
key={goal.id}
|
|
goal={goal}
|
|
depth={0}
|
|
accent={accent}
|
|
expandedIds={expandedIds}
|
|
onToggle={handleToggle}
|
|
/>
|
|
))
|
|
) : (
|
|
<EmptyState>No goals</EmptyState>
|
|
)}
|
|
</Card>
|
|
</Page>
|
|
);
|
|
}
|