From 3319b85fe2f0a6d563baaead2888952c7dcf2b2b Mon Sep 17 00:00:00 2001 From: nazarli-shabnam Date: Mon, 16 Mar 2026 04:08:56 +0400 Subject: [PATCH 01/17] add stack view button and fix modules header --- ui/src/components/layout/PageHeader.tsx | 31 +++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/ui/src/components/layout/PageHeader.tsx b/ui/src/components/layout/PageHeader.tsx index c712eb26..8fa7673e 100644 --- a/ui/src/components/layout/PageHeader.tsx +++ b/ui/src/components/layout/PageHeader.tsx @@ -348,6 +348,23 @@ const IconLayoutGrid = () => ( ); +const IconStack = () => ( + + + + + +); const IconColumns = () => ( - ↑ Name + Name + From 4f1e4f1b36b0e8e38bc5e6a2f3610325ebe45048 Mon Sep 17 00:00:00 2001 From: nazarli-shabnam Date: Mon, 16 Mar 2026 04:14:24 +0400 Subject: [PATCH 02/17] fix header: add sort icon before 'Name' text --- ui/src/components/layout/PageHeader.tsx | 20 +- ui/src/pages/ModulesPage.tsx | 365 ++++++++---------------- 2 files changed, 145 insertions(+), 240 deletions(-) diff --git a/ui/src/components/layout/PageHeader.tsx b/ui/src/components/layout/PageHeader.tsx index 8fa7673e..c308e5dd 100644 --- a/ui/src/components/layout/PageHeader.tsx +++ b/ui/src/components/layout/PageHeader.tsx @@ -158,6 +158,24 @@ const IconCalendar = () => ( ); +const IconArrowUpDown = () => ( + + + + + + +); const IconFilter = () => ( - Name + Name
+ + + + + + {progress}% + +
+ ); +} export function ModulesPage() { const { workspaceSlug, projectId } = useParams<{ @@ -132,7 +123,6 @@ export function ModulesPage() { useEffect(() => { if (!workspaceSlug || !projectId) { - // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: reset loading when no slug/project (kept for future use) setLoading(false); return; } @@ -165,11 +155,8 @@ export function ModulesPage() { }; }, [workspaceSlug, projectId]); - const getIssueCount = (moduleId: string) => - modules.find((m) => m.id === moduleId)?.issue_count ?? 0; - const getTotalIssues = (moduleId: string) => getIssueCount(moduleId); - const getProgress = (moduleId: string) => { - const total = getTotalIssues(moduleId); + const getProgress = (mod: ModuleApiResponse) => { + const total = mod.issue_count ?? 0; const done = 0; return total === 0 ? 0 : Math.round((done / total) * 100); }; @@ -190,170 +177,70 @@ export function ModulesPage() { const baseUrl = `/${workspace.slug}/projects/${project.id}`; return ( -
- {/* Toolbar */} -
- - - - - - - - - - -
- - {/* Module list */} -
- {modules.length === 0 ? ( -

- No modules yet. -

- ) : ( - modules.map((mod) => { - const progress = getProgress(mod.id); - return ( - + {modules.length === 0 ? ( +

+ No modules yet. +

+ ) : ( + modules.map((mod) => { + const progress = getProgress(mod); + const dateRange = formatModuleDateRange(mod); + return ( + + +

+ {mod.name} +

+ {dateRange !== null && ( + + {dateRange} + + )} + + {mod.status} + + -
- - ); - }) - )} -
+ + + + + + ); + }) + )} ); } From 628f34441e46734a266d65b4607fed4d9c57efdf Mon Sep 17 00:00:00 2001 From: nazarli-shabnam Date: Mon, 16 Mar 2026 04:15:17 +0400 Subject: [PATCH 03/17] replace IconUser with save/bookmark icon in module row --- ui/src/pages/ModulesPage.tsx | 67 +++++++++++++++++++++++++----------- 1 file changed, 47 insertions(+), 20 deletions(-) diff --git a/ui/src/pages/ModulesPage.tsx b/ui/src/pages/ModulesPage.tsx index 21615bdf..4bac9a8d 100644 --- a/ui/src/pages/ModulesPage.tsx +++ b/ui/src/pages/ModulesPage.tsx @@ -9,27 +9,51 @@ import type { ModuleApiResponse, } from "../api/types"; -/** Format ISO date to "Mon DD, YYYY" */ -function formatDate(iso: string): string { - const d = new Date(iso); - return d.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: "numeric", - }); +/** Zero-pad a number to 2 digits. */ +function pad2(n: number): string { + return n < 10 ? `0${n}` : String(n); } -/** Format module date range from API (start_date, target_date). Returns null if neither set. */ +const MONTH_ABBR = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +/** + * Format module date range in compact form matching the Plane design: + * - Same month+year: "Mar 03 - 27, 2026" + * - Same year: "Mar 03 - Apr 12, 2026" + * - Different year: "Dec 20, 2025 - Jan 05, 2026" + * - Single date: "Mar 03, 2026" + * Returns null when neither date is set. + */ function formatModuleDateRange(mod: ModuleApiResponse): string | null { - const start = mod.start_date?.trim(); - const end = mod.target_date?.trim(); - if (start && end) return `${formatDate(start)} - ${formatDate(end)}`; - if (start) return `From ${formatDate(start)}`; - if (end) return `Until ${formatDate(end)}`; - return null; + const startRaw = mod.start_date?.trim(); + const endRaw = mod.target_date?.trim(); + if (!startRaw && !endRaw) return null; + + const parse = (iso: string) => { + const d = new Date(iso); + return { m: d.getMonth(), d: d.getDate(), y: d.getFullYear() }; + }; + + if (startRaw && endRaw) { + const s = parse(startRaw); + const e = parse(endRaw); + if (s.y === e.y && s.m === e.m) { + return `${MONTH_ABBR[s.m]} ${pad2(s.d)} - ${pad2(e.d)}, ${s.y}`; + } + if (s.y === e.y) { + return `${MONTH_ABBR[s.m]} ${pad2(s.d)} - ${MONTH_ABBR[e.m]} ${pad2(e.d)}, ${s.y}`; + } + return `${MONTH_ABBR[s.m]} ${pad2(s.d)}, ${s.y} - ${MONTH_ABBR[e.m]} ${pad2(e.d)}, ${e.y}`; + } + + const single = parse((startRaw ?? endRaw)!); + return `${MONTH_ABBR[single.m]} ${pad2(single.d)}, ${single.y}`; } -const IconUser = () => ( +const IconSave = () => ( ( fill="none" stroke="currentColor" strokeWidth="2" + strokeLinecap="round" + strokeLinejoin="round" aria-hidden > - - + + + ); const IconStar = () => ( @@ -207,13 +234,13 @@ export function ModulesPage() { + {projectDropdownOpen && ( +
+
+ + + + setProjectSearch(e.target.value)} + className="min-w-0 flex-1 bg-transparent text-sm text-[var(--txt-primary)] placeholder:text-[var(--txt-placeholder)] focus:outline-none" + /> +
+
+ {filteredProjects.map((p) => ( + + ))} +
+
+ )} > diff --git a/ui/src/pages/ModulesPage.tsx b/ui/src/pages/ModulesPage.tsx index 4bac9a8d..902a5138 100644 --- a/ui/src/pages/ModulesPage.tsx +++ b/ui/src/pages/ModulesPage.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from "react"; import { Link, useParams } from "react-router-dom"; +import { Avatar } from "../components/ui"; import { workspaceService } from "../services/workspaceService"; import { projectService } from "../services/projectService"; import { moduleService } from "../services/moduleService"; @@ -15,18 +16,20 @@ function pad2(n: number): string { } const MONTH_ABBR = [ - "Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", ]; -/** - * Format module date range in compact form matching the Plane design: - * - Same month+year: "Mar 03 - 27, 2026" - * - Same year: "Mar 03 - Apr 12, 2026" - * - Different year: "Dec 20, 2025 - Jan 05, 2026" - * - Single date: "Mar 03, 2026" - * Returns null when neither date is set. - */ function formatModuleDateRange(mod: ModuleApiResponse): string | null { const startRaw = mod.start_date?.trim(); const endRaw = mod.target_date?.trim(); @@ -53,23 +56,6 @@ function formatModuleDateRange(mod: ModuleApiResponse): string | null { return `${MONTH_ABBR[single.m]} ${pad2(single.d)}, ${single.y}`; } -const IconSave = () => ( - - - - - -); const IconStar = () => ( ( ); -/** Circular progress indicator (0–100). */ function ModuleProgressCircle({ progress }: { progress: number }) { const r = 18; const c = 2 * Math.PI * r; @@ -150,6 +135,7 @@ export function ModulesPage() { useEffect(() => { if (!workspaceSlug || !projectId) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional: reset loading when no slug/project (kept for future use) setLoading(false); return; } @@ -217,7 +203,7 @@ export function ModulesPage() {

@@ -231,17 +217,11 @@ export function ModulesPage() { {mod.status} - + - - - +

+ + + +
@@ -1098,9 +1128,6 @@ function ProjectSectionHeader({ )} - - > - (); + const [searchParams] = useSearchParams(); const [workspace, setWorkspace] = useState(null); const [project, setProject] = useState(null); const [modules, setModules] = useState([]); @@ -188,66 +189,145 @@ export function ModulesPage() { } const baseUrl = `/${workspace.slug}/projects/${project.id}`; + const layout = + (searchParams.get("layout") as "list" | "gallery" | "timeline") || "list"; - return ( + const renderListLayout = () => (
- {modules.length === 0 ? ( -

- No modules yet. -

- ) : ( - modules.map((mod) => { - const progress = getProgress(mod); - const dateRange = formatModuleDateRange(mod); - return ( - { + const progress = getProgress(mod); + const dateRange = formatModuleDateRange(mod); + return ( + + +

+ {mod.name} +

+ {dateRange !== null && ( + + {dateRange} + + )} + + {mod.status} + + + + + + ); + })} +
+ ); + + const renderGalleryLayout = () => ( +
+ {modules.map((mod) => { + const progress = getProgress(mod); + const dateRange = formatModuleDateRange(mod); + return ( + +
+

{mod.name}

+ +
+
{dateRange !== null && ( - + {dateRange} )} - + {mod.status} - - - - - ); - }) - )} +
+ + ); + })}
); + + const renderTimelineLayout = () => { + const withDates = modules + .map((m) => ({ + mod: m, + start: m.start_date ? new Date(m.start_date) : null, + end: m.target_date ? new Date(m.target_date) : null, + })) + .sort((a, b) => { + const aTime = a.start?.getTime() ?? a.end?.getTime() ?? 0; + const bTime = b.start?.getTime() ?? b.end?.getTime() ?? 0; + return aTime - bTime; + }); + + return ( +
+ {withDates.map(({ mod, start, end }) => { + const progress = getProgress(mod); + const dateRange = formatModuleDateRange(mod); + return ( +
+
+ + +
+

+ {mod.name} +

+

+ {dateRange ?? "No dates"} +

+
+ +
+ ); + })} +
+ ); + }; + + if (modules.length === 0) { + return ( +

+ No modules yet. +

+ ); + } + + if (layout === "gallery") return renderGalleryLayout(); + if (layout === "timeline") return renderTimelineLayout(); + return renderListLayout(); } diff --git a/ui/src/services/moduleService.ts b/ui/src/services/moduleService.ts index 49d54de8..2869246a 100644 --- a/ui/src/services/moduleService.ts +++ b/ui/src/services/moduleService.ts @@ -1,6 +1,14 @@ import { apiClient } from "../api/client"; import type { ModuleApiResponse } from "../api/types"; +export interface CreateModulePayload { + name: string; + description?: string; + status?: string; + start_date?: string | null; + target_date?: string | null; +} + export const moduleService = { async list( workspaceSlug: string, @@ -12,6 +20,18 @@ export const moduleService = { return data; }, + async create( + workspaceSlug: string, + projectId: string, + payload: CreateModulePayload, + ): Promise { + const { data } = await apiClient.post( + `/api/workspaces/${encodeURIComponent(workspaceSlug)}/projects/${encodeURIComponent(projectId)}/modules/`, + payload, + ); + return data; + }, + async addIssue( workspaceSlug: string, projectId: string, From 50cfafbf1f8c6788a3b5fe26de23aa9766dc77cc Mon Sep 17 00:00:00 2001 From: nazarli-shabnam Date: Tue, 17 Mar 2026 07:49:24 +0400 Subject: [PATCH 06/17] feat: implement search, add module button --- ui/src/components/CreateModuleModal.tsx | 456 ++++++++++++++++++++++++ ui/src/components/layout/PageHeader.tsx | 62 +++- ui/src/pages/ModulesPage.tsx | 28 +- 3 files changed, 533 insertions(+), 13 deletions(-) create mode 100644 ui/src/components/CreateModuleModal.tsx diff --git a/ui/src/components/CreateModuleModal.tsx b/ui/src/components/CreateModuleModal.tsx new file mode 100644 index 00000000..bd5ca730 --- /dev/null +++ b/ui/src/components/CreateModuleModal.tsx @@ -0,0 +1,456 @@ +import { useState, useEffect, useRef } from "react"; +import { Modal, Button, Input, Avatar } from "./ui"; +import { DateRangeModal } from "./workspace-views/DateRangeModal"; +import { getImageUrl } from "../lib/utils"; +import { moduleService } from "../services/moduleService"; +import { workspaceService } from "../services/workspaceService"; +import type { ModuleApiResponse } from "../api/types"; +import type { WorkspaceMemberApiResponse } from "../api/types"; + +const MODULE_STATUSES = [ + { id: "backlog", label: "Backlog" }, + { id: "planned", label: "Planned" }, + { id: "in_progress", label: "In Progress" }, + { id: "paused", label: "Paused" }, + { id: "completed", label: "Completed" }, + { id: "cancelled", label: "Cancelled" }, +] as const; + +function formatDateRangeDisplay(start: string | null, end: string | null): string { + if (!start && !end) return "Start date → End date"; + const fmt = (s: string) => + new Date(s).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); + if (start && end) return `${fmt(start)} → ${fmt(end)}`; + return start ? fmt(start) : end ? fmt(end) : "Start date → End date"; +} + +export interface CreateModuleModalProps { + open: boolean; + onClose: () => void; + workspaceSlug: string; + projectId: string; + projectName: string; + onCreated?: (module: ModuleApiResponse) => void; +} + +export function CreateModuleModal({ + open, + onClose, + workspaceSlug, + projectId, + projectName, + onCreated, +}: CreateModuleModalProps) { + const [title, setTitle] = useState(""); + const [description, setDescription] = useState(""); + const [startDate, setStartDate] = useState(null); + const [endDate, setEndDate] = useState(null); + const [status, setStatus] = useState("backlog"); + const [leadId, setLeadId] = useState(null); + const [memberIds, setMemberIds] = useState([]); + const [dateModalOpen, setDateModalOpen] = useState(false); + const [statusDropdownOpen, setStatusDropdownOpen] = useState(false); + const [leadDropdownOpen, setLeadDropdownOpen] = useState(false); + const [membersDropdownOpen, setMembersDropdownOpen] = useState(false); + const [leadSearch, setLeadSearch] = useState(""); + const [membersSearch, setMembersSearch] = useState(""); + const [members, setMembers] = useState([]); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const statusRef = useRef(null); + const leadRef = useRef(null); + const membersRef = useRef(null); + + useEffect(() => { + if (!open || !workspaceSlug) return; + workspaceService + .listMembers(workspaceSlug) + .then((list) => setMembers(list ?? [])) + .catch(() => setMembers([])); + }, [open, workspaceSlug]); + + useEffect(() => { + if (!open) { + setTitle(""); + setDescription(""); + setStartDate(null); + setEndDate(null); + setStatus("backlog"); + setLeadId(null); + setMemberIds([]); + setError(null); + setLeadSearch(""); + setMembersSearch(""); + setStatusDropdownOpen(false); + setLeadDropdownOpen(false); + setMembersDropdownOpen(false); + } + }, [open]); + + useEffect(() => { + const handler = (e: MouseEvent) => { + const target = e.target as Node; + if (statusRef.current?.contains(target)) return; + if (leadRef.current?.contains(target)) return; + if (membersRef.current?.contains(target)) return; + setStatusDropdownOpen(false); + setLeadDropdownOpen(false); + setMembersDropdownOpen(false); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + const q = (s: string) => s.trim().toLowerCase(); + const filteredLead = members.filter((m) => + q(m.member_display_name ?? m.member_email ?? m.member_id).includes(q(leadSearch)), + ); + const filteredMembers = members.filter((m) => + q(m.member_display_name ?? m.member_email ?? m.member_id).includes(q(membersSearch)), + ); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!title.trim()) { + setError("Title is required."); + return; + } + setError(null); + setSubmitting(true); + try { + const created = await moduleService.create(workspaceSlug, projectId, { + name: title.trim(), + description: description.trim() || undefined, + status: status || "backlog", + start_date: startDate || undefined, + target_date: endDate || undefined, + }); + onClose(); + onCreated?.(created); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create module."); + } finally { + setSubmitting(false); + } + }; + + const statusLabel = MODULE_STATUSES.find((s) => s.id === status)?.label ?? status; + const leadMember = leadId ? members.find((m) => m.member_id === leadId) : null; + const selectedMembers = memberIds + .map((id) => members.find((m) => m.member_id === id)) + .filter(Boolean) as WorkspaceMemberApiResponse[]; + + return ( + <> + + + + + } + > +
+ {projectName} +
+
+ setTitle(e.target.value)} + placeholder="Title" + autoFocus + /> +
+ +