Inputs

Chart Of Accounts

Picking an account from a chart of accounts: the code assembles segment by segment, unfilled parts show as dots, and a posting cannot be attached to an account group.

  • cascader
  • accounting
  • codes
  • tree

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/cascader-014?lang=en

Ledger account

51.01.··

Выбрать можно только конечный субсчёт

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 "cascader-014" (Chart Of Accounts) 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/cascader-014.json

Registry item: https://vibeui.ru/r/cascader-014.json
Installs to: components/vibeui/cascader-014.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
Picking an account from a chart of accounts: the code assembles segment by segment, unfilled parts show as dots, and a posting cannot be attached to an account group.

A cascader for a chart of accounts: 51 → 01 → 02. The code assembles in front of you and sits large at the top, with unfilled segments shown as dots. Only a leaf can be selected — a posting must never land on an account group — while archived accounts stay visible but blocked. Zero dependencies, one file, its own palette.

## 3. How to use it
import { Cascader014 } from "@/components/vibeui/cascader-014"

<Cascader014 label="Ledger account" defaultPath={["51", "01"]} />

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-cascader-014-* palette — do not swap it for your theme tokens (bg-muted, text-muted-foreground and the like)
- the assembled code at the top with dots for empty segments: accountants recognise an account by its code, not its name
- the monospace font for codes — otherwise 51.01.02 and 51.1.2 look alike
- blocking selection of nodes with children: posting to an account group is an accounting error, not a convenience
- deriving the current level from path on every render instead of storing nodes in state: two sources of truth drift apart
- breadcrumbs as buttons: stepping one level up has to be a single click
- native disabled on archived accounts together with the “archive” caption — grey alone does not explain the block

## 6. You may change
- label — the heading above the code
- accounts — the chart itself as a tree with codes, names and an archived flag
- defaultPath — the path expanded at first render
- onSelect — the handler; it receives the full code and the account name
- accent — the colour of the code background and of the highlight
- the tree depth: three levels is an accounting habit, but the structure is recursive

## 7. Rules
- The code line is assembled from three segments: for four-level charts adjust both the code markup and the parsing.
- Do not sort accounts by name: code order is part of the statutory chart.
- An archived account must stay in the list: old postings still reference it and it has to be findable.
- This component does not validate active/passive accounts — that is a posting rule, not a selection rule.

## 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/cascader-014.json
https://vibeui.ru/r/cascader-014.json

Компонент самодостаточен: один файл, ноль зависимостей, собственная палитра в локальных переменных --vibeui-cascader-014-*. Клиентский: путь по дереву и выбранный код живут в useState. Дерево описано рекурсивным типом с полями code, name, children и archived; текущий уровень вычисляется проходом по path на каждом рендере, поэтому отдельного состояния «текущие узлы» нет и рассинхрона не бывает. Наверху стоит собранный код в моноширинном шрифте: незаполненные сегменты показаны точками через <i>, поэтому форма кода видна заранее. Хлебные крошки — кнопки, возвращающие путь до нужной глубины. Узлы с детьми открывают следующий уровень, листья выбираются и помечаются aria-current; архивные счета получают нативный disabled.

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 } from "react"

export type Cascader014Account = {
  code: string
  name: string
  archived?: boolean
  children?: Cascader014Account[]
}

export type Cascader014Props = Omit<
  ComponentPropsWithoutRef<"div">,
  "children" | "onSelect"
> & {
  label?: string
  accounts?: Cascader014Account[]
  defaultPath?: string[]
  onSelect?: (code: string, name: string) => void
  accent?: string
}

// Идея компонента: в плане счетов человек мыслит кодом — 51.01.02, — и код
// собирается на глазах, сегмент за сегментом. Поэтому наверху стоит строка
// кода, где невыбранные сегменты показаны точками, а выбор возможен только
// на конечном уровне: проводку нельзя повесить на группу счетов.
const STYLES = `
:where([data-vibeui-block="cascader-014"]){
--vibeui-cascader-014-bg:oklch(1 0 0);
--vibeui-cascader-014-fg:oklch(0.22 0.014 250);
--vibeui-cascader-014-muted:oklch(0.55 0.014 250);
--vibeui-cascader-014-faint:oklch(0.76 0.01 250);
--vibeui-cascader-014-border:oklch(0.9 0.008 250);
--vibeui-cascader-014-soft:oklch(0.965 0.006 250);
--vibeui-cascader-014-accent:oklch(0.48 0.12 250);
--vibeui-cascader-014-accentsoft:oklch(0.94 0.04 250);
--vibeui-cascader-014-radius:0.625rem;
--vibeui-cascader-014-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--vibeui-cascader-014-mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;
}
[data-vibeui-block="cascader-014"]{
display:flex;flex-direction:column;gap:0.5rem;
width:100%;max-width:23rem;box-sizing:border-box;padding:0.875rem;
background:var(--vibeui-cascader-014-bg);
border:1px solid var(--vibeui-cascader-014-border);
border-radius:calc(var(--vibeui-cascader-014-radius) + 0.25rem);
color:var(--vibeui-cascader-014-fg);
font-family:var(--vibeui-cascader-014-font);
}
[data-vibeui-block="cascader-014"] [data-part="title"]{
margin:0;font-size:0.875rem;font-weight:700;letter-spacing:-0.01em;
}
[data-vibeui-block="cascader-014"] [data-part="code"]{
margin:0;padding:0.45rem 0.6rem;border-radius:var(--vibeui-cascader-014-radius);
background:var(--vibeui-cascader-014-accentsoft);
font-family:var(--vibeui-cascader-014-mono);font-size:1.05rem;font-weight:700;letter-spacing:0.04em;
}
[data-vibeui-block="cascader-014"] [data-part="code"] i{font-style:normal;color:var(--vibeui-cascader-014-faint)}
[data-vibeui-block="cascader-014"] [data-part="crumbs"]{
display:flex;flex-wrap:wrap;align-items:center;gap:0.2rem;margin:0;padding:0;list-style:none;
font-size:0.75rem;
}
[data-vibeui-block="cascader-014"] [data-part="crumb"]{
appearance:none;cursor:pointer;font:inherit;
padding:0.15rem 0.4rem;border-radius:0.35rem;
border:0;background:var(--vibeui-cascader-014-soft);color:var(--vibeui-cascader-014-muted);
}
[data-vibeui-block="cascader-014"] [data-part="crumb"]:hover{color:var(--vibeui-cascader-014-fg)}
[data-vibeui-block="cascader-014"] [data-part="crumb"]:focus-visible{
outline:2px solid var(--vibeui-cascader-014-accent);outline-offset:1px;
}
[data-vibeui-block="cascader-014"] [data-part="list"]{
margin:0;padding:0.2rem;list-style:none;display:flex;flex-direction:column;gap:0.1rem;
max-height:12rem;overflow:auto;
border:1px solid var(--vibeui-cascader-014-border);
border-radius:var(--vibeui-cascader-014-radius);
}
[data-vibeui-block="cascader-014"] [data-part="row"]{
appearance:none;cursor:pointer;font:inherit;width:100%;
display:grid;grid-template-columns:3.4rem 1fr auto;align-items:center;gap:0.4rem;
box-sizing:border-box;padding:0.4rem 0.5rem;
border:0;border-radius:0.45rem;background:transparent;color:inherit;text-align:left;
transition:background-color .16s ease;
}
[data-vibeui-block="cascader-014"] [data-part="row"]:hover:not(:disabled){background:var(--vibeui-cascader-014-soft)}
[data-vibeui-block="cascader-014"] [data-part="row"]:focus-visible{
outline:2px solid var(--vibeui-cascader-014-accent);outline-offset:-2px;
}
[data-vibeui-block="cascader-014"] [data-part="row"]:disabled{cursor:not-allowed;color:var(--vibeui-cascader-014-faint)}
[data-vibeui-block="cascader-014"] [data-part="row"][aria-current="true"]{
background:var(--vibeui-cascader-014-accentsoft);font-weight:600;
}
[data-vibeui-block="cascader-014"] [data-part="num"]{
font-family:var(--vibeui-cascader-014-mono);font-size:0.78rem;font-weight:700;
}
[data-vibeui-block="cascader-014"] [data-part="name"]{
min-width:0;font-size:0.8125rem;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
[data-vibeui-block="cascader-014"] [data-part="more"]{color:var(--vibeui-cascader-014-muted);font-size:0.8rem}
[data-vibeui-block="cascader-014"] [data-part="note"]{
margin:0;font-size:0.78rem;color:var(--vibeui-cascader-014-muted);
}
[data-vibeui-block="cascader-014"] [data-part="note"] b{color:var(--vibeui-cascader-014-fg)}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="cascader-014"] *{animation:none!important;transition:none!important}}
`

const PLAN: Cascader014Account[] = [
  {
    code: "50",
    name: "Касса",
    children: [
      {
        code: "01",
        name: "Касса организации",
        children: [
          { code: "01", name: "Основная касса" },
          { code: "02", name: "Касса магазина" },
        ],
      },
      {
        code: "03",
        name: "Денежные документы",
        children: [{ code: "01", name: "Марки и бланки" }],
      },
    ],
  },
  {
    code: "51",
    name: "Расчётные счета",
    children: [
      {
        code: "01",
        name: "Рублёвые счета",
        children: [
          { code: "01", name: "Основной счёт" },
          { code: "02", name: "Счёт для налогов" },
          { code: "09", name: "Закрытый счёт", archived: true },
        ],
      },
      {
        code: "02",
        name: "Специальные счета",
        children: [{ code: "01", name: "Эскроу по договору" }],
      },
    ],
  },
  {
    code: "62",
    name: "Расчёты с покупателями",
    children: [
      {
        code: "01",
        name: "Расчёты в рублях",
        children: [
          { code: "01", name: "Оптовые покупатели" },
          { code: "02", name: "Розница" },
        ],
      },
    ],
  },
]

/**
 * Выбор счёта в плане счетов: код собирается сегмент за сегментом.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Cascader014({
  label = "Счёт учёта",
  accounts = PLAN,
  defaultPath = ["51", "01"],
  onSelect,
  accent,
  className,
  style,
  ...props
}: Cascader014Props) {
  const [path, setPath] = useState(defaultPath)
  const [chosen, setChosen] = useState("")

  const trail: Cascader014Account[] = []
  let level = accounts

  for (const code of path) {
    const node = level.find((entry) => entry.code === code)

    if (!node) break

    trail.push(node)
    level = node.children ?? []
  }

  const segments = [path[0] ?? "", path[1] ?? "", chosen.split(".")[2] ?? ""]

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

  return (
    <>
      <style href="vibeui-cascader-014" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="cascader-014"
        className={className}
        style={palette}
      >
        <h3 data-part="title">{label}</h3>
        <p data-part="code" aria-live="polite">
          {segments.map((segment, index) => (
            <span key={index}>
              {index > 0 ? "." : ""}
              {segment ? segment : <i>··</i>}
            </span>
          ))}
        </p>
        <ul data-part="crumbs">
          <li>
            <button
              type="button"
              data-part="crumb"
              onClick={() => {
                setPath([])
                setChosen("")
              }}
            >
              План счетов
            </button>
          </li>
          {trail.map((node, index) => (
            <li key={node.code}>
              <span aria-hidden="true">›</span>{" "}
              <button
                type="button"
                data-part="crumb"
                onClick={() => {
                  setPath(path.slice(0, index + 1))
                  setChosen("")
                }}
              >
                {node.code} {node.name}
              </button>
            </li>
          ))}
        </ul>
        <ul data-part="list" aria-label="Счета уровня">
          {level.map((node) => {
            const branch = Boolean(node.children?.length)
            const full = [...path, node.code].join(".")

            return (
              <li key={node.code}>
                <button
                  type="button"
                  data-part="row"
                  disabled={node.archived}
                  aria-current={!branch && chosen === full}
                  onClick={() => {
                    if (branch) {
                      setPath([...path, node.code])
                      setChosen("")

                      return
                    }

                    setChosen(full)
                    onSelect?.(full, node.name)
                  }}
                >
                  <span data-part="num">{node.code}</span>
                  <span data-part="name">
                    {node.name}
                    {node.archived ? " · архив" : ""}
                  </span>
                  <span data-part="more" aria-hidden="true">
                    {branch ? "›" : "•"}
                  </span>
                </button>
              </li>
            )
          })}
        </ul>
        <p data-part="note">
          {chosen ? (
            <>
              Выбран счёт <b>{chosen}</b>
            </>
          ) : (
            "Выбрать можно только конечный субсчёт"
          )}
        </p>
      </div>
    </>
  )
}