diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 19a209f..6246e28 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -375,6 +375,7 @@ export interface ModuleApiResponse { status: string; project_id: string; workspace_id: string; + lead_id?: string | null; sort_order?: number; issue_count?: number; created_at: string; diff --git a/ui/src/components/AddExistingWorkItemModal.tsx b/ui/src/components/AddExistingWorkItemModal.tsx new file mode 100644 index 0000000..1ed54af --- /dev/null +++ b/ui/src/components/AddExistingWorkItemModal.tsx @@ -0,0 +1,254 @@ +import { useEffect, useState, useMemo } from "react"; +import { createPortal } from "react-dom"; +import { Button } from "./ui"; +import { issueService } from "../services/issueService"; +import { moduleService } from "../services/moduleService"; +import type { IssueApiResponse } from "../api/types"; + +const IconSearch = () => ( + + + + +); + +function displayId(issue: IssueApiResponse, projectIdentifier: string): string { + return `${projectIdentifier}-${issue.sequence_id ?? issue.id.slice(-4)}`; +} + +export interface AddExistingWorkItemModalProps { + open: boolean; + onClose: () => void; + workspaceSlug: string; + projectId: string; + moduleId: string; + projectIdentifier: string; + onAdded?: () => void; +} + +export function AddExistingWorkItemModal({ + open, + onClose, + workspaceSlug, + projectId, + moduleId, + projectIdentifier, + onAdded, +}: AddExistingWorkItemModalProps) { + const [searchQuery, setSearchQuery] = useState(""); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [projectIssues, setProjectIssues] = useState([]); + const [moduleIssueIds, setModuleIssueIds] = useState>(new Set()); + const [loading, setLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open || !workspaceSlug || !projectId || !moduleId) return; + setLoading(true); + setError(null); + Promise.all([ + issueService.list(workspaceSlug, projectId, { limit: 2000 }), + moduleService.listIssueIds(workspaceSlug, projectId, moduleId), + ]) + .then(([issues, ids]) => { + setProjectIssues(issues ?? []); + setModuleIssueIds(new Set(ids ?? [])); + setSelectedIds(new Set()); + }) + .catch(() => setError("Failed to load work items")) + .finally(() => setLoading(false)); + }, [open, workspaceSlug, projectId, moduleId]); + + useEffect(() => { + if (!open) { + setSearchQuery(""); + setSelectedIds(new Set()); + } + }, [open]); + + const availableIssues = useMemo(() => { + return projectIssues.filter((i) => !moduleIssueIds.has(i.id)); + }, [projectIssues, moduleIssueIds]); + + const filteredIssues = useMemo(() => { + if (!searchQuery.trim()) return availableIssues; + const q = searchQuery.trim().toLowerCase(); + return availableIssues.filter((issue) => { + const id = displayId(issue, projectIdentifier); + const name = (issue.name ?? "").toLowerCase(); + return id.toLowerCase().includes(q) || name.includes(q); + }); + }, [availableIssues, searchQuery, projectIdentifier]); + + const selectAll = () => { + if (filteredIssues.length === 0) return; + setSelectedIds((prev) => { + const next = new Set(prev); + filteredIssues.forEach((i) => next.add(i.id)); + return next; + }); + }; + + const toggleOne = (id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const handleAdd = async () => { + if (selectedIds.size === 0) return; + setSubmitting(true); + setError(null); + try { + await Promise.all( + Array.from(selectedIds).map((issueId) => + moduleService.addIssue(workspaceSlug, projectId, moduleId, issueId), + ), + ); + onAdded?.(); + onClose(); + } catch { + setError("Failed to add work items"); + } finally { + setSubmitting(false); + } + }; + + const selectedCount = selectedIds.size; + const selectionLabel = + selectedCount === 0 + ? "No work items selected" + : `${selectedCount} work item${selectedCount === 1 ? "" : "s"} selected`; + + const footer = ( +
+ +
+ + +
+
+ ); + + if (!open) return null; + + return createPortal( +
+
+
e.stopPropagation()} + > +
+
+ + + + setSearchQuery(e.target.value)} + className="h-9 w-full rounded-md border border-(--border-subtle) bg-(--bg-surface-1) py-2 pl-9 pr-3 text-sm text-(--txt-primary) placeholder:text-(--txt-placeholder) focus:outline-none" + aria-label="Search work items" + /> +
+ + {selectionLabel} + +
+ +
+ {error && ( +

{error}

+ )} + {loading ? ( +

+ Loading work items… +

+ ) : filteredIssues.length === 0 ? ( +

+ {availableIssues.length === 0 + ? "No other work items in this project." + : "No matching work items."} +

+ ) : ( +
    + {filteredIssues.map((issue) => { + const id = displayId(issue, projectIdentifier); + const checked = selectedIds.has(issue.id); + return ( +
  • + +
  • + ); + })} +
+ )} +
+ +
+ {footer} +
+
+
, + document.body, + ); +} diff --git a/ui/src/components/CoverImageModal.tsx b/ui/src/components/CoverImageModal.tsx index 84e25d3..8505ad7 100644 --- a/ui/src/components/CoverImageModal.tsx +++ b/ui/src/components/CoverImageModal.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { Button, Modal } from "./ui"; import { instanceSettingsService, @@ -164,14 +164,14 @@ export function CoverImageModal({ footer={footer} className="max-w-2xl" > -
+
{unsplashError && ( -

+

{unsplashError}

)} @@ -219,10 +219,10 @@ export function CoverImageModal({ type="button" key={r.id} onClick={() => setSelectedUrl(r.url)} - className={`relative aspect-video rounded-[var(--radius-md)] overflow-hidden border-2 transition-colors ${ + className={`relative aspect-video rounded-(--radius-md) overflow-hidden border-2 transition-colors ${ selectedUrl === r.url - ? "border-[var(--brand-default)] ring-2 ring-[var(--brand-200)]" - : "border-transparent hover:border-[var(--border-strong)]" + ? "border-(--brand-default) ring-2 ring-(--brand-200)" + : "border-transparent hover:border-(--border-strong)" }`} > -

+

Drag & drop image here

) : ( -
+
Preview Edit
)} -

+

File formats supported: .jpeg, .jpg, .png, .webp

{uploadError && ( -

- {uploadError} -

+

{uploadError}

)}
)} diff --git a/ui/src/components/CreateModuleModal.tsx b/ui/src/components/CreateModuleModal.tsx new file mode 100644 index 0000000..0445589 --- /dev/null +++ b/ui/src/components/CreateModuleModal.tsx @@ -0,0 +1,542 @@ +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 + /> +
+ +