diff --git a/frontend/src/components/dashboard/AlertsPanel.module.css b/frontend/src/components/dashboard/AlertsPanel.module.css index 46a00803..2f5df1cd 100644 --- a/frontend/src/components/dashboard/AlertsPanel.module.css +++ b/frontend/src/components/dashboard/AlertsPanel.module.css @@ -1,27 +1,11 @@ -.card { - position: relative; - background: var(--card-bg); - border: 1px solid var(--border); - border-radius: var(--radius-lg); - padding: 16px 20px 14px; - overflow: hidden; -} - -.card::before { - content: ''; - position: absolute; - inset: 0 0 auto 0; - height: 1px; - background: linear-gradient(90deg, transparent, rgba(var(--accRgb), 0.35), transparent); - opacity: 0.6; -} - -.header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 10px; -} +/* Alert-specific styling. + * + * The outer container (background, border, radius, padding) is now + * provided by the design-system primitive. This module owns + * only what's *unique* to the alerts panel: the eyebrow title style, + * the count badge, the row grid, the severity tints, and the + * "show all" footer button. + */ .title { font-family: var(--fm); @@ -30,6 +14,7 @@ letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-secondary); + margin: 0; } .totalCount { diff --git a/frontend/src/components/dashboard/AlertsPanel.tsx b/frontend/src/components/dashboard/AlertsPanel.tsx index 0d9fd53d..9b05454b 100644 --- a/frontend/src/components/dashboard/AlertsPanel.tsx +++ b/frontend/src/components/dashboard/AlertsPanel.tsx @@ -4,6 +4,7 @@ import { AlertTriangle, Clock, PackageMinus, Wrench, CheckCircle2, ChevronRight, } from 'lucide-react'; import { useTranslation } from '../../i18n'; +import { Card, Cluster } from '../../design-system'; import s from './AlertsPanel.module.css'; type Severity = 'critical' | 'warning' | 'info'; @@ -26,6 +27,17 @@ interface Props { const SEV_RANK: Record = { critical: 0, warning: 1, info: 2 }; +/** + * Compact severity-tinted alerts panel for the dashboard. + * + * Phase 1e: outer container migrated from a local `s.card` rule to the + * design-system `` primitive. Header layout uses ``. The + * row grid, severity tints, count badge and "show all" footer button + * are alert-specific affordances and remain in the local CSS module. + * + * Public props, alert ordering, navigation routes and accessibility + * contract are preserved verbatim from the legacy implementation. + */ export default function AlertsPanel({ underRepairMachines, pendingOperations, @@ -95,11 +107,12 @@ export default function AlertsPanel({ const hasMore = rows.length > 5 && !expanded; return ( -
-
- {dash.needsAttention} + + +

{dash.needsAttention}

{rows.length} -
+ +
    {visible.map((row, i) => (
  • @@ -117,11 +130,16 @@ export default function AlertsPanel({
  • ))}
+ {hasMore && ( - )} -
+
); } diff --git a/frontend/src/components/dashboard/__tests__/AlertsPanel.test.tsx b/frontend/src/components/dashboard/__tests__/AlertsPanel.test.tsx new file mode 100644 index 00000000..e520b326 --- /dev/null +++ b/frontend/src/components/dashboard/__tests__/AlertsPanel.test.tsx @@ -0,0 +1,176 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, within } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import AlertsPanel from '../AlertsPanel'; + +/* ── Mocks ─────────────────────────────────────────────────────────── */ + +const navigateMock = vi.fn(); +vi.mock('react-router-dom', async () => { + const actual = + await vi.importActual('react-router-dom'); + return { + ...actual, + useNavigate: () => navigateMock, + }; +}); + +vi.mock('../../../i18n', () => ({ + useTranslation: () => ({ + t: { + dashboard: new Proxy( + {} as Record, + { get: (_t, key: string) => `dash.${key}` }, + ), + operationTypes: {}, + }, + lang: 'en', + setLang: vi.fn(), + }), +})); + +/* ── Helpers ───────────────────────────────────────────────────────── */ + +const renderPanel = ( + props: Partial> = {}, +) => + render( + + + , + ); + +beforeEach(() => navigateMock.mockReset()); + +/* ── Tests ─────────────────────────────────────────────────────────── */ + +describe('AlertsPanel — empty state', () => { + it('renders nothing when no alerts apply (all counts are 0)', () => { + const { container } = renderPanel(); + expect(container.firstChild).toBeNull(); + }); +}); + +describe('AlertsPanel — normal alerts', () => { + it('renders one row per non-zero count, in severity order', () => { + renderPanel({ + underRepairMachines: 2, + pendingOperations: 5, + overdueOperations: 1, + lowStockItems: 3, + completedToday: 7, + }); + const items = screen.getAllByRole('listitem'); + expect(items).toHaveLength(5); + + // Critical first (overdueOps, machinesUnderRepair), then warnings, + // then info. + const orderedTitles = items.map((li) => + within(li).getByRole('button').getAttribute('aria-label'), + ); + expect(orderedTitles[0]).toMatch(/dash\.overdueOps: 1/); + expect(orderedTitles[1]).toMatch(/dash\.machinesUnderRepair: 2/); + expect(orderedTitles[2]).toMatch(/dash\.pendingOpsAlert: 5/); + expect(orderedTitles[3]).toMatch(/dash\.lowStockAlert: 3/); + expect(orderedTitles[4]).toMatch(/dash\.completedToday: 7/); + }); + + it('renders the panel as a
with an aria-label', () => { + const { container } = renderPanel({ pendingOperations: 1 }); + const section = container.querySelector('section'); + expect(section).not.toBeNull(); + expect(section).toHaveAttribute('aria-label', 'dash.needsAttention'); + }); + + it('renders a semantic

for the panel title', () => { + renderPanel({ pendingOperations: 1 }); + expect( + screen.getByRole('heading', { level: 2, name: 'dash.needsAttention' }), + ).toBeInTheDocument(); + }); + + it('shows the total count badge equal to the number of rows', () => { + renderPanel({ pendingOperations: 1, lowStockItems: 1, completedToday: 1 }); + const items = screen.getAllByRole('listitem'); + expect(items).toHaveLength(3); + // Total count badge sits next to the title and matches `items.length`. + const badges = screen.getAllByText('3'); + expect(badges.length).toBeGreaterThan(0); + }); +}); + +describe('AlertsPanel — status (severity) rendering', () => { + it('tags each row with a severity-specific class for the tint', () => { + renderPanel({ + overdueOperations: 1, // critical + pendingOperations: 1, // warning + completedToday: 1, // info + }); + const buttons = screen.getAllByRole('button'); + const criticalBtn = buttons.find((b) => + b.getAttribute('aria-label')?.startsWith('dash.overdueOps'), + ); + const warningBtn = buttons.find((b) => + b.getAttribute('aria-label')?.startsWith('dash.pendingOpsAlert'), + ); + const infoBtn = buttons.find((b) => + b.getAttribute('aria-label')?.startsWith('dash.completedToday'), + ); + expect(criticalBtn?.className).toMatch(/critical/); + expect(warningBtn?.className).toMatch(/warning/); + expect(infoBtn?.className).toMatch(/info/); + }); +}); + +describe('AlertsPanel — actions', () => { + it('navigates to the row’s route when the row button is clicked', () => { + renderPanel({ underRepairMachines: 1, pendingOperations: 1 }); + const repairBtn = screen.getByRole('button', { + name: /dash\.machinesUnderRepair/, + }); + fireEvent.click(repairBtn); + expect(navigateMock).toHaveBeenCalledWith('/machinery'); + + const pendingBtn = screen.getByRole('button', { + name: /dash\.pendingOpsAlert/, + }); + fireEvent.click(pendingBtn); + expect(navigateMock).toHaveBeenCalledWith('/operations'); + }); + + it('hides rows beyond 5 by default and shows a "show all" footer', () => { + // Pad with non-zero counts so we get 5 rows. To exceed 5 we’d need + // a sixth alert source — the current API exposes 5 — so the footer + // is only shown when the fixture intentionally crosses the limit. + // For the sake of this assertion we render exactly 5 rows and then + // assert the footer is NOT shown (boundary case). + renderPanel({ + underRepairMachines: 1, + pendingOperations: 1, + overdueOperations: 1, + lowStockItems: 1, + completedToday: 1, + }); + expect(screen.queryByText(/dash\.showAll|Показати всі/i)).not.toBeInTheDocument(); + expect(screen.getAllByRole('listitem')).toHaveLength(5); + }); +}); + +describe('AlertsPanel — Card adoption', () => { + it('the outer container is the design-system Card (data-variant="subtle", radius=lg)', () => { + const { container } = renderPanel({ pendingOperations: 1 }); + const section = container.querySelector('section'); + expect(section).toHaveAttribute('data-variant', 'subtle'); + expect(section).toHaveAttribute('data-bordered', 'true'); + const inline = section?.getAttribute('style') ?? ''; + expect(inline).toContain('var(--card-bg)'); + expect(inline).toContain('var(--radius-lg)'); + }); +});