diff --git a/packages/backend/.env.example b/packages/backend/.env.example index 82191fc..72c5f83 100644 --- a/packages/backend/.env.example +++ b/packages/backend/.env.example @@ -10,7 +10,7 @@ S3_PUBLIC_URL=https://images.sprintpm.org S3_ENDPOINT=https://account_id.r2.cloudflarestorage.com/sprint S3_ACCESS_KEY_ID=your_access_key_id S3_SECRET_ACCESS_KEY=your_secret_access_key -S3_BUCKET_NAME=issue +S3_BUCKET_NAME=sprint STRIPE_PUBLISHABLE_KEY=your_stripe_publishable_key STRIPE_SECRET_KEY=your_stripe_secret_key diff --git a/packages/backend/scripts/db-seed.ts b/packages/backend/scripts/db-seed.ts index 05dea8f..4c7e7a7 100644 --- a/packages/backend/scripts/db-seed.ts +++ b/packages/backend/scripts/db-seed.ts @@ -103,16 +103,81 @@ if (!SEED_PASSWORD) { } const passwordHash = await hashPassword(SEED_PASSWORD); +const now = new Date(); const users = [ - { name: "demo user 1", username: "demo1", email: "demo1@example.com", passwordHash, avatarURL: null }, - { name: "demo user 2", username: "demo2", email: "demo2@example.com", passwordHash, avatarURL: null }, + { + name: "demo user 1", + username: "demo1", + email: "demo1@example.com", + passwordHash, + avatarURL: null, + emailVerified: true, + emailVerifiedAt: now, + }, + { + name: "demo user 2", + username: "demo2", + email: "demo2@example.com", + passwordHash, + avatarURL: null, + emailVerified: true, + emailVerifiedAt: now, + }, // anything past here is just to have more users to assign issues to - { name: "demo user 3", username: "demo3", email: "demo3@example.com", passwordHash, avatarURL: null }, - { name: "demo user 4", username: "demo4", email: "demo4@example.com", passwordHash, avatarURL: null }, - { name: "demo user 5", username: "demo5", email: "demo5@example.com", passwordHash, avatarURL: null }, - { name: "demo user 6", username: "demo6", email: "demo6@example.com", passwordHash, avatarURL: null }, - { name: "demo user 7", username: "demo7", email: "demo7@example.com", passwordHash, avatarURL: null }, - { name: "demo user 8", username: "demo8", email: "demo8@example.com", passwordHash, avatarURL: null }, + { + name: "demo user 3", + username: "demo3", + email: "demo3@example.com", + passwordHash, + avatarURL: null, + emailVerified: true, + emailVerifiedAt: now, + }, + { + name: "demo user 4", + username: "demo4", + email: "demo4@example.com", + passwordHash, + avatarURL: null, + emailVerified: true, + emailVerifiedAt: now, + }, + { + name: "demo user 5", + username: "demo5", + email: "demo5@example.com", + passwordHash, + avatarURL: null, + emailVerified: true, + emailVerifiedAt: now, + }, + { + name: "demo user 6", + username: "demo6", + email: "demo6@example.com", + passwordHash, + avatarURL: null, + emailVerified: true, + emailVerifiedAt: now, + }, + { + name: "demo user 7", + username: "demo7", + email: "demo7@example.com", + passwordHash, + avatarURL: null, + emailVerified: true, + emailVerifiedAt: now, + }, + { + name: "demo user 8", + username: "demo8", + email: "demo8@example.com", + passwordHash, + avatarURL: null, + emailVerified: true, + emailVerifiedAt: now, + }, ]; async function seed() { diff --git a/packages/frontend/src/components/issues-table.tsx b/packages/frontend/src/components/issues-table.tsx index 68140ae..fb54dad 100644 --- a/packages/frontend/src/components/issues-table.tsx +++ b/packages/frontend/src/components/issues-table.tsx @@ -170,108 +170,126 @@ export function IssuesTable({ - {issues.map((issueData) => ( - { - if (issueData.Issue.id === selectedIssueId) { - selectIssue(null); - return; - } - selectIssue(issueData); - }} - > - {showId && ( - - { + const isSelected = issueData.Issue.id === selectedIssueId; + return ( + { + if (isSelected) { + selectIssue(null); + return; + } + selectIssue(issueData); + }} + > + {showId && ( + - {issueData.Issue.number.toString().padStart(3, "0")} - - - )} - {showTitle && ( - - + {issueData.Issue.number.toString().padStart(3, "0")} + + + )} + {showTitle && ( + - {selectedOrganisation?.Organisation.features.issueTypes && - issueTypes[issueData.Issue.type] && ( - - )} - {selectedOrganisation?.Organisation.features.issueStatus && - (columns.status == null || columns.status === true) && ( - - )} - {issueData.Issue.title} - - - )} - {showDescription && ( - - + {selectedOrganisation?.Organisation.features.issueTypes && + issueTypes[issueData.Issue.type] && ( + + )} + {selectedOrganisation?.Organisation.features.issueStatus && + (columns.status == null || columns.status === true) && ( + + )} + {issueData.Issue.title} + + + )} + {showDescription && ( + - {issueData.Issue.description} - - - )} - {showAssignee && ( - - + {issueData.Issue.description} + + + )} + {showAssignee && ( + - {selectedOrganisation?.Organisation.features.issueAssigneesShownInTable && - issueData.Assignees && - issueData.Assignees.length > 0 && ( - - {issueData.Assignees.slice(0, 3).map((assignee) => ( - - ))} - {issueData.Assignees.length > 3 && ( - - +{issueData.Assignees.length - 3} - - )} - - )} - - - )} - - ))} + + {selectedOrganisation?.Organisation.features.issueAssigneesShownInTable && + issueData.Assignees && + issueData.Assignees.length > 0 && ( + + {issueData.Assignees.slice(0, 3).map((assignee) => ( + + ))} + {issueData.Assignees.length > 3 && ( + + +{issueData.Assignees.length - 3} + + )} + + )} + + + )} + + ); + })} ); diff --git a/packages/frontend/src/components/organisations.tsx b/packages/frontend/src/components/organisations.tsx index 9b5821b..13e2209 100644 --- a/packages/frontend/src/components/organisations.tsx +++ b/packages/frontend/src/components/organisations.tsx @@ -203,13 +203,14 @@ function Organisations({ trigger }: { trigger?: ReactNode }) { // generate CSV or JSON if (format === "csv") { - const headers = ["User ID", "Name", "Username", "Total Time (ms)", "Total Time (formatted)"]; + const headers = ["User ID", "Name", "Username", "Total Time (ms)", "Total Time (formatted)", "Hours"]; const rows = data.map((user) => [ user.userId, user.name, user.username, user.totalTimeMs, formatDuration(user.totalTimeMs), + (user.totalTimeMs / 3600000).toFixed(2), ]); const csv = [headers.join(","), ...rows.map((row) => row.map((cell) => `"${cell}"`).join(","))].join( "\n", @@ -234,6 +235,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) { members: data.map((user) => ({ ...user, totalTimeFormatted: formatDuration(user.totalTimeMs), + hours: Number((user.totalTimeMs / 3600000).toFixed(2)), })), }, null, diff --git a/packages/frontend/src/components/ui/icon.tsx b/packages/frontend/src/components/ui/icon.tsx index 304ea47..f659017 100644 --- a/packages/frontend/src/components/ui/icon.tsx +++ b/packages/frontend/src/components/ui/icon.tsx @@ -11,6 +11,7 @@ import { Clock as PixelClock, Close as PixelClose, MessagePlus as PixelCommentSend, + Copy as PixelCopy, CreditCard as PixelCreditCard, CreditCardDelete as PixelCreditCardDelete, Dashboard as PixelDashboard, @@ -53,6 +54,7 @@ import { CaretUpIcon as PhosphorChevronUp, CircleIcon as PhosphorCircle, ChatTextIcon as PhosphorComment, + CopyIcon as PhosphorCopy, CreditCardIcon as PhosphorCreditCard, CubeIcon as PhosphorCube, DotsSixVerticalIcon as PhosphorDotsSixVertical, @@ -102,6 +104,7 @@ import { CircleCheckIcon, CircleIcon, CircleQuestionMark, + Copy, CreditCard, Edit, EllipsisVertical, @@ -161,6 +164,7 @@ const icons = { circleIcon: { lucide: CircleIcon, pixel: PixelCircle, phosphor: PhosphorCircle }, circleQuestionMark: { lucide: CircleQuestionMark, pixel: PixelNoteDelete, phosphor: PhosphorQuestion }, comment: { lucide: MessageSquarePlus, pixel: PixelCommentSend, phosphor: PhosphorComment }, + copy: { lucide: Copy, pixel: PixelCopy, phosphor: PhosphorCopy }, creditCard: { lucide: CreditCard, pixel: PixelCreditCard, phosphor: PhosphorCreditCard }, creditCardDelete: { lucide: CreditCard, pixel: PixelCreditCardDelete, phosphor: PhosphorCreditCard }, edit: { lucide: Edit, pixel: PixelEdit, phosphor: PhosphorEdit }, diff --git a/packages/frontend/src/pages/Issues.tsx b/packages/frontend/src/pages/Issues.tsx index 6ea61f0..e9834a0 100644 --- a/packages/frontend/src/pages/Issues.tsx +++ b/packages/frontend/src/pages/Issues.tsx @@ -2,10 +2,12 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { useLocation } from "react-router-dom"; +import Avatar from "@/components/avatar"; import { IssueDetailPane } from "@/components/issue-detail-pane"; import { IssueModal } from "@/components/issue-modal"; import { defaultIssuesTableFilters, IssuesTable, type IssuesTableFilters } from "@/components/issues-table"; import { useSelection } from "@/components/selection-provider"; +import SmallSprintDisplay from "@/components/small-sprint-display"; import SmallUserDisplay from "@/components/small-user-display"; import StatusTag from "@/components/status-tag"; import TopBar from "@/components/top-bar"; @@ -353,12 +355,6 @@ export default function Issues() { "title-desc": "Title Z-A", status: "Status", }; - const sprintLabel = useMemo(() => { - if (issueFilters.sprintId === "all") return "Sprint"; - if (issueFilters.sprintId === "none") return "No sprint"; - const sprintMatch = sprintsData.find((sprint) => sprint.id === issueFilters.sprintId); - return sprintMatch?.name ?? "Sprint"; - }, [issueFilters.sprintId, sprintsData]); return ( @@ -384,7 +380,17 @@ export default function Issues() { {selectedOrganisation?.Organisation.features.issueStatus && ( - Status + {issueFilters.statuses.length === 0 ? ( + "Status" + ) : ( + + {Object.entries(statuses) + .filter(([status]) => issueFilters.statuses.includes(status)) + .map(([status, colour]) => ( + + ))} + + )} Status @@ -414,7 +420,20 @@ export default function Issues() { {selectedOrganisation?.Organisation.features.issueTypes && ( - Type + {issueFilters.types.length === 0 ? ( + "Type" + ) : ( + + {Object.entries(issueTypes) + .filter(([type]) => issueFilters.types.includes(type)) + .map(([type, definition]) => ( + + + {type} + + ))} + + )} Type @@ -446,7 +465,25 @@ export default function Issues() { )} - Assignee + {issueFilters.assignees.length === 0 ? ( + "Assignee" + ) : ( + + {issueFilters.assignees.includes("unassigned") && Unassigned} + {members + .filter((member) => issueFilters.assignees.includes(String(member.id))) + .map((member) => ( + + ))} + + )} Assignee @@ -486,7 +523,16 @@ export default function Issues() { {selectedOrganisation?.Organisation.features.sprints && ( - {sprintLabel} + {issueFilters.sprintId === "all" ? ( + "Sprint" + ) : issueFilters.sprintId === "none" ? ( + + ) : ( + (() => { + const sprint = sprintsData.find((s) => s.id === issueFilters.sprintId); + return sprint ? : "Sprint"; + })() + )} Sprint @@ -555,6 +601,64 @@ export default function Issues() { > + { + const params = new URLSearchParams(window.location.search); + + if (issueFilters.query) { + params.set("q", issueFilters.query); + } else { + params.delete("q"); + } + + if (issueFilters.statuses.length > 0) { + params.set("status", issueFilters.statuses.join(",")); + } else { + params.delete("status"); + } + + if (issueFilters.types.length > 0) { + params.set("type", issueFilters.types.join(",")); + } else { + params.delete("type"); + } + + if (issueFilters.assignees.length > 0) { + params.set("assignee", issueFilters.assignees.join(",")); + } else { + params.delete("assignee"); + } + + if (issueFilters.sprintId !== defaultIssuesTableFilters.sprintId) { + params.set("sprint", String(issueFilters.sprintId)); + } else { + params.delete("sprint"); + } + + if (issueFilters.sort !== defaultIssuesTableFilters.sort) { + params.set("sort", issueFilters.sort); + } else { + params.delete("sort"); + } + + const url = `${window.location.origin}${window.location.pathname}?${params.toString()}`; + navigator.clipboard.writeText(url); + }} + > + + )}