feat: add ticket numbers to cards with workspace prefix (#443)

* feat: add ticket numbers to cards with workspace prefix

- Add cardNumber to cards
- Add cardPrefix/cardCounter to workspace
- Populate initial prefixes and numbers via new migration
- Introduce generateWorkspacePrefix util and export it
- Include cardNumber in card-related API responses and search results
- Render and display tickets as PREFIX-NUMBER in UI
- Update Card component to accept ticketNumber
- Update CardModal and board cards to show number when available
- Add cardNumber to CommandPalette search results type

* feat: regen migration

* feat: enhance card and workspace schemas with cardNumber and indexing

- Updated the card repository to allocate card numbers atomically per workspace.
- Modified the card schema to include a cardNumber field and added an index on listId and cardNumber for improved query performance.
- Enhanced the workspace schema to include an index on cardPrefix for optimized lookups.
- Adjusted the migration journal to reflect the new schema changes and their timestamps.
- Updated the regex in workspace repository to allow alphanumeric prefixes in ticket IDs.

---------

Co-authored-by: Henry <henry_ball@hotmail.co.uk>
This commit is contained in:
Florent Azonnoudo
2026-04-28 21:29:41 +00:00
committed by GitHub
parent bc8b5463b3
commit 4658b2961c
21 changed files with 4302 additions and 91 deletions

View File

@@ -16,6 +16,7 @@ import { getAvatarUrl } from "~/utils/helpers";
const Card = ({
title,
ticketNumber,
labels,
members,
checklists,
@@ -25,6 +26,7 @@ const Card = ({
dueDate,
}: {
title: string;
ticketNumber?: string | null;
labels: { name: string; colourCode: string | null }[];
members: {
publicId: string;
@@ -67,6 +69,11 @@ const Card = ({
return (
<div className="flex flex-col overflow-hidden rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
{ticketNumber && (
<span className="mb-1 text-xs text-light-700 dark:text-dark-800">
{ticketNumber}
</span>
)}
<span className="break-words">{title}</span>
{labels.length ||
members.length ||

View File

@@ -146,6 +146,10 @@ export function NewCardForm({
listId: 2,
description: "",
dueDate: args.dueDate ?? null,
cardNumber: null,
comments: [],
checklists: [],
attachments: [],
labels: oldBoard.labels.filter((label) =>
args.labelPublicIds.includes(label.publicId),
),

View File

@@ -757,6 +757,11 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
>
<Card
title={card.title}
ticketNumber={
card.cardNumber != null
? `${boardData.workspace.cardPrefix}-${card.cardNumber}`
: null
}
labels={card.labels}
members={card.members}
checklists={card.checklists ?? []}

View File

@@ -1,6 +1,7 @@
import { t } from "@lingui/core/macro";
import {
HiEllipsisHorizontal,
HiHashtag,
HiLink,
HiOutlineCheckCircle,
HiOutlineTrash,
@@ -18,11 +19,13 @@ export default function CardDropdown({
isTemplate,
boardPublicId,
cardCreatedBy,
ticketNumber,
}: {
cardPublicId: string;
isTemplate?: boolean;
boardPublicId?: string;
cardCreatedBy?: string | null;
ticketNumber?: string | null;
}) {
const { openModal } = useModal();
const { showPopup } = usePopup();
@@ -53,12 +56,40 @@ export default function CardDropdown({
}
};
const handleCopyTicketId = async () => {
if (!ticketNumber) return;
try {
await navigator.clipboard.writeText(ticketNumber);
showPopup({
header: t`ID copied`,
icon: "success",
message: t`Ticket ID copied to clipboard`,
});
} catch (error) {
console.error(error);
showPopup({
header: t`Unable to copy ID`,
icon: "error",
message: t`Please try again.`,
});
}
};
const items = [
{
label: t`Copy card link`,
action: handleCopyCardLink,
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
},
...(ticketNumber
? [
{
label: t`Copy ticket ID`,
action: handleCopyTicketId,
icon: <HiHashtag className="h-[16px] w-[16px] text-dark-900" />,
},
]
: []),
...(canEditCard
? [
{

View File

@@ -340,6 +340,14 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
>
{board?.name}
</Link>
{card.cardNumber != null && card.list.board.workspace.cardPrefix && (
<>
<IoChevronForwardSharp className="h-[10px] w-[10px] text-light-900 dark:text-dark-900" />
<span className="whitespace-nowrap text-sm font-bold leading-[1.5rem] text-light-700 dark:text-dark-800">
{card.list.board.workspace.cardPrefix}-{card.cardNumber}
</span>
</>
)}
</div>
<div className="flex items-center gap-2">
<Dropdown
@@ -347,6 +355,11 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
isTemplate={isTemplate}
boardPublicId={boardId}
cardCreatedBy={card?.createdBy}
ticketNumber={
card.cardNumber != null && card.list.board.workspace.cardPrefix
? `${card.list.board.workspace.cardPrefix}-${card.cardNumber}`
: null
}
/>
<Link
href={`/${isTemplate ? "templates" : "boards"}/${boardId}`}

View File

@@ -140,6 +140,11 @@ export function CardModal({
</div>
) : (
<>
{data?.cardNumber != null && data.list.board.workspace.cardPrefix && (
<span className="mb-1 block text-xs font-medium text-light-700 dark:text-dark-800">
{data.list.board.workspace.cardPrefix}-{data.cardNumber}
</span>
)}
<h1 className="pr-8 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{data?.title}
</h1>