Compare commits

...

1 Commits

Author SHA1 Message Date
Jiang Bohan
c0b5391737 feat(projects): add priority attribute to projects
Add priority field (urgent/high/medium/low/none) to projects, matching
the existing issue priority system. Includes database migration, API
support for create/update/list filtering, and UI for the create dialog,
project list table, and project detail page.
2026-04-09 16:29:49 +08:00
11 changed files with 132 additions and 18 deletions

View File

@@ -1,4 +1,4 @@
import type { ProjectStatus } from "../types";
import type { ProjectStatus, ProjectPriority } from "../types";
export const PROJECT_STATUS_ORDER: ProjectStatus[] = [
"planned",
@@ -18,3 +18,22 @@ export const PROJECT_STATUS_CONFIG: Record<
completed: { label: "Completed", color: "text-info", badgeBg: "bg-info", badgeText: "text-white" },
cancelled: { label: "Cancelled", color: "text-muted-foreground", badgeBg: "bg-muted", badgeText: "text-muted-foreground" },
};
export const PROJECT_PRIORITY_ORDER: ProjectPriority[] = [
"urgent",
"high",
"medium",
"low",
"none",
];
export const PROJECT_PRIORITY_CONFIG: Record<
ProjectPriority,
{ label: string; bars: number; color: string; badgeBg: string; badgeText: string }
> = {
urgent: { label: "Urgent", bars: 4, color: "text-destructive", badgeBg: "bg-priority", badgeText: "text-white" },
high: { label: "High", bars: 3, color: "text-warning", badgeBg: "bg-priority/80", badgeText: "text-white" },
medium: { label: "Medium", bars: 2, color: "text-warning", badgeBg: "bg-priority/15", badgeText: "text-priority" },
low: { label: "Low", bars: 1, color: "text-info", badgeBg: "bg-priority/10", badgeText: "text-priority" },
none: { label: "No priority", bars: 0, color: "text-muted-foreground", badgeBg: "bg-muted", badgeText: "text-muted-foreground" },
};

View File

@@ -30,4 +30,4 @@ export type * from "./events";
export type * from "./api";
export type { Attachment } from "./attachment";
export type { StorageAdapter } from "./storage";
export type { Project, ProjectStatus, CreateProjectRequest, UpdateProjectRequest, ListProjectsResponse } from "./project";
export type { Project, ProjectStatus, ProjectPriority, CreateProjectRequest, UpdateProjectRequest, ListProjectsResponse } from "./project";

View File

@@ -1,5 +1,7 @@
export type ProjectStatus = "planned" | "in_progress" | "paused" | "completed" | "cancelled";
export type ProjectPriority = "urgent" | "high" | "medium" | "low" | "none";
export interface Project {
id: string;
workspace_id: string;
@@ -7,6 +9,7 @@ export interface Project {
description: string | null;
icon: string | null;
status: ProjectStatus;
priority: ProjectPriority;
lead_type: "member" | "agent" | null;
lead_id: string | null;
created_at: string;
@@ -18,6 +21,7 @@ export interface CreateProjectRequest {
description?: string;
icon?: string;
status?: ProjectStatus;
priority?: ProjectPriority;
lead_type?: "member" | "agent";
lead_id?: string;
}
@@ -27,6 +31,7 @@ export interface UpdateProjectRequest {
description?: string | null;
icon?: string | null;
status?: ProjectStatus;
priority?: ProjectPriority;
lead_type?: "member" | "agent" | null;
lead_id?: string | null;
}

View File

@@ -5,7 +5,7 @@ import { Check, ChevronRight, Link2, ListTodo, MoreHorizontal, Trash2, UserMinus
import { useQuery } from "@tanstack/react-query";
import { cn } from "@multica/ui/lib/utils";
import { toast } from "sonner";
import type { Issue, IssueStatus, ProjectStatus } from "@multica/core/types";
import type { Issue, IssueStatus, ProjectStatus, ProjectPriority } from "@multica/core/types";
import { projectDetailOptions } from "@multica/core/projects/queries";
import { useUpdateProject, useDeleteProject } from "@multica/core/projects/mutations";
import { issueListOptions } from "@multica/core/issues/queries";
@@ -14,7 +14,7 @@ import { memberListOptions, agentListOptions } from "@multica/core/workspace/que
import { useWorkspaceId } from "@multica/core/hooks";
import { useWorkspaceStore } from "@multica/core/workspace";
import { useActorName } from "@multica/core/workspace/hooks";
import { PROJECT_STATUS_ORDER, PROJECT_STATUS_CONFIG } from "@multica/core/projects/config";
import { PROJECT_STATUS_ORDER, PROJECT_STATUS_CONFIG, PROJECT_PRIORITY_ORDER, PROJECT_PRIORITY_CONFIG } from "@multica/core/projects/config";
import { BOARD_STATUSES } from "@multica/core/issues/config";
import { createIssueViewStore, useIssueViewStore as useGlobalIssueViewStore } from "@multica/core/issues/stores/view-store";
import { ViewStoreProvider, useViewStore } from "@multica/core/issues/stores/view-store-context";
@@ -23,6 +23,7 @@ import { filterIssues } from "../../issues/utils/filter";
import { ActorAvatar } from "../../common/actor-avatar";
import { AppLink, useNavigation } from "../../navigation";
import { TitleEditor, ContentEditor, type ContentEditorRef } from "../../editor";
import { PriorityIcon } from "../../issues/components/priority-icon";
import { IssuesHeader } from "../../issues/components/issues-header";
import { BoardView } from "../../issues/components/board-view";
import { ListView } from "../../issues/components/list-view";
@@ -218,6 +219,7 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
}
const statusCfg = PROJECT_STATUS_CONFIG[project.status];
const priorityCfg = PROJECT_PRIORITY_CONFIG[project.priority];
return (
<div className="flex h-full flex-col">
@@ -363,6 +365,27 @@ export function ProjectDetail({ projectId }: { projectId: string }) {
</DropdownMenuContent>
</DropdownMenu>
{/* Priority */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<PropertyPill>
<PriorityIcon priority={project.priority} />
<span>{priorityCfg.label}</span>
</PropertyPill>
}
/>
<DropdownMenuContent align="start" className="w-44">
{PROJECT_PRIORITY_ORDER.map((p) => (
<DropdownMenuItem key={p} onClick={() => handleUpdateField({ priority: p as ProjectPriority })}>
<PriorityIcon priority={p} />
<span>{PROJECT_PRIORITY_CONFIG[p].label}</span>
{p === project.priority && <Check className="ml-auto h-3.5 w-3.5" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Lead */}
<Popover open={leadOpen} onOpenChange={(v) => { setLeadOpen(v); if (!v) setLeadFilter(""); }}>
<PopoverTrigger

View File

@@ -5,7 +5,7 @@ import { Plus, FolderKanban, ChevronRight, Maximize2, Minimize2, X as XIcon, Use
import { useQuery } from "@tanstack/react-query";
import { projectListOptions } from "@multica/core/projects/queries";
import { useCreateProject } from "@multica/core/projects/mutations";
import { PROJECT_STATUS_CONFIG, PROJECT_STATUS_ORDER } from "@multica/core/projects/config";
import { PROJECT_STATUS_CONFIG, PROJECT_STATUS_ORDER, PROJECT_PRIORITY_CONFIG, PROJECT_PRIORITY_ORDER } from "@multica/core/projects/config";
import { useWorkspaceId } from "@multica/core/hooks";
import { useWorkspaceStore } from "@multica/core/workspace";
import { memberListOptions, agentListOptions } from "@multica/core/workspace/queries";
@@ -36,7 +36,8 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/
import { ContentEditor, type ContentEditorRef } from "../../editor";
import { TitleEditor } from "../../editor";
import { EmojiPicker } from "@multica/ui/components/common/emoji-picker";
import type { Project, ProjectStatus } from "@multica/core/types";
import type { Project, ProjectStatus, ProjectPriority } from "@multica/core/types";
import { PriorityIcon } from "../../issues/components/priority-icon";
function formatRelativeDate(date: string): string {
const diff = Date.now() - new Date(date).getTime();
@@ -50,6 +51,7 @@ function formatRelativeDate(date: string): string {
function ProjectRow({ project }: { project: Project }) {
const statusCfg = PROJECT_STATUS_CONFIG[project.status];
const priorityCfg = PROJECT_PRIORITY_CONFIG[project.priority];
return (
<AppLink
href={`/projects/${project.id}`}
@@ -59,6 +61,12 @@ function ProjectRow({ project }: { project: Project }) {
<span className="shrink-0 w-[24px] text-center text-base">{project.icon || "📁"}</span>
<span className="min-w-0 flex-1 truncate font-medium">{project.title}</span>
{/* Priority */}
<span className="flex w-24 items-center justify-center gap-1 shrink-0">
<PriorityIcon priority={project.priority} />
<span className={cn("text-xs", priorityCfg.color)}>{priorityCfg.label}</span>
</span>
{/* Status */}
<span className={cn(
"inline-flex items-center gap-1 rounded px-2 py-0.5 text-xs font-medium shrink-0 w-28 justify-center",
@@ -115,6 +123,7 @@ function CreateProjectDialog({ open, onOpenChange }: { open: boolean; onOpenChan
const [title, setTitle] = useState("");
const descEditorRef = useRef<ContentEditorRef>(null);
const [status, setStatus] = useState<ProjectStatus>("planned");
const [priority, setPriority] = useState<ProjectPriority>("none");
const [leadType, setLeadType] = useState<"member" | "agent" | undefined>();
const [leadId, setLeadId] = useState<string | undefined>();
const [icon, setIcon] = useState<string | undefined>();
@@ -144,6 +153,7 @@ function CreateProjectDialog({ open, onOpenChange }: { open: boolean; onOpenChan
description: descEditorRef.current?.getMarkdown()?.trim() || undefined,
icon,
status,
priority,
lead_type: leadType,
lead_id: leadId,
});
@@ -151,6 +161,7 @@ function CreateProjectDialog({ open, onOpenChange }: { open: boolean; onOpenChan
setTitle("");
setIcon(undefined);
setStatus("planned");
setPriority("none");
setLeadType(undefined);
setLeadId(undefined);
toast.success("Project created");
@@ -279,6 +290,26 @@ function CreateProjectDialog({ open, onOpenChange }: { open: boolean; onOpenChan
</DropdownMenuContent>
</DropdownMenu>
{/* Priority */}
<DropdownMenu>
<DropdownMenuTrigger
render={
<PillButton>
<PriorityIcon priority={priority} />
<span>{PROJECT_PRIORITY_CONFIG[priority].label}</span>
</PillButton>
}
/>
<DropdownMenuContent align="start" className="w-44">
{PROJECT_PRIORITY_ORDER.map((p) => (
<DropdownMenuItem key={p} onClick={() => setPriority(p)}>
<PriorityIcon priority={p} />
<span>{PROJECT_PRIORITY_CONFIG[p].label}</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Lead */}
<Popover open={leadOpen} onOpenChange={(v) => { setLeadOpen(v); if (!v) setLeadFilter(""); }}>
<PopoverTrigger
@@ -410,6 +441,7 @@ export function ProjectsPage() {
{/* Icon spacer + Name */}
<span className="shrink-0 w-[24px]" />
<span className="min-w-0 flex-1">Name</span>
<span className="w-24 text-center shrink-0">Priority</span>
<span className="w-28 text-center shrink-0">Status</span>
<span className="w-10 text-center shrink-0">Lead</span>
<span className="w-20 text-right shrink-0">Created</span>

View File

@@ -18,6 +18,7 @@ type ProjectResponse struct {
Description *string `json:"description"`
Icon *string `json:"icon"`
Status string `json:"status"`
Priority string `json:"priority"`
LeadType *string `json:"lead_type"`
LeadID *string `json:"lead_id"`
CreatedAt string `json:"created_at"`
@@ -32,6 +33,7 @@ func projectToResponse(p db.Project) ProjectResponse {
Description: textToPtr(p.Description),
Icon: textToPtr(p.Icon),
Status: p.Status,
Priority: p.Priority,
LeadType: textToPtr(p.LeadType),
LeadID: uuidToPtr(p.LeadID),
CreatedAt: timestampToString(p.CreatedAt),
@@ -44,6 +46,7 @@ type CreateProjectRequest struct {
Description *string `json:"description"`
Icon *string `json:"icon"`
Status string `json:"status"`
Priority string `json:"priority"`
LeadType *string `json:"lead_type"`
LeadID *string `json:"lead_id"`
}
@@ -53,6 +56,7 @@ type UpdateProjectRequest struct {
Description *string `json:"description"`
Icon *string `json:"icon"`
Status *string `json:"status"`
Priority *string `json:"priority"`
LeadType *string `json:"lead_type"`
LeadID *string `json:"lead_id"`
}
@@ -63,9 +67,14 @@ func (h *Handler) ListProjects(w http.ResponseWriter, r *http.Request) {
if s := r.URL.Query().Get("status"); s != "" {
statusFilter = pgtype.Text{String: s, Valid: true}
}
var priorityFilter pgtype.Text
if p := r.URL.Query().Get("priority"); p != "" {
priorityFilter = pgtype.Text{String: p, Valid: true}
}
projects, err := h.Queries.ListProjects(r.Context(), db.ListProjectsParams{
WorkspaceID: parseUUID(workspaceID),
Status: statusFilter,
Priority: priorityFilter,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to list projects")
@@ -110,6 +119,10 @@ func (h *Handler) CreateProject(w http.ResponseWriter, r *http.Request) {
if status == "" {
status = "planned"
}
priority := req.Priority
if priority == "" {
priority = "none"
}
var leadType pgtype.Text
var leadID pgtype.UUID
if req.LeadType != nil {
@@ -126,6 +139,7 @@ func (h *Handler) CreateProject(w http.ResponseWriter, r *http.Request) {
Status: status,
LeadType: leadType,
LeadID: leadID,
Priority: priority,
})
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to create project")
@@ -176,6 +190,9 @@ func (h *Handler) UpdateProject(w http.ResponseWriter, r *http.Request) {
if req.Status != nil {
params.Status = pgtype.Text{String: *req.Status, Valid: true}
}
if req.Priority != nil {
params.Priority = pgtype.Text{String: *req.Priority, Valid: true}
}
if _, ok := rawFields["description"]; ok {
if req.Description != nil {
params.Description = pgtype.Text{String: *req.Description, Valid: true}

View File

@@ -0,0 +1 @@
ALTER TABLE project DROP COLUMN priority;

View File

@@ -0,0 +1,2 @@
ALTER TABLE project ADD COLUMN priority TEXT NOT NULL DEFAULT 'none'
CHECK (priority IN ('urgent', 'high', 'medium', 'low', 'none'));

View File

@@ -245,6 +245,7 @@ type Project struct {
LeadID pgtype.UUID `json:"lead_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Priority string `json:"priority"`
}
type RuntimeUsage struct {

View File

@@ -26,10 +26,10 @@ func (q *Queries) CountIssuesByProject(ctx context.Context, projectID pgtype.UUI
const createProject = `-- name: CreateProject :one
INSERT INTO project (
workspace_id, title, description, icon, status,
lead_type, lead_id
lead_type, lead_id, priority
) VALUES (
$1, $2, $3, $4, $5, $6, $7
) RETURNING id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at
$1, $2, $3, $4, $5, $6, $7, $8
) RETURNING id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at, priority
`
type CreateProjectParams struct {
@@ -40,6 +40,7 @@ type CreateProjectParams struct {
Status string `json:"status"`
LeadType pgtype.Text `json:"lead_type"`
LeadID pgtype.UUID `json:"lead_id"`
Priority string `json:"priority"`
}
func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) {
@@ -51,6 +52,7 @@ func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (P
arg.Status,
arg.LeadType,
arg.LeadID,
arg.Priority,
)
var i Project
err := row.Scan(
@@ -64,6 +66,7 @@ func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (P
&i.LeadID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Priority,
)
return i, err
}
@@ -78,7 +81,7 @@ func (q *Queries) DeleteProject(ctx context.Context, id pgtype.UUID) error {
}
const getProject = `-- name: GetProject :one
SELECT id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at FROM project
SELECT id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at, priority FROM project
WHERE id = $1
`
@@ -96,12 +99,13 @@ func (q *Queries) GetProject(ctx context.Context, id pgtype.UUID) (Project, erro
&i.LeadID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Priority,
)
return i, err
}
const getProjectInWorkspace = `-- name: GetProjectInWorkspace :one
SELECT id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at FROM project
SELECT id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at, priority FROM project
WHERE id = $1 AND workspace_id = $2
`
@@ -124,24 +128,27 @@ func (q *Queries) GetProjectInWorkspace(ctx context.Context, arg GetProjectInWor
&i.LeadID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Priority,
)
return i, err
}
const listProjects = `-- name: ListProjects :many
SELECT id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at FROM project
SELECT id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at, priority FROM project
WHERE workspace_id = $1
AND ($2::text IS NULL OR status = $2)
AND ($3::text IS NULL OR priority = $3)
ORDER BY created_at DESC
`
type ListProjectsParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Status pgtype.Text `json:"status"`
Priority pgtype.Text `json:"priority"`
}
func (q *Queries) ListProjects(ctx context.Context, arg ListProjectsParams) ([]Project, error) {
rows, err := q.db.Query(ctx, listProjects, arg.WorkspaceID, arg.Status)
rows, err := q.db.Query(ctx, listProjects, arg.WorkspaceID, arg.Status, arg.Priority)
if err != nil {
return nil, err
}
@@ -160,6 +167,7 @@ func (q *Queries) ListProjects(ctx context.Context, arg ListProjectsParams) ([]P
&i.LeadID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Priority,
); err != nil {
return nil, err
}
@@ -177,11 +185,12 @@ UPDATE project SET
description = $3,
icon = $4,
status = COALESCE($5, status),
lead_type = $6,
lead_id = $7,
priority = COALESCE($6, priority),
lead_type = $7,
lead_id = $8,
updated_at = now()
WHERE id = $1
RETURNING id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at
RETURNING id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at, priority
`
type UpdateProjectParams struct {
@@ -190,6 +199,7 @@ type UpdateProjectParams struct {
Description pgtype.Text `json:"description"`
Icon pgtype.Text `json:"icon"`
Status pgtype.Text `json:"status"`
Priority pgtype.Text `json:"priority"`
LeadType pgtype.Text `json:"lead_type"`
LeadID pgtype.UUID `json:"lead_id"`
}
@@ -201,6 +211,7 @@ func (q *Queries) UpdateProject(ctx context.Context, arg UpdateProjectParams) (P
arg.Description,
arg.Icon,
arg.Status,
arg.Priority,
arg.LeadType,
arg.LeadID,
)
@@ -216,6 +227,7 @@ func (q *Queries) UpdateProject(ctx context.Context, arg UpdateProjectParams) (P
&i.LeadID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Priority,
)
return i, err
}

View File

@@ -2,6 +2,7 @@
SELECT * FROM project
WHERE workspace_id = $1
AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status'))
AND (sqlc.narg('priority')::text IS NULL OR priority = sqlc.narg('priority'))
ORDER BY created_at DESC;
-- name: GetProject :one
@@ -15,9 +16,9 @@ WHERE id = $1 AND workspace_id = $2;
-- name: CreateProject :one
INSERT INTO project (
workspace_id, title, description, icon, status,
lead_type, lead_id
lead_type, lead_id, priority
) VALUES (
$1, $2, $3, $4, $5, $6, $7
$1, $2, $3, $4, $5, $6, $7, $8
) RETURNING *;
-- name: UpdateProject :one
@@ -26,6 +27,7 @@ UPDATE project SET
description = sqlc.narg('description'),
icon = sqlc.narg('icon'),
status = COALESCE(sqlc.narg('status'), status),
priority = COALESCE(sqlc.narg('priority'), priority),
lead_type = sqlc.narg('lead_type'),
lead_id = sqlc.narg('lead_id'),
updated_at = now()