diff --git a/apps/web/src/views/board/components/List.tsx b/apps/web/src/views/board/components/List.tsx index 258436b9d..df842241e 100644 --- a/apps/web/src/views/board/components/List.tsx +++ b/apps/web/src/views/board/components/List.tsx @@ -1,21 +1,28 @@ -import type { ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { t } from "@lingui/core/macro"; import { Draggable } from "react-beautiful-dnd"; import { useForm } from "react-hook-form"; import { HiEllipsisHorizontal, HiOutlinePlusSmall, + HiOutlineSignal, HiOutlineSquaresPlus, HiOutlineTrash, } from "react-icons/hi2"; +import { twMerge } from "tailwind-merge"; import { authClient } from "@kan/auth/client"; +import Button from "~/components/Button"; import Dropdown from "~/components/Dropdown"; import { Tooltip } from "~/components/Tooltip"; import { usePermissions } from "~/hooks/usePermissions"; import { useModal } from "~/providers/modal"; import { api } from "~/utils/api"; +import { + getWipState, + ListWipSummary, +} from "./ListWipSummary"; interface ListProps { children: ReactNode; @@ -28,11 +35,14 @@ interface List { publicId: string; name: string; createdBy?: string | null; + wipLimit?: number | null; + cards: { publicId: string }[]; } interface FormValues { listPublicId: string; name: string; + wipLimit: number | null; } type PublicListId = string; @@ -46,9 +56,13 @@ export default function List({ const { openModal } = useModal(); const { canCreateCard, canEditList, canDeleteList } = usePermissions(); const { data: session } = authClient.useSession(); + const utils = api.useUtils(); const isCreator = list.createdBy && session?.user.id === list.createdBy; const canEdit = canEditList || isCreator; const canDrag = canEditList || isCreator; + const [isWipPanelOpen, setIsWipPanelOpen] = useState(false); + const cardCount = list.cards.length; + const wipState = getWipState(cardCount, list.wipLimit); const openNewCardForm = (publicListId: PublicListId) => { if (!canCreateCard) return; @@ -56,16 +70,22 @@ export default function List({ setSelectedPublicListId(publicListId); }; - const updateList = api.list.update.useMutation(); + const updateList = api.list.update.useMutation({ + onSettled: async () => { + await utils.board.byId.invalidate(); + }, + }); const { register, handleSubmit } = useForm({ defaultValues: { listPublicId: list.publicId, name: list.name, + wipLimit: list.wipLimit ?? null, }, values: { listPublicId: list.publicId, name: list.name, + wipLimit: list.wipLimit ?? null, }, }); @@ -74,6 +94,7 @@ export default function List({ updateList.mutate({ listPublicId: values.listPublicId, name: values.name, + wipLimit: values.wipLimit, }); }; @@ -95,78 +116,153 @@ export default function List({ ref={provided.innerRef} {...provided.draggableProps} {...provided.dragHandleProps} - className="dark-text-dark-1000 mr-5 h-fit min-w-[18rem] max-w-[18rem] rounded-md border border-light-400 bg-light-300 py-2 pl-2 pr-1 text-neutral-900 dark:border-dark-300 dark:bg-dark-100" + className={twMerge( + "dark-text-dark-1000 mr-5 h-fit min-w-[18rem] max-w-[18rem] rounded-md border bg-light-300 py-2 pl-2 pr-1 text-neutral-900 transition-colors duration-200 dark:bg-dark-100", + wipState === "normal" && + "border-light-400 dark:border-dark-300", + wipState === "warning" && + "border-amber-300 bg-amber-50/40 dark:border-amber-500/30 dark:bg-amber-500/5", + wipState === "danger" && + "border-red-300 bg-red-50/40 dark:border-red-500/30 dark:bg-red-500/5", + )} > -
-
- -
-
- +
+
-
- - {(() => { - const dropdownItems = [ - ...(canCreateCard - ? [ - { - label: t`Add a card`, - action: () => openNewCardForm(list.publicId), - icon: ( - - ), - }, - ] - : []), - ...(canDeleteList || isCreator - ? [ - { - label: t`Delete list`, - action: handleOpenDeleteListConfirmation, - icon: ( - - ), - }, - ] - : []), - ]; - - if (dropdownItems.length === 0) { - return null; - } - - return ( -
- - - -
- ); - })()} + + + {(() => { + const dropdownItems = [ + ...(canCreateCard + ? [ + { + label: t`Add a card`, + action: () => openNewCardForm(list.publicId), + icon: ( + + ), + }, + ] + : []), + ...(canEdit + ? [ + { + label: t`Edit WIP limit`, + action: () => setIsWipPanelOpen((value) => !value), + icon: ( + + ), + }, + ] + : []), + ...(canDeleteList || isCreator + ? [ + { + label: t`Delete list`, + action: handleOpenDeleteListConfirmation, + icon: ( + + ), + }, + ] + : []), + ]; + + if (dropdownItems.length === 0) { + return null; + } + + return ( +
+ + + +
+ ); + })()} +
+ + + + {isWipPanelOpen ? ( +
{ + onSubmit(values); + setIsWipPanelOpen(false); + })} + className="mx-2 mt-3 rounded-md border border-light-500 bg-light-200/70 p-3 dark:border-dark-300 dark:bg-dark-200" + > + + { + if (value === "") return null; + + return Number(value); + }, + })} + className="block w-full rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 text-sm shadow-sm ring-1 ring-inset ring-light-600 placeholder:text-dark-800 focus:ring-2 focus:ring-inset focus:ring-light-700 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:leading-6" + /> +

+ {t`Use 0 or leave empty for unlimited.`} +

+
+ + +
+
+ ) : null}
{children} diff --git a/apps/web/src/views/board/components/ListWipSummary.tsx b/apps/web/src/views/board/components/ListWipSummary.tsx new file mode 100644 index 000000000..12a9a57ee --- /dev/null +++ b/apps/web/src/views/board/components/ListWipSummary.tsx @@ -0,0 +1,107 @@ +import { twMerge } from "tailwind-merge"; + +export type WipState = "normal" | "warning" | "danger"; + +export const normalizeWipLimit = ( + wipLimit?: number | null, +): number | null => { + if (!wipLimit || wipLimit <= 0) return null; + + return wipLimit; +}; + +export const getWipState = ( + cardCount: number, + wipLimit?: number | null, +): WipState => { + const normalizedWipLimit = normalizeWipLimit(wipLimit); + + if (!normalizedWipLimit) return "normal"; + if (cardCount >= normalizedWipLimit * 1.5) return "danger"; + if (cardCount > normalizedWipLimit) return "warning"; + + return "normal"; +}; + +const stateStyles: Record< + WipState, + { + container: string; + badge: string; + progressTrack: string; + progressFill: string; + } +> = { + normal: { + container: "text-light-950 dark:text-dark-950", + badge: + "bg-light-200/80 text-light-1000 dark:bg-dark-200 dark:text-dark-1000", + progressTrack: "bg-light-400/70 dark:bg-dark-300", + progressFill: "bg-light-900 dark:bg-dark-900", + }, + warning: { + container: "text-amber-700 dark:text-amber-300", + badge: "bg-amber-100 text-amber-700 dark:bg-amber-500/15 dark:text-amber-300", + progressTrack: "bg-amber-100 dark:bg-amber-500/10", + progressFill: "bg-amber-500", + }, + danger: { + container: "text-red-700 dark:text-red-300", + badge: "bg-red-100 text-red-700 dark:bg-red-500/15 dark:text-red-300", + progressTrack: "bg-red-100 dark:bg-red-500/10", + progressFill: "bg-red-500", + }, +}; + +export function ListWipSummary({ + cardCount, + wipLimit, + className, +}: { + cardCount: number; + wipLimit?: number | null; + className?: string; +}) { + const normalizedWipLimit = normalizeWipLimit(wipLimit); + const state = getWipState(cardCount, normalizedWipLimit); + const styles = stateStyles[state]; + const progress = normalizedWipLimit + ? Math.min((cardCount / normalizedWipLimit) * 100, 100) + : 0; + + return ( +
+ + {normalizedWipLimit ? `${cardCount}/${normalizedWipLimit}` : cardCount} + + + {normalizedWipLimit ? ( +
+
+
+ ) : null} +
+ ); +} diff --git a/apps/web/src/views/board/components/NewListForm.tsx b/apps/web/src/views/board/components/NewListForm.tsx index facfbe35a..827397b4a 100644 --- a/apps/web/src/views/board/components/NewListForm.tsx +++ b/apps/web/src/views/board/components/NewListForm.tsx @@ -41,6 +41,7 @@ export function NewListForm({ defaultValues: { name: "", boardPublicId: boardPublicId, + wipLimit: null, isCreateAnotherEnabled: false, }, }); @@ -59,6 +60,7 @@ export function NewListForm({ const newList = { publicId: generateUID(), name: args.name, + wipLimit: args.wipLimit && args.wipLimit > 0 ? args.wipLimit : null, boardId: 1, boardPublicId, cards: [], @@ -96,12 +98,14 @@ export function NewListForm({ if (!isCreateAnotherEnabled) closeModal(); reset({ name: "", + wipLimit: null, isCreateAnotherEnabled, }); createList.mutate({ name: data.name, boardPublicId, + wipLimit: data.wipLimit, }); }; @@ -135,6 +139,28 @@ export function NewListForm({ } }} /> +
+ + { + if (value === "") return null; + + return Number(value); + }, + })} + /> +
(
-
- +
+ {list.name} +
{list.cards.map((card) => { diff --git a/docker-compose.yml b/docker-compose.yml index 63a985b2a..dfd939c7d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,7 +19,7 @@ services: image: ghcr.io/kanbn/kan:latest container_name: ${CONTAINER_NAME:-kan-web} ports: - - "${WEB_PORT:-3000}:3000" + - "127.0.0.1:${WEB_PORT:-3050}:3000" networks: - kan-network build: @@ -138,8 +138,8 @@ services: - POSTGRES_DB=kan_db - POSTGRES_USER=kan - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - ports: - - 5432:5432 + # ports: + # - 5432:5432 volumes: - kan_postgres_data:/var/lib/postgresql/data healthcheck: diff --git a/packages/api/src/routers/list.ts b/packages/api/src/routers/list.ts index 7565f6b17..e66af89aa 100644 --- a/packages/api/src/routers/list.ts +++ b/packages/api/src/routers/list.ts @@ -7,7 +7,12 @@ import * as activityRepo from "@kan/db/repository/cardActivity.repo"; import * as listRepo from "@kan/db/repository/list.repo"; import { createTRPCRouter, protectedProcedure } from "../trpc"; -import { listCreateResponseSchema, listUpdateResponseSchema } from "../schemas"; +import { + listCreateResponseSchema, + listCreateWipLimitSchema, + listUpdateResponseSchema, + listUpdateWipLimitSchema, +} from "../schemas"; import { assertCanDelete, assertCanEdit, assertPermission } from "../utils/permissions"; export const listRouter = createTRPCRouter({ @@ -26,6 +31,7 @@ export const listRouter = createTRPCRouter({ z.object({ name: z.string().min(1), boardPublicId: z.string().min(12), + wipLimit: listCreateWipLimitSchema.optional(), }), ) .output(listCreateResponseSchema) @@ -55,6 +61,7 @@ export const listRouter = createTRPCRouter({ name: input.name, createdBy: userId, boardId: board.id, + wipLimit: input.wipLimit, }); if (!result) @@ -162,6 +169,7 @@ export const listRouter = createTRPCRouter({ listPublicId: z.string().min(12), name: z.string().min(1).optional(), index: z.number().optional(), + wipLimit: listUpdateWipLimitSchema.optional(), }), ) .output(listUpdateResponseSchema) @@ -193,12 +201,12 @@ export const listRouter = createTRPCRouter({ list.createdBy, ); - let result: { name: string; publicId: string } | undefined; + let result: { name: string; publicId: string; wipLimit: number | null } | undefined; - if (input.name) { + if (input.name !== undefined || input.wipLimit !== undefined) { result = await listRepo.update( ctx.db, - { name: input.name }, + { name: input.name, wipLimit: input.wipLimit }, { listPublicId: input.listPublicId }, ); } diff --git a/packages/api/src/schemas/board.ts b/packages/api/src/schemas/board.ts index 5327087e6..9941e1cfe 100644 --- a/packages/api/src/schemas/board.ts +++ b/packages/api/src/schemas/board.ts @@ -16,6 +16,7 @@ export const boardListItemSchema = z.object({ publicId: z.string(), name: z.string(), index: z.number(), + wipLimit: z.number().int().nullable(), }), ), labels: z.array(labelSchema), @@ -65,6 +66,7 @@ export const boardDetailSchema = z.object({ publicId: z.string(), name: z.string(), index: z.number(), + wipLimit: z.number().int().nullable(), cards: z.array(boardDetailCardSchema), }), ), @@ -106,6 +108,7 @@ export const boardBySlugSchema = z.object({ publicId: z.string(), name: z.string(), index: z.number(), + wipLimit: z.number().int().nullable(), cards: z.array(boardSlugCardSchema), }), ), diff --git a/packages/api/src/schemas/index.ts b/packages/api/src/schemas/index.ts index 74110b5ac..f0c945e51 100644 --- a/packages/api/src/schemas/index.ts +++ b/packages/api/src/schemas/index.ts @@ -32,7 +32,12 @@ export { workspaceDeleteResponseSchema, } from "./workspace"; -export { listCreateResponseSchema, listUpdateResponseSchema } from "./list"; +export { + listCreateResponseSchema, + listCreateWipLimitSchema, + listUpdateResponseSchema, + listUpdateWipLimitSchema, +} from "./list"; export { memberInviteResponseSchema } from "./member"; diff --git a/packages/api/src/schemas/list.ts b/packages/api/src/schemas/list.ts index 378375b65..d68963be7 100644 --- a/packages/api/src/schemas/list.ts +++ b/packages/api/src/schemas/list.ts @@ -1,13 +1,39 @@ import { z } from "zod"; +const rawWipLimitInputSchema = z.preprocess((value) => { + if (value === "" || value === undefined) return undefined; + if (typeof value === "number" && Number.isNaN(value)) return undefined; + + return value; +}, z.number().int().min(0).nullable().optional()); + +export const listCreateWipLimitSchema = rawWipLimitInputSchema.transform( + (value) => { + if (!value || value <= 0) return null; + + return value; + }, +); + +export const listUpdateWipLimitSchema = rawWipLimitInputSchema.transform( + (value) => { + if (value === undefined) return undefined; + if (!value || value <= 0) return null; + + return value; + }, +); + // ─── list.create ───────────────────────────────────────────── export const listCreateResponseSchema = z.object({ publicId: z.string(), name: z.string(), + wipLimit: z.number().int().nullable(), }); // ─── list.update / list.reorder ────────────────────────────── export const listUpdateResponseSchema = z.object({ publicId: z.string(), name: z.string(), + wipLimit: z.number().int().nullable(), }); diff --git a/packages/db/migrations/20260507064441_AddListWipLimit.sql b/packages/db/migrations/20260507064441_AddListWipLimit.sql new file mode 100644 index 000000000..c411b5379 --- /dev/null +++ b/packages/db/migrations/20260507064441_AddListWipLimit.sql @@ -0,0 +1 @@ +ALTER TABLE "list" ADD COLUMN "wipLimit" integer;--> statement-breakpoint diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 010a812b0..c0f353d0c 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -232,6 +232,13 @@ "when": 1776809379931, "tag": "20260421220939_AddCardNumber", "breakpoints": true + }, + { + "idx": 33, + "version": "7", + "when": 1778136281981, + "tag": "20260507064441_AddListWipLimit", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/db/src/repository/board.repo.ts b/packages/db/src/repository/board.repo.ts index aa37a1104..0f5116c9a 100644 --- a/packages/db/src/repository/board.repo.ts +++ b/packages/db/src/repository/board.repo.ts @@ -63,6 +63,7 @@ export const getAllByWorkspaceId = async ( publicId: true, name: true, index: true, + wipLimit: true, }, orderBy: [asc(lists.index)], }, @@ -246,6 +247,7 @@ export const getByPublicId = async ( name: true, boardId: true, index: true, + wipLimit: true, }, with: { cards: { @@ -443,6 +445,7 @@ export const getBySlug = async ( name: true, boardId: true, index: true, + wipLimit: true, }, with: { cards: { @@ -753,6 +756,7 @@ export const createFromSnapshot = async ( lists: { name: string; index: number; + wipLimit: number | null; cards: { title: string; description: string | null; @@ -845,6 +849,7 @@ export const createFromSnapshot = async ( createdBy: args.createdBy, boardId: newBoard.id, index: list.index, + wipLimit: list.wipLimit, })), ) .returning({ id: lists.id, index: lists.index }); @@ -998,4 +1003,4 @@ export const removeUserFavorite = async ( ) ) .returning(); -}; \ No newline at end of file +}; diff --git a/packages/db/src/repository/list.repo.ts b/packages/db/src/repository/list.repo.ts index 33d7ef305..a22ebf5e0 100644 --- a/packages/db/src/repository/list.repo.ts +++ b/packages/db/src/repository/list.repo.ts @@ -20,6 +20,7 @@ export const create = async ( createdBy: string; boardId: number; importId?: number; + wipLimit?: number | null; }, ) => { return db.transaction(async (tx) => { @@ -44,12 +45,14 @@ export const create = async ( boardId: listInput.boardId, index, importId: listInput.importId, + wipLimit: listInput.wipLimit, }) .returning({ id: lists.id, publicId: lists.publicId, boardId: lists.boardId, name: lists.name, + wipLimit: lists.wipLimit, }); if (!result) @@ -213,6 +216,7 @@ export const getByPublicId = async (db: dbClient, listPublicId: string) => { name: true, boardId: true, index: true, + wipLimit: true, }, where: and(eq(lists.publicId, listPublicId), isNull(lists.deletedAt)), }); @@ -242,7 +246,8 @@ export const getWithCardsByPublicId = async ( export const update = async ( db: dbClient, listInput: { - name: string; + name?: string; + wipLimit?: number | null; }, args: { listPublicId: string; @@ -250,11 +255,15 @@ export const update = async ( ) => { const [result] = await db .update(lists) - .set({ name: listInput.name }) + .set({ + ...(listInput.name !== undefined && { name: listInput.name }), + ...(listInput.wipLimit !== undefined && { wipLimit: listInput.wipLimit }), + }) .where(and(eq(lists.publicId, args.listPublicId), isNull(lists.deletedAt))) .returning({ publicId: lists.publicId, name: lists.name, + wipLimit: lists.wipLimit, }); return result; @@ -337,6 +346,7 @@ export const reorder = async ( columns: { publicId: true, name: true, + wipLimit: true, }, where: eq(lists.publicId, args.listPublicId), }); diff --git a/packages/db/src/schema/lists.ts b/packages/db/src/schema/lists.ts index c5c74ac94..6eddf25db 100644 --- a/packages/db/src/schema/lists.ts +++ b/packages/db/src/schema/lists.ts @@ -19,6 +19,7 @@ export const lists = pgTable("list", { publicId: varchar("publicId", { length: 12 }).notNull().unique(), name: varchar("name", { length: 255 }).notNull(), index: integer("index").notNull(), + wipLimit: integer("wipLimit"), createdBy: uuid("createdBy").references(() => users.id, { onDelete: "set null", }),