diff --git a/api/internal/handler/module.go b/api/internal/handler/module.go index e8d3ab6c..ff8c1e2c 100644 --- a/api/internal/handler/module.go +++ b/api/internal/handler/module.go @@ -140,14 +140,26 @@ func (h *ModuleHandler) Update(c *gin.Context) { return } var body struct { - Name string `json:"name"` - Description string `json:"description"` - Status string `json:"status"` - StartDate string `json:"start_date"` - TargetDate string `json:"target_date"` + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` + StartDate string `json:"start_date"` + TargetDate string `json:"target_date"` + LeadID *string `json:"lead_id"` } _ = c.ShouldBindJSON(&body) - mod, err := h.Module.Update(c.Request.Context(), slug, projectID, moduleID, user.ID, body.Name, body.Description, body.Status, parseOptionalDate(body.StartDate), parseOptionalDate(body.TargetDate)) + var leadIDPtr *uuid.UUID + if body.LeadID != nil { + if *body.LeadID != "" { + id, parseErr := uuid.Parse(*body.LeadID) + if parseErr != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid lead_id", "detail": "must be a valid UUID"}) + return + } + leadIDPtr = &id + } + } + mod, err := h.Module.Update(c.Request.Context(), slug, projectID, moduleID, user.ID, body.Name, body.Description, body.Status, parseOptionalDate(body.StartDate), parseOptionalDate(body.TargetDate), body.LeadID != nil, leadIDPtr) if err != nil { if err == service.ErrModuleNotFound || err == service.ErrProjectForbidden || err == service.ErrProjectNotFound { c.JSON(http.StatusNotFound, gin.H{"error": "Not found"}) diff --git a/api/internal/service/module.go b/api/internal/service/module.go index 1e21916f..9b7cbb12 100644 --- a/api/internal/service/module.go +++ b/api/internal/service/module.go @@ -100,7 +100,7 @@ func (s *ModuleService) Get(ctx context.Context, workspaceSlug string, projectID return mod, nil } -func (s *ModuleService) Update(ctx context.Context, workspaceSlug string, projectID, moduleID uuid.UUID, userID uuid.UUID, name, description, status string, startDate, targetDate *time.Time) (*model.Module, error) { +func (s *ModuleService) Update(ctx context.Context, workspaceSlug string, projectID, moduleID uuid.UUID, userID uuid.UUID, name, description, status string, startDate, targetDate *time.Time, leadIDSet bool, leadID *uuid.UUID) (*model.Module, error) { mod, err := s.Get(ctx, workspaceSlug, projectID, moduleID, userID) if err != nil { return nil, err @@ -120,6 +120,9 @@ func (s *ModuleService) Update(ctx context.Context, workspaceSlug string, projec if targetDate != nil { mod.TargetDate = targetDate } + if leadIDSet { + mod.LeadID = leadID + } if err := s.ms.Update(ctx, mod); err != nil { return nil, err } diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 19a209f3..6246e284 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 00000000..47627038 --- /dev/null +++ b/ui/src/components/AddExistingWorkItemModal.tsx @@ -0,0 +1,274 @@ +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; + let cancelled = false; + setLoading(true); + setError(null); + Promise.all([ + issueService.list(workspaceSlug, projectId, { limit: 2000 }), + moduleService.listIssueIds(workspaceSlug, projectId, moduleId), + ]) + .then(([issues, ids]) => { + if (cancelled) return; + setProjectIssues(issues ?? []); + setModuleIssueIds(new Set(ids ?? [])); + setSelectedIds(new Set()); + }) + .catch(() => { + if (!cancelled) setError("Failed to load work items"); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [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 { + const ids = Array.from(selectedIds); + const BATCH = 5; + for (let i = 0; i < ids.length; i += BATCH) { + await Promise.all( + ids + .slice(i, i + BATCH) + .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 84e25d3b..3fdae6a3 100644 --- a/ui/src/components/CoverImageModal.tsx +++ b/ui/src/components/CoverImageModal.tsx @@ -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 00000000..68b6d8d6 --- /dev/null +++ b/ui/src/components/CreateModuleModal.tsx @@ -0,0 +1,534 @@ +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"; +import { formatISODateDisplay } from "../lib/dateOnly"; +import { MODULE_STATUSES } from "../lib/moduleStatuses"; + +function formatDateRangeDisplay( + start: string | null, + end: string | null, +): string { + if (!start && !end) return "Start date → End date"; + if (start && end) + return `${formatISODateDisplay(start)} → ${formatISODateDisplay(end)}`; + return start + ? formatISODateDisplay(start) + : end + ? formatISODateDisplay(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 + /> +
+ +