Back to projectscurrent project

Arbat Beauty CRM

My main commercial project since May 2025. An internal CRM used daily by salon administrators for bookings, staff scheduling, inventory and payroll. I am the top contributor — I lead the migration of the legacy frontend to React 19 and TypeScript, design the architecture along Feature-Sliced Design, and own modules end to end.

  • React 19
  • TypeScript
  • Vite
  • TanStack Query
  • Zustand
  • React Hook Form
  • React Router
  • Sass
  • Storybook
  • Vitest
  • Playwright
  • Top contributor: 1486 commits over 15 months.
  • Custom drag-and-drop built on mouse events instead of a ready-made library — extracted into a shared hook and reused by the schedule grid and the shared data table.
  • Own entire modules: staff scheduling, visits and clients, inventory and tech maps, payroll and payouts.
  • Feature-Sliced Design architecture across 10 domain entities.
  • Testing pyramid: Vitest for unit and component tests, Playwright for e2e, 34 Storybook stories.

How it was built

An internal product for the daily work of salon administrators: client bookings, staff schedules, consumable inventory and employee payroll.

Problem

  • Legacy JavaScript frontend with duplicated business logic: a single change had to be made in several places.
  • The schedule is the heaviest screen in the product: dozens of employees, a monthly grid, reordering rows and shifts without a reload.
  • Off-the-shelf drag-and-drop libraries did not fit: the native HTML5 drag decides whether a drag starts before React can mark the row, and it drags an unstylable ghost with a "copy" badge.
  • Modules had to be rewritten without pausing feature delivery for the business.

Solution

  • Moved to React 19 + TypeScript and decomposed the code along Feature-Sliced Design: 10 domain entities instead of one shared pile of components.
  • Split server state from client state: TanStack Query for server data, Zustand for UI state.
  • Built a custom row-reordering hook on mouse events: a copy of the row follows the cursor while neighbours slide apart via CSS transforms written straight to the DOM, bypassing React re-renders.
  • Replaced copy-paste with shared components: a data table, React Hook Form based forms and one set of controls used across every module.
  • Test coverage on three levels so the migration would not break already working screens.

Contribution

1486 commits, top contributor

Duration

15 months of continuous work

Architecture

10 domain entities, Feature-Sliced Design

Reuse

One drag-and-drop hook powers 2 modules

Reordering schedule rows: neighbours slide apart via transform
// Transforms are written straight to the DOM: going through React would
// re-render every row on every crossed row, and a schedule row is an
// employee cell plus one cell per day of the month.
const applyShifts = (target: number) => {
  const pitch = pitchRef.current;
  const from = draggedRef.current;
  if (from === null || !pitch) return;

  rowElementsRef.current.forEach((elements, rowIndex) => {
    let shift = 0;
    if (rowIndex === from) shift = (target - from) * pitch;
    else if (rowIndex > from && rowIndex <= target) shift = -pitch;
    else if (rowIndex < from && rowIndex >= target) shift = pitch;

    elements.forEach((element) => {
      element.style.transform = shift ? `translateY(${shift}px)` : '';
    });
  });
};
Component preview
Расписание мастера
10:30Анна Петроваnew
12:00Мария Ивановаconfirmed