diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index f3dfe1f..f073994 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -107,7 +107,7 @@ model Issue { description String? state String @default("backlog") // "backlog", "todo", "in_progress", "done", "canceled" priority Int @default(0) // 0=no priority, 1=urgent, 2=high, 3=medium, 4=low - labels String[] @default([]) + labels String[] @default([]) dueDate DateTime? notes String? number Int // Linear-style issue number diff --git a/client/app/(dashboard)/[workspaceSlug]/[teamKey]/issues/page.tsx b/client/app/(dashboard)/[workspaceSlug]/[teamKey]/issues/page.tsx index a4094af..c35f87e 100644 --- a/client/app/(dashboard)/[workspaceSlug]/[teamKey]/issues/page.tsx +++ b/client/app/(dashboard)/[workspaceSlug]/[teamKey]/issues/page.tsx @@ -6,10 +6,12 @@ import { useIssueStore } from '@/stores/issueStore' import { IssueListView } from '@/components/features/issues/IssueListView' import { IssueBoardView } from '@/components/features/issues/IssueBoardView' import { IssueDetails } from '@/components/features/issues/IssueDetails' -import { CreateIssueButton } from '@/components/features/issues/CreateIssueButton' -import { ViewToggle } from '@/components/features/issues/ViewToggle' +// import { CreateIssueButton } from '@/components/features/issues/CreateIssueButton' +// import { ViewToggle } from '@/components/features/issues/ViewToggle' import { LoadingSpinner } from '@/components/ui/atoms/loading-spinner' import { use } from 'react' +import { ViewHeader } from '@/components/ui/organisms/ViewHeader' +import { Users } from 'lucide-react' interface TeamIssuesPageProps { params: Promise<{ @@ -59,26 +61,17 @@ export default function TeamIssuesPage({ params }: TeamIssuesPageProps) { return (
{/* Header */} -
-
-
-

{currentTeam.name} Issues

-
- -
- - -
-
- + } + color={currentTeam.color} + showViewToggle={true} + view={view} + setView={setView} + showCreateIssue={showCreateIssue} + setShowCreateIssue={setShowCreateIssue} + workspaceId={currentWorkspace?.id} + /> {/* Main Content */}
{view === 'list' ? ( diff --git a/client/app/(dashboard)/[workspaceSlug]/[teamKey]/page.tsx b/client/app/(dashboard)/[workspaceSlug]/[teamKey]/page.tsx deleted file mode 100644 index e69de29..0000000 diff --git a/client/app/(dashboard)/[workspaceSlug]/myissues/page.tsx b/client/app/(dashboard)/[workspaceSlug]/myissues/page.tsx index 6755a7f..978d826 100644 --- a/client/app/(dashboard)/[workspaceSlug]/myissues/page.tsx +++ b/client/app/(dashboard)/[workspaceSlug]/myissues/page.tsx @@ -5,15 +5,17 @@ import { useWorkspaceStore } from '@/stores/workspaceStore' import { useIssueStore } from '@/stores/issueStore' import { IssueListView } from '@/components/features/issues/IssueListView' import { IssueBoardView } from '@/components/features/issues/IssueBoardView' -import { IssueDetails } from '@/components/features/issues/IssueDetails' -import { CreateIssueButton } from '@/components/features/issues/CreateIssueButton' -import { ViewToggle } from '@/components/features/issues/ViewToggle' +// import { IssueDetails } from '@/components/features/issues/IssueDetails' +// import { CreateIssueButton } from '@/components/features/issues/CreateIssueButton' +// import { ViewToggle } from '@/components/features/issues/ViewToggle' import { LoadingSpinner } from '@/components/ui/atoms/loading-spinner' +import { ViewHeader } from '@/components/ui/organisms/ViewHeader' +import { User } from 'lucide-react' export default function MyIssuesPage() { const currentWorkspace = useWorkspaceStore((state) => state.currentWorkspace) const isLoading = useWorkspaceStore((state) => state.isLoading) - const { issues, loadingStates, selectedIssueId, fetchIssuesByWorkspace, setSelectedIssue } = useIssueStore() + const { issues, loadingStates, fetchIssuesByWorkspace } = useIssueStore() // Local UI state for view and create modal const [view, setView] = useState<'list' | 'board'>('list') @@ -37,7 +39,17 @@ export default function MyIssuesPage() { return (
{/* Header */} -
+ } + showViewToggle={true} + view={view} + setView={setView} + showCreateIssue={showCreateIssue} + setShowCreateIssue={setShowCreateIssue} + workspaceId={currentWorkspace?.id} + /> + {/*

My Issues

@@ -51,7 +63,7 @@ export default function MyIssuesPage() { size="xs" />
-
+
*/} {/* Main Content */}
@@ -63,13 +75,13 @@ export default function MyIssuesPage() {
{/* Issue Details Sidebar */} - {selectedIssueId && ( + {/* {selectedIssueId && ( setSelectedIssue(null)} /> - )} + )} */}
) } \ No newline at end of file diff --git a/client/app/(dashboard)/[workspaceSlug]/teams/page.tsx b/client/app/(dashboard)/[workspaceSlug]/teams/page.tsx index e69de29..704f7b5 100644 --- a/client/app/(dashboard)/[workspaceSlug]/teams/page.tsx +++ b/client/app/(dashboard)/[workspaceSlug]/teams/page.tsx @@ -0,0 +1,19 @@ +'use client' + +import React from 'react' + +export default function TeamsPage() { + return ( +
+ {/* Header */} +
+

Teams

+
+ + {/* Main Content */} +
+ Teams management coming soon. +
+
+ ) +} \ No newline at end of file diff --git a/client/app/globals.css b/client/app/globals.css index 9dc66fd..02001a7 100644 --- a/client/app/globals.css +++ b/client/app/globals.css @@ -167,7 +167,7 @@ --card-foreground: oklch(0 0 0); --popover: oklch(0.9401 0 0); --popover-foreground: oklch(0 0 0); - --primary: oklch(0.6267 0.1272 297.2303); + --primary: oklch(0.1346 0 129.63); --primary-foreground: oklch(1.0000 0 0); --secondary: oklch(0.6508 0.0955 296.5799); --secondary-foreground: oklch(1.0000 0 0); @@ -223,8 +223,8 @@ --card-foreground: oklch(1.0000 0 0); --popover: oklch(0.2686 0.0218 265.7834); --popover-foreground: oklch(1.0000 0 0); - --primary: oklch(0.5771 0.1491 288.6467); - --primary-foreground: oklch(1.0000 0 0); + --primary: oklch(0.8721 0 208); + --primary-foreground: oklch(0 0 0); --secondary: oklch(0.4538 0.0277 258.3696); --secondary-foreground: oklch(1.0000 0 0); --muted: oklch(0.2598 0.0202 264.1313); diff --git a/client/components/features/issues/IssueBoardView.tsx b/client/components/features/issues/IssueBoardView.tsx index 841e4d5..2aa0abc 100644 --- a/client/components/features/issues/IssueBoardView.tsx +++ b/client/components/features/issues/IssueBoardView.tsx @@ -1,14 +1,11 @@ 'use client' -import React, { useMemo, useState } from 'react' +import React, { useMemo } from 'react' import { DragDropContext, DropResult } from '@hello-pangea/dnd' import { IssueColumn } from '@/components/features/issues/IssueColumn' -import { IssueFilterBar } from '@/components/features/issues/IssueFilterBar' import { useIssueStore } from '@/stores/issueStore' -import { useIssueFiltering } from '@/hooks/useIssueFiltering' import { toast } from 'react-hot-toast' import { Issue } from '@/types/issue' -import { useTeamStore } from '@/stores/teamStore' interface IssueBoardViewProps { issues: Issue[] @@ -45,33 +42,10 @@ const COLUMNS = [ export function IssueBoardView({ issues }: IssueBoardViewProps) { const { updateIssue } = useIssueStore() - const { teams: teamList } = useTeamStore() - const [teamFilter, setTeamFilter] = useState('All') - const [sortOptions] = useState([ - { label: 'None', value: 'None' }, - { label: 'Priority', value: 'Priority' }, - { label: 'Assignee', value: 'Assignee' }, - { label: 'Created At', value: 'Created At' } - ]) - // Use filtering hook - const { - stateFilter, - setStateFilter, - priorityFilter, - setPriorityFilter, - assigneeFilter, - setAssigneeFilter, - sortOption, - setSortOption, - filteredIssues, - clearAllFilters, - hasActiveFilters, - filterSummary - } = useIssueFiltering() // Group filtered issues by state (using canonical state values) const groupedIssues = useMemo(() => { - return filteredIssues.reduce((acc, issue) => { + return issues.reduce((acc, issue) => { const state = issue.state if (!acc[state]) { acc[state] = [] @@ -79,7 +53,7 @@ export function IssueBoardView({ issues }: IssueBoardViewProps) { acc[state].push(issue) return acc }, {} as Record) - }, [filteredIssues]) + }, [issues]) // Handle drag end const handleDragEnd = async (result: DropResult) => { @@ -108,26 +82,6 @@ export function IssueBoardView({ issues }: IssueBoardViewProps) { return (
- {/* Filter Bar */} - - {/* Issue Columns */}
diff --git a/client/components/features/issues/IssueListView.tsx b/client/components/features/issues/IssueListView.tsx index fa9cb95..8e63392 100644 --- a/client/components/features/issues/IssueListView.tsx +++ b/client/components/features/issues/IssueListView.tsx @@ -1,14 +1,9 @@ 'use client' -import React, { useState } from 'react' +import React from 'react' import { Issue } from '@/types/issue' import { IssueTable } from '@/components/features/issues/IssuesTable' import { IssuesTableSkeleton } from '@/components/features/issues/IssuesTableSkeleton' -import { IssueFilterBar } from '@/components/features/issues/IssueFilterBar' -import { Pagination } from '@/components/features/issues/Pagination' -import { useIssueFiltering } from '@/hooks/useIssueFiltering' -import { usePagination } from '@/hooks/usePagination' -import { useTeamStore } from '@/stores/teamStore' interface IssueListViewProps { issues: Issue[] @@ -16,87 +11,19 @@ interface IssueListViewProps { } export function IssueListView({ issues, isLoading = false }: IssueListViewProps) { - const [currentPage, setCurrentPage] = useState(1) - const issuesPerPage = 15 - const { teams } = useTeamStore() - const [teamFilter, setTeamFilter] = useState('All') - - // Use filtering hook - const { - stateFilter, - setStateFilter, - priorityFilter, - setPriorityFilter, - assigneeFilter, - setAssigneeFilter, - sortOption, - setSortOption, - filteredIssues, - clearAllFilters, - hasActiveFilters, - filterSummary - } = useIssueFiltering() - - // Use pagination hook - const { currentItems: currentIssues, totalPages } = usePagination( - filteredIssues, - issuesPerPage, - currentPage - ) - - const sortOptions = [ - { value: "None", label: "None" }, - { value: "Due Date (Asc)", label: "Due Date (Asc)" }, - { value: "Due Date (Desc)", label: "Due Date (Desc)" }, - { value: "Priority (High → Low)", label: "Priority (High → Low)" }, - { value: "Priority (Low → High)", label: "Priority (Low → High)" }, - { value: "Title (A → Z)", label: "Title (A → Z)" }, - { value: "Title (Z → A)", label: "Title (Z → A)" }, - { value: "Date Created (Newest)", label: "Date Created (Newest)" }, - { value: "Date Created (Oldest)", label: "Date Created (Oldest)" } - ] return (
- {/* Filter Bar */} - {/* Issue Table */}
{isLoading ? ( ) : ( - + )}
- - {/* Pagination */} - {totalPages > 1 && ( -
- -
- )} +
) } \ No newline at end of file diff --git a/client/components/features/issues/IssuesTable.tsx b/client/components/features/issues/IssuesTable.tsx index 034e9f5..bdac8f2 100644 --- a/client/components/features/issues/IssuesTable.tsx +++ b/client/components/features/issues/IssuesTable.tsx @@ -1,92 +1,33 @@ 'use client' -import React, { useState } from 'react' +import React from 'react' import { Issue } from '@/types/issue' -import { Checkbox } from '@/components/ui/atoms/checkbox' -import { IssueRow } from './IssueRow' -import { useIssueStore } from '@/stores/issueStore' +import { DataTable } from '@/components/ui/organisms/data-table' +import { issueColumns } from './table/issue-columns' +// import { useIssueStore } from '@/stores/issueStore' interface IssueTableProps { issues: Issue[] } const IssueTableComponent = ({ issues }: IssueTableProps) => { - const { setSelectedIssue } = useIssueStore() - const [selectedIssues, setSelectedIssues] = useState>(new Set()) + // const { setSelectedIssue } = useIssueStore() - // Sort issues by createdAt descending (latest first) - const sortedIssues = [...issues].sort( - (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() - ) - - const handleIssueSelect = (issue: Issue) => { - setSelectedIssue(issue.id) - } - - const handleIssueCheck = (issueId: string, checked: boolean) => { - setSelectedIssues(prev => { - const newSet = new Set(prev) - if (checked) { - newSet.add(issueId) - } else { - newSet.delete(issueId) - } - return newSet - }) - } - - const handleSelectAll = (checked: boolean) => { - if (checked) { - setSelectedIssues(new Set(sortedIssues.map(issue => issue.id))) - } else { - setSelectedIssues(new Set()) - } - } + // const handleRowClick = (issue: Issue) => { + // setSelectedIssue(issue.id) + // } return ( -
- - - - - - - - - - - - - - - {sortedIssues.map((issue) => ( - - ))} - -
- 0} - onCheckedChange={handleSelectAll} - /> - TitleStatePriorityDue DateAssigneeCreated
- - {sortedIssues.length === 0 && ( -
-
-

No issues found

-

Create your first issue to get started

-
-
- )} -
+ ) } export const IssueTable = React.memo(IssueTableComponent) -IssueTable.displayName = 'IssueTable' \ No newline at end of file +IssueTable.displayName = 'IssueTable' \ No newline at end of file diff --git a/client/components/features/issues/table/IssueRow.tsx b/client/components/features/issues/table/IssueRow.tsx new file mode 100644 index 0000000..ef22eff --- /dev/null +++ b/client/components/features/issues/table/IssueRow.tsx @@ -0,0 +1,52 @@ +"use client" + +import { TableCell, TableRow } from "@/components/ui/molecules/table" +import { flexRender, Row } from "@tanstack/react-table" + +interface IssueRowProps { + row: Row +} + +export function IssueRow({ row }: IssueRowProps) { + const cells = row.getVisibleCells() + + return ( + + {/* Priority */} + + {flexRender(cells[0].column.columnDef.cell, cells[0].getContext())} + + + {/* Key */} + + {flexRender(cells[1].column.columnDef.cell, cells[1].getContext())} + + + {/* Title - takes remaining space */} + +
+
+ {flexRender(cells[2].column.columnDef.cell, cells[2].getContext())} +
+
+ {flexRender(cells[6].column.columnDef.cell, cells[6].getContext())} +
+
+ +
+ + {/* Labels */} + + + {/* Assignee */} + + {flexRender(cells[5].column.columnDef.cell, cells[5].getContext())} + + + {/* Created Date */} + + {flexRender(cells[7].column.columnDef.cell, cells[7].getContext())} + +
+ ) +} \ No newline at end of file diff --git a/client/components/features/issues/table/issue-columns.tsx b/client/components/features/issues/table/issue-columns.tsx new file mode 100644 index 0000000..2e0e33d --- /dev/null +++ b/client/components/features/issues/table/issue-columns.tsx @@ -0,0 +1,153 @@ +"use client" + +import { ColumnDef } from "@tanstack/react-table" +import { Issue } from "@/types/issue" +import { + PriorityCell, + KeyCell, + TitleCell, + AssigneeCell, + StateCell +} from "@/components/ui/atoms/rowElements" +import { Calendar, Bug, Star, Wrench, ArrowUp } from "lucide-react" + +// Label helpers +const getLabelIcon = (labelType: string) => { + switch (labelType) { + case 'bug': return + case 'feature': return + case 'fix': return + case 'enhancement': return + case 'due_date': return + default: return + } +} + +// Date formatter for "12 July" format +const formatDate = (dateString: string) => { + const date = new Date(dateString) + const day = date.getDate() + const month = date.toLocaleDateString('en-US', { month: 'long' }) + return `${day} ${month}` +} + +export const issueColumns: ColumnDef[] = [ + { + accessorKey: "priority", + header: "Priority", + enableSorting: true, + cell: ({ row }) => { + const issue = row.original + const priority = row.getValue("priority") as number + + return ( + + ) + }, + }, + { + accessorKey: "key", + header: "Key", + enableSorting: true, + cell: ({ row }) => { + const issue = row.original + return + }, + }, + { + accessorKey: "title", + header: "Title", + enableSorting: true, + cell: ({ row }) => { + const issue = row.original + return + }, + }, + { + id: "labels", + header: "Labels", + enableSorting: false, + cell: ({ row }) => { + const issue = row.original + const labels = issue.labels || [] + + return ( +
+ {labels.length > 0 ? ( + labels.slice(0, 2).map((label, index) => ( +
+ {getLabelIcon(label)} +
+ )) + ) : ( +
+ +
+ )} +
+ ) + }, + }, + { + accessorKey: "dueDate", + header: "Due Date", + enableSorting: true, + cell: ({ row }) => { + const dueDate = row.getValue("dueDate") as string + if (!dueDate) return No due date + + return ( + + {formatDate(dueDate)} + + ) + }, + }, + { + accessorKey: "assignee", + header: "Assignee", + enableSorting: true, + cell: ({ row }) => { + const issue = row.original + + return ( + + ) + }, + }, + { + accessorKey: "state", + header: "State", + enableSorting: true, + cell: ({ row }) => { + const issue = row.original + const state = row.getValue("state") as string + + return ( + + ) + }, + }, + { + accessorKey: "createdAt", + header: "Created", + enableSorting: true, + cell: ({ row }) => { + const createdAt = row.getValue("createdAt") as string + return ( + + {formatDate(createdAt)} + + ) + }, + }, +] \ No newline at end of file diff --git a/client/components/ui/atoms/rowElements/AssigneeCell.tsx b/client/components/ui/atoms/rowElements/AssigneeCell.tsx new file mode 100644 index 0000000..bedd806 --- /dev/null +++ b/client/components/ui/atoms/rowElements/AssigneeCell.tsx @@ -0,0 +1,23 @@ +"use client" + +import { AssigneeMenu } from "@/components/ui/molecules/menus/AssigneeMenu" + +interface Assignee { + id: string + name?: string + email?: string +} + +interface AssigneeCellProps { + issueId: string + assignee?: Assignee +} + +export function AssigneeCell({ issueId, assignee }: AssigneeCellProps) { + return ( + + ) +} \ No newline at end of file diff --git a/client/components/ui/atoms/rowElements/CreatedAtCell.tsx b/client/components/ui/atoms/rowElements/CreatedAtCell.tsx new file mode 100644 index 0000000..09e18a9 --- /dev/null +++ b/client/components/ui/atoms/rowElements/CreatedAtCell.tsx @@ -0,0 +1,11 @@ +"use client" + +import { ClientDate } from "@/components/ui/atoms/client-date" + +interface CreatedAtCellProps { + createdAt: string +} + +export function CreatedAtCell({ createdAt }: CreatedAtCellProps) { + return +} \ No newline at end of file diff --git a/client/components/ui/atoms/rowElements/KeyCell.tsx b/client/components/ui/atoms/rowElements/KeyCell.tsx new file mode 100644 index 0000000..19299c6 --- /dev/null +++ b/client/components/ui/atoms/rowElements/KeyCell.tsx @@ -0,0 +1,17 @@ +"use client" + +import { Issue } from "@/types/issue" + +interface KeyCellProps { + issue: Issue +} + +export function KeyCell({ issue }: KeyCellProps) { + const key = `ISS-${issue.id.slice(-2)}` + + return ( + + {key} + + ) +} \ No newline at end of file diff --git a/client/components/ui/atoms/rowElements/LabelsCell.tsx b/client/components/ui/atoms/rowElements/LabelsCell.tsx new file mode 100644 index 0000000..e79560e --- /dev/null +++ b/client/components/ui/atoms/rowElements/LabelsCell.tsx @@ -0,0 +1,64 @@ +"use client" + +import { Calendar, Bug, Star, Wrench, ArrowUp } from "lucide-react" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/atoms/dropdown-menu" + +interface Label { + id: string + type: string + name: string +} + +interface LabelsCellProps { + labels: Label[] + onLabelsChange: (labels: Label[]) => void +} + +const getLabelIcon = (labelType: string) => { + switch (labelType) { + case 'bug': return + case 'feature': return + case 'fix': return + case 'enhancement': return + case 'due_date': return + default: return + } +} + +export function LabelsCell({ labels }: LabelsCellProps) { + return ( + + +
+ {labels.length > 0 ? ( + labels.slice(0, 3).map((label, index) => ( +
+ {getLabelIcon(label.type)} +
+ )) + ) : ( +
+ +
+ )} +
+
+ + Labels + Add Bug Label + Add Feature Label + Add Fix Label + Add Enhancement Label + + Manage Labels + +
+ ) +} \ No newline at end of file diff --git a/client/components/ui/atoms/rowElements/PriorityCell.tsx b/client/components/ui/atoms/rowElements/PriorityCell.tsx new file mode 100644 index 0000000..d61ce3c --- /dev/null +++ b/client/components/ui/atoms/rowElements/PriorityCell.tsx @@ -0,0 +1,17 @@ +"use client" + +import { PriorityMenu } from "@/components/ui/molecules/menus/PriorityMenu" + +interface PriorityCellProps { + issueId: string + priority: number +} + +export function PriorityCell({ issueId, priority }: PriorityCellProps) { + return ( + + ) +} \ No newline at end of file diff --git a/client/components/ui/atoms/rowElements/StateCell.tsx b/client/components/ui/atoms/rowElements/StateCell.tsx new file mode 100644 index 0000000..cf891bc --- /dev/null +++ b/client/components/ui/atoms/rowElements/StateCell.tsx @@ -0,0 +1,17 @@ +"use client" + +import { StateMenu } from "@/components/ui/molecules/menus/StateMenu" + +interface StateCellProps { + issueId: string + state: string +} + +export function StateCell({ issueId, state }: StateCellProps) { + return ( + + ) +} \ No newline at end of file diff --git a/client/components/ui/atoms/rowElements/TitleCell.tsx b/client/components/ui/atoms/rowElements/TitleCell.tsx new file mode 100644 index 0000000..f7fc0fa --- /dev/null +++ b/client/components/ui/atoms/rowElements/TitleCell.tsx @@ -0,0 +1,15 @@ +"use client" + +import { Issue } from "@/types/issue" + +interface TitleCellProps { + issue: Issue +} + +export function TitleCell({ issue }: TitleCellProps) { + return ( +
+ {issue.title} +
+ ) +} \ No newline at end of file diff --git a/client/components/ui/atoms/rowElements/index.ts b/client/components/ui/atoms/rowElements/index.ts new file mode 100644 index 0000000..a0d860b --- /dev/null +++ b/client/components/ui/atoms/rowElements/index.ts @@ -0,0 +1,7 @@ +export { PriorityCell } from './PriorityCell' +export { KeyCell } from './KeyCell' +export { TitleCell } from './TitleCell' +export { AssigneeCell } from './AssigneeCell' +export { LabelsCell } from './LabelsCell' +export { StateCell } from './StateCell' +export { CreatedAtCell } from './CreatedAtCell' \ No newline at end of file diff --git a/client/components/ui/molecules/menus/AssigneeMenu.tsx b/client/components/ui/molecules/menus/AssigneeMenu.tsx new file mode 100644 index 0000000..3e91c57 --- /dev/null +++ b/client/components/ui/molecules/menus/AssigneeMenu.tsx @@ -0,0 +1,116 @@ +"use client" + +import { Avatar, AvatarFallback } from "@/components/ui/atoms/avatar" +import { User, Check } from "lucide-react" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuGroup, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/atoms/dropdown-menu" +import { useWorkspaceStore } from "@/stores/workspaceStore" +import { useIssueStore } from "@/stores/issueStore" +import { useEffect } from "react" + +interface Assignee { + id: string + name?: string + email?: string +} + +interface AssigneeMenuProps { + issueId: string + assignee?: Assignee +} + +export function AssigneeMenu({ issueId, assignee }: AssigneeMenuProps) { + const { members, fetchMembers, currentWorkspace } = useWorkspaceStore() + const { assignIssue, getIssueById } = useIssueStore() + + // Get the current issue to access assigneeId + const currentIssue = getIssueById(issueId) + + // Fetch members when component mounts or workspace changes + useEffect(() => { + if (currentWorkspace) { + console.log('Fetching members for workspace:', currentWorkspace.id) + fetchMembers() + } + }, [currentWorkspace, fetchMembers]) + + const onAssigneeChange = async (assigneeId: string | null) => { + try { + await assignIssue(issueId, assigneeId || '') + } catch (error) { + console.error('Failed to change assignee:', error) + } + } + + // Check if a member is currently assigned + const isAssigned = (memberId: string) => { + // Check both assigneeId and assignee object + return currentIssue?.assigneeId === memberId || assignee?.id === memberId + } + + return ( + + +
+ {assignee ? ( + + + {assignee.name?.charAt(0).toUpperCase() || assignee.email?.charAt(0).toUpperCase() || '?'} + + + ) : ( +
+ +
+ )} +
+
+ + + {members.length > 0 ? ( + members.map((member) => ( + onAssigneeChange(member.userId)} + className="flex items-center gap-2 cursor-pointer" + > + + + {member.user.name?.charAt(0).toUpperCase() || member.user.email?.charAt(0).toUpperCase() || '?'} + + + {member.user.name || member.user.email} + {isAssigned(member.userId) && ( + + )} + + )) + ) : ( + + No members available + + )} + + + onAssigneeChange(null)} + className="flex items-center gap-2 cursor-pointer" + > +
+ +
+ Unassign + {!currentIssue?.assigneeId && !assignee && ( + + )} +
+
+
+ ) +} \ No newline at end of file diff --git a/client/components/ui/molecules/menus/LabelsMenu.tsx b/client/components/ui/molecules/menus/LabelsMenu.tsx new file mode 100644 index 0000000..418a15d --- /dev/null +++ b/client/components/ui/molecules/menus/LabelsMenu.tsx @@ -0,0 +1,64 @@ +"use client" + +import { Calendar, Bug, Star, Wrench, ArrowUp } from "lucide-react" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/atoms/dropdown-menu" + +interface Label { + id: string + type: string + name: string +} + +interface LabelsMenuProps { + labels: Label[] + onLabelsChange: (labels: Label[]) => void +} + +const getLabelIcon = (labelType: string) => { + switch (labelType) { + case 'bug': return + case 'feature': return + case 'fix': return + case 'enhancement': return + case 'due_date': return + default: return + } +} + +export function LabelsMenu({ labels }: LabelsMenuProps) { + return ( + + +
+ {labels.length > 0 ? ( + labels.slice(0, 3).map((label, index) => ( +
+ {getLabelIcon(label.type)} +
+ )) + ) : ( +
+ +
+ )} +
+
+ + Labels + Add Bug Label + Add Feature Label + Add Fix Label + Add Enhancement Label + + Manage Labels + +
+ ) +} \ No newline at end of file diff --git a/client/components/ui/molecules/menus/PriorityMenu.tsx b/client/components/ui/molecules/menus/PriorityMenu.tsx new file mode 100644 index 0000000..dfaa93c --- /dev/null +++ b/client/components/ui/molecules/menus/PriorityMenu.tsx @@ -0,0 +1,97 @@ +"use client" + +import { Badge } from "@/components/ui/atoms/badge" +import { Check } from "lucide-react" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuGroup, + DropdownMenuTrigger, +} from "@/components/ui/atoms/dropdown-menu" +import { useIssueStore } from "@/stores/issueStore" +import { cn } from "@/lib/utils" + +interface PriorityMenuProps { + issueId: string + priority: number +} + +const getPriorityLabel = (priority: number) => { + switch (priority) { + case 1: return 'P1' + case 2: return 'P2' + case 3: return 'P3' + case 4: return 'P4' + case 0: return 'P0' + default: return `P${priority}` + } +} + +const getPriorityColor = (priority: number) => { + switch (priority) { + case 1: return 'bg-destructive/10 text-destructive border-destructive/20' + case 2: return 'bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20' + case 3: return 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border-yellow-500/20' + case 4: return 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20' + default: return 'bg-muted text-muted-foreground border-border' + } +} + +const priorityOptions = [ + { value: 1, label: 'Urgent', color: 'bg-destructive/10 text-destructive border-destructive/20' }, + { value: 2, label: 'High', color: 'bg-orange-500/10 text-orange-600 dark:text-orange-400 border-orange-500/20' }, + { value: 3, label: 'Medium', color: 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border-yellow-500/20' }, + { value: 4, label: 'Low', color: 'bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/20' }, + { value: 0, label: 'None', color: 'bg-muted text-muted-foreground border-border' }, +] + +export function PriorityMenu({ issueId, priority }: PriorityMenuProps) { + const { updateIssue, getIssueById } = useIssueStore() + + // Get the current issue to access priority + const currentIssue = getIssueById(issueId) + const currentPriority = currentIssue?.priority ?? priority + + const onPriorityChange = async (newPriority: number) => { + try { + await updateIssue(issueId, { priority: newPriority }) + } catch (error) { + console.error('Failed to change priority:', error) + } + } + + return ( + + + + {getPriorityLabel(currentPriority)} + + + + + {priorityOptions.map((option) => ( + onPriorityChange(option.value)} + className="flex items-center gap-2 cursor-pointer" + > + + {getPriorityLabel(option.value)} + + {option.label} + {currentPriority === option.value && ( + + )} + + ))} + + + + ) +} \ No newline at end of file diff --git a/client/components/ui/molecules/menus/StateMenu.tsx b/client/components/ui/molecules/menus/StateMenu.tsx new file mode 100644 index 0000000..10dd063 --- /dev/null +++ b/client/components/ui/molecules/menus/StateMenu.tsx @@ -0,0 +1,78 @@ +"use client" + +import { Clock, Check, Archive, X, List } from "lucide-react" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuGroup, + DropdownMenuTrigger, +} from "@/components/ui/atoms/dropdown-menu" +import { useIssueStore } from "@/stores/issueStore" + +interface StateMenuProps { + issueId: string + state: string +} + +const getStateIcon = (state: string) => { + switch (state) { + case 'todo': return + case 'in_progress': return + case 'done': return + case 'backlog': return + case 'canceled': return + default: return + } +} + +const stateOptions = [ + { value: 'todo', label: 'Todo', icon: }, + { value: 'in_progress', label: 'In Progress', icon: }, + { value: 'done', label: 'Done', icon: }, + { value: 'backlog', label: 'Backlog', icon: }, + { value: 'canceled', label: 'Canceled', icon: }, +] + +export function StateMenu({ issueId, state }: StateMenuProps) { + const { updateIssue, getIssueById } = useIssueStore() + + // Get the current issue to access state + const currentIssue = getIssueById(issueId) + const currentState = currentIssue?.state ?? state + + const onStateChange = async (newState: string) => { + try { + await updateIssue(issueId, { state: newState }) + } catch (error) { + console.error('Failed to change state:', error) + } + } + + return ( + + +
+ {getStateIcon(currentState)} +
+
+ + + {stateOptions.map((option) => ( + onStateChange(option.value)} + className="flex items-center gap-2 cursor-pointer" + > + {option.icon} + {option.label} + {currentState === option.value && ( + + )} + + ))} + + +
+ ) +} \ No newline at end of file diff --git a/client/components/ui/organisms/ViewHeader.tsx b/client/components/ui/organisms/ViewHeader.tsx new file mode 100644 index 0000000..8590b59 --- /dev/null +++ b/client/components/ui/organisms/ViewHeader.tsx @@ -0,0 +1,71 @@ +'use client' + +import React from 'react' +import { ViewToggle } from '@/components/features/issues/ViewToggle' +import { CreateIssueButton } from '@/components/features/issues/CreateIssueButton' +import { cn } from '@/lib/utils' + +interface ViewHeaderProps { + title: string + icon?: React.ReactNode + color?: string + showViewToggle?: boolean + view?: 'list' | 'board' + setView?: (view: 'list' | 'board') => void + showCreateIssue?: boolean + setShowCreateIssue?: (show: boolean) => void + workspaceId?: string + actions?: React.ReactNode + className?: string +} + +export function ViewHeader({ + title, + icon, + color, + showViewToggle = false, + view, + setView, + showCreateIssue = false, + setShowCreateIssue, + workspaceId, + actions, + className +}: ViewHeaderProps) { + return ( +
+
+ {icon && ( +
+ {icon} +
+ )} + {color && ( +
+ )} +

{title}

+
+ +
+ {actions} + {showViewToggle && view && setView && ( + + )} + {showCreateIssue && setShowCreateIssue && workspaceId && ( + + )} +
+
+ ) +} \ No newline at end of file diff --git a/client/components/ui/organisms/data-table.tsx b/client/components/ui/organisms/data-table.tsx new file mode 100644 index 0000000..5f048ee --- /dev/null +++ b/client/components/ui/organisms/data-table.tsx @@ -0,0 +1,183 @@ +"use client" + +import * as React from "react" +import { + ColumnDef, + getCoreRowModel, + useReactTable, + getSortedRowModel, + SortingState, + getFilteredRowModel, + ColumnFiltersState, + getPaginationRowModel, +} from "@tanstack/react-table" + +import { + Table, + TableBody, + TableCell, + TableRow, +} from "@/components/ui/molecules/table" +import { Button } from "@/components/ui/atoms/button" +import { Input } from "@/components/ui/atoms/input" +import { cn } from "@/lib/utils" +import { IssueRow } from "@/components/features/issues/table/IssueRow" + +interface DataTableProps { + columns: ColumnDef[] + data: TData[] + searchKey?: string + searchPlaceholder?: string + showSearch?: boolean + showPagination?: boolean + className?: string + emptyMessage?: string + showRowSelection?: boolean + groupBy?: string +} + +// State order for grouping +const stateOrder = ['todo', 'in_progress', 'backlog', 'done', 'canceled'] +const stateLabels = { + todo: 'To Do', + in_progress: 'In Progress', + backlog: 'Backlog', + done: 'Done', + canceled: 'Canceled' +} + +export function DataTable({ + columns, + data, + searchKey, + searchPlaceholder = "Search...", + showSearch = true, + showPagination = true, + className, + emptyMessage = "No data found", + groupBy = "state" +}: DataTableProps) { + const [sorting, setSorting] = React.useState([]) + const [columnFilters, setColumnFilters] = React.useState([]) + + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onSortingChange: setSorting, + onColumnFiltersChange: setColumnFilters, + state: { + sorting, + columnFilters, + }, + }) + + // Group data by state + const groupedData = React.useMemo(() => { + const groups: Record = {} + + table.getFilteredRowModel().rows.forEach((row) => { + const state = (row.original as Record)[groupBy] as string || 'unknown' + if (!groups[state]) { + groups[state] = [] + } + groups[state].push(row.original) + }) + + return groups + }, [table.getFilteredRowModel().rows, groupBy]) + + return ( +
+ {showSearch && searchKey && ( +
+ + table.getColumn(searchKey)?.setFilterValue(event.target.value) + } + className="max-w-sm" + /> +
+ )} + + {/* Table */} +
+ + + {Object.keys(groupedData).length > 0 ? ( + stateOrder.map((state) => { + const groupIssues = groupedData[state] + if (!groupIssues || groupIssues.length === 0) return null + + return ( + + {/* Group Header */} + + +
+ + {stateLabels[state as keyof typeof stateLabels]} + + + {groupIssues.length} issue{groupIssues.length !== 1 ? 's' : ''} + +
+
+
+ + {/* Group Issues */} + {groupIssues.map((issue) => { + const row = table.getRowModel().rows.find(r => r.original === issue) + if (!row) return null + + return ( + key={row.id} row={row} /> + ) + })} +
+ ) + }) + ) : ( + + + {emptyMessage} + + + )} +
+
+
+ + {showPagination && ( +
+
+ {table.getFilteredRowModel().rows.length} row(s) total. +
+
+ + +
+
+ )} +
+ ) +} \ No newline at end of file diff --git a/client/package-lock.json b/client/package-lock.json index 9c20b71..0e00020 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -23,6 +23,7 @@ "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "^1.2.7", "@tanstack/react-query": "^5.83.0", + "@tanstack/react-table": "^8.21.3", "@tiptap/extension-link": "^3.0.1", "@tiptap/extension-placeholder": "^3.0.1", "@tiptap/extension-table": "^3.0.1", @@ -2476,6 +2477,39 @@ "react": "^18 || ^19" } }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tiptap/core": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.0.7.tgz", @@ -6646,9 +6680,9 @@ } }, "node_modules/linkifyjs": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.1.tgz", - "integrity": "sha512-DRSlB9DKVW04c4SUdGvKK5FR6be45lTU9M76JnngqPeeGDqPwYc0zdUErtsNVMtxPXgUWV4HbXbnC4sNyBxkYg==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz", + "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==", "license": "MIT" }, "node_modules/locate-path": { diff --git a/client/package.json b/client/package.json index e8e228d..6aac049 100644 --- a/client/package.json +++ b/client/package.json @@ -24,6 +24,7 @@ "@radix-ui/react-tabs": "^1.1.12", "@radix-ui/react-tooltip": "^1.2.7", "@tanstack/react-query": "^5.83.0", + "@tanstack/react-table": "^8.21.3", "@tiptap/extension-link": "^3.0.1", "@tiptap/extension-placeholder": "^3.0.1", "@tiptap/extension-table": "^3.0.1", diff --git a/client/stores/workspaceStore.ts b/client/stores/workspaceStore.ts index 7d7b0a5..4dc95ec 100644 --- a/client/stores/workspaceStore.ts +++ b/client/stores/workspaceStore.ts @@ -92,8 +92,20 @@ export const useWorkspaceStore = create((set, get) => ({ const currentWorkspace = get().currentWorkspace if (!currentWorkspace) return try { - const res = await api.get(`/api/workspaces/${currentWorkspace.id}/members`) - set({ members: res.data.members || [] }) + const res = await api.get(`/api/workspace/${currentWorkspace.id}/members`) + // Transform the backend response to match the expected TeamMember structure + const backendMembers = res.data.data?.members || [] + const transformedMembers = backendMembers.map((member: { id: string; user_id: string; name?: string; email: string }) => ({ + id: member.id, + userId: member.user_id, + role: 'member' as const, // Default role since backend doesn't provide it + user: { + id: member.user_id, + name: member.name, + email: member.email + } + })) + set({ members: transformedMembers }) } catch (error) { set({ members: [] }) console.error('Failed to fetch members:', error)