Display

Column Totals Board

A board that counts hours rather than cards: the column total, its share and the grand total are derived from the cards on every render.

  • kanban
  • totals
  • estimate
  • board

Preview

1440pxHost theme

Use it with AI

  1. 1. Copy the link.
  2. 2. Write to your agent in your own words and drop the link into the sentence.
  3. 3. The agent opens the link and installs the component from the registry.

put this in the header: https://vibeui.ru/c/kanban-007?lang=en

Оценено2

  • Каталог: фильтры12 h
  • Экспорт заказов6 h

18 h35%

В работе2

  • Личный кабинет20 h
  • Импорт остатков8 h

28 h55%

Сдано1

  • Страница тарифов5 h

5 h10%

Итого: 51 h5 задач в 3 колонках
Show the full instructions

What the link says, spelled out. Use it when your agent cannot open links — paste this text instead.

# Install and place "kanban-007" (Column Totals Board) from VibeUI

## 1. Install first — do not skip, do not recreate
Run this exact command before writing any code:
npx shadcn@latest add https://vibeui.ru/r/kanban-007.json

Registry item: https://vibeui.ru/r/kanban-007.json
Installs to: components/vibeui/kanban-007.tsx (the exact path follows this project's components.json aliases).
npm dependencies: none.
Registry dependencies: none.

Install it from the registry. Do not recreate it from the description,
do not substitute a similar component from another library, and do not
rewrite it to match the project's existing style.

## 2. What it is
A board that counts hours rather than cards: the column total, its share and the grand total are derived from the cards on every render.

A board with column totals: summed hours, the column share as a bar and a percentage, and a grand total in the footer. Zero dependencies, one file.

## 3. How to use it
import { Kanban007 } from "@/components/vibeui/kanban-007"

<Kanban007 unit="h" cards={[{ id: "1", title: "Catalogue", hours: 12, column: "Estimated" }]} />

Read the installed file for the full prop list.

## 4. Where to place it
This is a small inline component. Put it exactly where the user asked,
inside the existing markup. Do not create a new page, section or
wrapper for it. If a similar control already sits in that spot,
replace it instead of adding a second one.

Placement: ___
(The user fills this line in. If it is still blank, ask where to put it
instead of guessing.)

## 5. Keep exactly as installed
- the local --vibeui-kanban-007-* palette — do not swap it for your theme tokens
- computing the totals from the cards: a stored sum drifts from the board after the first move
- the share as both a bar and a number — the bar gives comparison, the number gives precision
- the column select on the card: mouse dragging is unavailable from a keyboard
- the role=status live region carrying the column's new total after a move
- the footer element for the grand total with a worded summary

## 6. You may change
- the columns and cards arrays with estimates in hours
- unit — the unit of the estimate
- the onChange handler and the accent through the accent prop
- the column width

## 7. Rules
- The estimate has to be a number: a string breaks the summation.
- The share is rounded to whole percents, so the shares may add up to 99 or 101.
- Order inside a column does not change: the move only sets membership.
- Do not lift the CSS variables into globals.css: the component has to stay a single file.

## 8. Verify
- it renders with no console errors;
- it looks like the preview on the VibeUI page you copied this from;
- if it does not, you changed something listed in section 5 — put it back.

For developers

npx shadcn@latest add https://vibeui.ru/r/kanban-007.json
https://vibeui.ru/r/kanban-007.json

Компонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-kanban-007-*. Клиентский: "use client" ради состояния доски. Итог колонки, доля и общий итог считаются из массива карточек, а не хранятся числом: сохранённая сумма расходится с содержимым после первого переноса. Доля показана и процентом, и полосой, чтобы колонки сравнивались с одного взгляда. Итоговая строка вынесена в тег footer. Перенос: нативный drag мышью и список колонок на карточке с клавиатуры, результат объявляется в живой области.

Component source

The same file your agent installs. Here in case you would rather copy it by hand.

"use client"

import { useState } from "react"
import type { ComponentPropsWithoutRef, CSSProperties, DragEvent } from "react"

export type Kanban007Card = {
  id: string
  title: string
  hours: number
  column: string
}

export type Kanban007Props = Omit<
  ComponentPropsWithoutRef<"div">,
  "children" | "onChange"
> & {
  columns?: string[]
  cards?: Kanban007Card[]
  unit?: string
  onChange?: (cards: Kanban007Card[]) => void
  accent?: string
}

// Идея компонента: доска, которая считает не карточки, а часы. Итог колонки и
// итог доски выводятся из карточек при каждом рендере — хранить их отдельным
// числом нельзя, после первого же переноса оно разойдётся с содержимым. Доля
// колонки показана и цифрой, и полосой: полоса даёт сравнение колонок с одного
// взгляда, цифра — точность. Итоговая строка сделана тегом footer со сводкой
// словами, поэтому её читают и глазами, и скринридером. Перенос: мышью — drag,
// с клавиатуры — список колонок на карточке, результат объявляется вслух.
const STYLES = `
:where([data-vibeui-block="kanban-007"]){
--vibeui-kanban-007-bg:oklch(0.985 0.002 265);
--vibeui-kanban-007-card:oklch(1 0 0);
--vibeui-kanban-007-fg:oklch(0.24 0.014 265);
--vibeui-kanban-007-muted:oklch(0.56 0.014 265);
--vibeui-kanban-007-border:oklch(0.91 0.006 265);
--vibeui-kanban-007-accent:oklch(0.55 0.2 262);
--vibeui-kanban-007-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="kanban-007"]{
position:relative;display:grid;gap:0.625rem;
width:100%;box-sizing:border-box;padding:0.75rem;
background:var(--vibeui-kanban-007-bg);
border:1px solid var(--vibeui-kanban-007-border);border-radius:0.875rem;
font-family:var(--vibeui-kanban-007-font);color:var(--vibeui-kanban-007-fg);
}
[data-vibeui-block="kanban-007"] *{box-sizing:border-box}
[data-vibeui-block="kanban-007"] [data-part="board"]{
display:grid;grid-auto-flow:column;grid-auto-columns:minmax(11rem,1fr);gap:0.625rem;overflow-x:auto;
}
[data-vibeui-block="kanban-007"] [data-part="column"]{
display:flex;flex-direction:column;gap:0.5rem;min-width:0;
padding:0.5rem;border-radius:0.75rem;border:1px dashed transparent;
}
[data-vibeui-block="kanban-007"] [data-part="column"][data-over="true"]{
border-color:var(--vibeui-kanban-007-accent);
background:color-mix(in oklab,var(--vibeui-kanban-007-accent) 6%,transparent);
}
[data-vibeui-block="kanban-007"] [data-part="head"]{
margin:0;display:flex;align-items:baseline;justify-content:space-between;gap:0.5rem;
font-size:0.75rem;font-weight:650;
}
[data-vibeui-block="kanban-007"] [data-part="count"]{
color:var(--vibeui-kanban-007-muted);font-size:0.6875rem;font-variant-numeric:tabular-nums;
}
[data-vibeui-block="kanban-007"] ul{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:0.375rem;flex:1 1 auto}
[data-vibeui-block="kanban-007"] [data-part="card"]{
display:grid;gap:0.375rem;padding:0.5rem;border-radius:0.625rem;cursor:grab;
background:var(--vibeui-kanban-007-card);
border:1px solid var(--vibeui-kanban-007-border);
box-shadow:0 1px 2px oklch(0.2 0.02 265 / 6%);
font-size:0.75rem;line-height:1.35;
}
[data-vibeui-block="kanban-007"] [data-part="card"][data-dragging="true"]{opacity:.45}
[data-vibeui-block="kanban-007"] [data-part="hours"]{
justify-self:start;padding:0 0.375rem;border-radius:0.375rem;
background:color-mix(in oklab,var(--vibeui-kanban-007-accent) 10%,transparent);
color:color-mix(in oklab,var(--vibeui-kanban-007-accent) 80%,black);
font-size:0.625rem;font-weight:650;font-variant-numeric:tabular-nums;
}
[data-vibeui-block="kanban-007"] select{
width:100%;height:1.625rem;padding:0 0.25rem;
border:1px solid var(--vibeui-kanban-007-border);border-radius:0.375rem;
background:var(--vibeui-kanban-007-card);color:var(--vibeui-kanban-007-muted);
font:inherit;font-size:0.625rem;
}
[data-vibeui-block="kanban-007"] select:focus-visible{outline:2px solid var(--vibeui-kanban-007-accent);outline-offset:1px}
/* Итог колонки считается из карточек: отдельное число разошлось бы после переноса. */
[data-vibeui-block="kanban-007"] [data-part="total"]{
display:grid;gap:0.25rem;padding-top:0.375rem;
border-top:1px solid var(--vibeui-kanban-007-border);
font-size:0.6875rem;font-weight:650;font-variant-numeric:tabular-nums;
}
[data-vibeui-block="kanban-007"] [data-part="share"]{
display:flex;align-items:center;justify-content:space-between;gap:0.5rem;
color:var(--vibeui-kanban-007-muted);font-weight:600;
}
[data-vibeui-block="kanban-007"] [data-part="bar"]{
height:3px;border-radius:999px;overflow:hidden;
background:color-mix(in oklab,var(--vibeui-kanban-007-muted) 20%,transparent);
}
[data-vibeui-block="kanban-007"] [data-part="bar"] span{display:block;height:100%;background:var(--vibeui-kanban-007-accent)}
[data-vibeui-block="kanban-007"] footer{
display:flex;align-items:baseline;justify-content:space-between;gap:0.75rem;
padding-top:0.5rem;border-top:1px solid var(--vibeui-kanban-007-border);
font-size:0.75rem;font-weight:650;font-variant-numeric:tabular-nums;
}
[data-vibeui-block="kanban-007"] footer span{color:var(--vibeui-kanban-007-muted);font-weight:600;font-size:0.6875rem}
[data-vibeui-block="kanban-007"] [data-part="live"]{
position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;
clip-path:inset(50%);white-space:nowrap;border:0;
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="kanban-007"] *{animation:none!important;transition:none!important}}
`

const DEFAULT_COLUMNS = ["Оценено", "В работе", "Сдано"]

const DEFAULT_CARDS: Kanban007Card[] = [
  { id: "1", title: "Каталог: фильтры", hours: 12, column: "Оценено" },
  { id: "2", title: "Экспорт заказов", hours: 6, column: "Оценено" },
  { id: "3", title: "Личный кабинет", hours: 20, column: "В работе" },
  { id: "4", title: "Импорт остатков", hours: 8, column: "В работе" },
  { id: "5", title: "Страница тарифов", hours: 5, column: "Сдано" },
]

/**
 * Доска со счётчиками и итогом по колонкам: часы суммируются из карточек.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Kanban007({
  columns = DEFAULT_COLUMNS,
  cards = DEFAULT_CARDS,
  unit = "ч",
  onChange,
  accent,
  className,
  style,
  ...props
}: Kanban007Props) {
  const [board, setBoard] = useState(cards)
  const [dragged, setDragged] = useState<string | null>(null)
  const [over, setOver] = useState<string | null>(null)
  const [announcement, setAnnouncement] = useState("")

  const grand = board.reduce((sum, card) => sum + card.hours, 0)

  const put = (id: string, column: string) => {
    const card = board.find((row) => row.id === id)
    if (!card || card.column === column) return

    const next = board.map((row) => (row.id === id ? { ...row, column } : row))
    const total = next
      .filter((row) => row.column === column)
      .reduce((sum, row) => sum + row.hours, 0)

    setBoard(next)
    onChange?.(next)
    setAnnouncement(
      `«${card.title}» перенесена в «${column}». Итог колонки ${total} ${unit}.`,
    )
  }

  const drop = (event: DragEvent<HTMLElement>, column: string) => {
    event.preventDefault()
    setOver(null)
    if (dragged) put(dragged, column)
    setDragged(null)
  }

  const palette = {
    ...(accent ? { "--vibeui-kanban-007-accent": accent } : null),
    ...style,
  } as CSSProperties

  return (
    <>
      <style href="vibeui-kanban-007" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="kanban-007"
        className={className}
        style={palette}
      >
        <div data-part="board">
          {columns.map((column) => {
            const rows = board.filter((card) => card.column === column)
            const total = rows.reduce((sum, card) => sum + card.hours, 0)
            const share = grand === 0 ? 0 : Math.round((total / grand) * 100)

            return (
              <section
                key={column}
                data-part="column"
                data-over={column === over}
                aria-label={`${column}: ${rows.length} задач, ${total} ${unit}`}
                onDragOver={(event) => {
                  event.preventDefault()
                  setOver(column)
                }}
                onDragLeave={() => setOver(null)}
                onDrop={(event) => drop(event, column)}
              >
                <p data-part="head">
                  {column}
                  <span data-part="count">{rows.length}</span>
                </p>
                <ul>
                  {rows.map((card) => (
                    <li key={card.id}>
                      <article
                        data-part="card"
                        data-dragging={card.id === dragged}
                        draggable
                        onDragStart={() => setDragged(card.id)}
                        onDragEnd={() => {
                          setDragged(null)
                          setOver(null)
                        }}
                      >
                        {card.title}
                        <span data-part="hours">
                          {card.hours} {unit}
                        </span>
                        <select
                          value={card.column}
                          aria-label={`Колонка задачи «${card.title}»`}
                          onChange={(event) => put(card.id, event.target.value)}
                        >
                          {columns.map((option) => (
                            <option key={option} value={option}>
                              {option}
                            </option>
                          ))}
                        </select>
                      </article>
                    </li>
                  ))}
                </ul>
                <p data-part="total">
                  <span data-part="share">
                    <span>
                      {total} {unit}
                    </span>
                    <span>{share}%</span>
                  </span>
                  <span data-part="bar" aria-hidden="true">
                    <span style={{ width: `${share}%` }} />
                  </span>
                </p>
              </section>
            )
          })}
        </div>
        <footer>
          Итого: {grand} {unit}
          <span>
            {board.length} задач в {columns.length} колонках
          </span>
        </footer>
        <span data-part="live" role="status" aria-live="polite">
          {announcement}
        </span>
      </div>
    </>
  )
}