Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 9 additions & 24 deletions frontend/src/components/dashboard/AlertsPanel.module.css
Original file line number Diff line number Diff line change
@@ -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 <Card> 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);
Expand All @@ -30,6 +14,7 @@
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-secondary);
margin: 0;
}

.totalCount {
Expand Down
30 changes: 24 additions & 6 deletions frontend/src/components/dashboard/AlertsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -26,6 +27,17 @@ interface Props {

const SEV_RANK: Record<Severity, number> = { 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 `<Card>` primitive. Header layout uses `<Cluster>`. 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,
Expand Down Expand Up @@ -95,11 +107,12 @@ export default function AlertsPanel({
const hasMore = rows.length > 5 && !expanded;

return (
<section className={s.card} aria-label={dash.needsAttention}>
<header className={s.header}>
<span className={s.title}>{dash.needsAttention}</span>
<Card as="section" radius="lg" aria-label={dash.needsAttention}>
<Cluster as="header" justify="between" align="center" gap="2">
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep alerts header on a single row

Switching the header to Cluster changed its flex behavior from non-wrapping to wrapping by default, so on narrow layouts or longer localized needsAttention strings the count badge can drop to a second line. The previous header styling kept title and badge in one horizontal row, and this panel’s anatomy depends on that for consistent scanability. Set nowrap on this Cluster (or otherwise disable wrapping) to preserve the prior behavior.

Useful? React with 👍 / 👎.

<h2 className={s.title}>{dash.needsAttention}</h2>
<span className={s.totalCount}>{rows.length}</span>
</header>
</Cluster>

<ul className={s.list}>
{visible.map((row, i) => (
<li key={i} className={s.listItem}>
Expand All @@ -117,11 +130,16 @@ export default function AlertsPanel({
</li>
))}
</ul>

{hasMore && (
<button type="button" className={s.showAll} onClick={() => setExpanded(true)}>
<button
type="button"
className={s.showAll}
onClick={() => setExpanded(true)}
>
{dash.showAll ?? 'Показати всі'} ({rows.length})
</button>
)}
</section>
</Card>
);
}
176 changes: 176 additions & 0 deletions frontend/src/components/dashboard/__tests__/AlertsPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof import('react-router-dom')>('react-router-dom');
return {
...actual,
useNavigate: () => navigateMock,
};
});

vi.mock('../../../i18n', () => ({
useTranslation: () => ({
t: {
dashboard: new Proxy(
{} as Record<string, string>,
{ get: (_t, key: string) => `dash.${key}` },
),
operationTypes: {},
},
lang: 'en',
setLang: vi.fn(),
}),
}));

/* ── Helpers ───────────────────────────────────────────────────────── */

const renderPanel = (
props: Partial<React.ComponentProps<typeof AlertsPanel>> = {},
) =>
render(
<MemoryRouter>
<AlertsPanel
underRepairMachines={0}
pendingOperations={0}
overdueOperations={0}
lowStockItems={0}
completedToday={0}
{...props}
/>
</MemoryRouter>,
);

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 <section> 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 <h2> 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)');
});
});
Loading