Compare commits
3 Commits
bec2580e2f
...
0beebb04c0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0beebb04c0 | ||
|
|
44d3b2cf26 | ||
|
|
21bb2bbea8 |
@@ -0,0 +1,230 @@
|
|||||||
|
import { act } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { Button } from '../ui/button'
|
||||||
|
import { ConfirmDialog } from './confirm-dialog'
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
DataTableBody,
|
||||||
|
DataTableCell,
|
||||||
|
DataTableHead,
|
||||||
|
DataTableHeader,
|
||||||
|
DataTableRow,
|
||||||
|
} from './data-table'
|
||||||
|
import { EmptyState } from './empty-state'
|
||||||
|
import { FilterBar } from './filter-bar'
|
||||||
|
import { PageHeader } from './page-header'
|
||||||
|
|
||||||
|
describe('admin UI compositions', () => {
|
||||||
|
let container: HTMLDivElement | null
|
||||||
|
let root: ReturnType<typeof createRoot> | null
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement('div')
|
||||||
|
document.body.appendChild(container)
|
||||||
|
root = createRoot(container)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (root) {
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
container?.remove()
|
||||||
|
container = null
|
||||||
|
root = null
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders page header identity, metadata, status, and actions in named regions', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<PageHeader
|
||||||
|
title="Users"
|
||||||
|
description="Manage platform operators."
|
||||||
|
meta={<span>24 active</span>}
|
||||||
|
status={<span>Synced</span>}
|
||||||
|
actions={<Button>Invite user</Button>}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const header = container?.querySelector('header')
|
||||||
|
const actions = container?.querySelector('[aria-label="Page actions"]')
|
||||||
|
|
||||||
|
expect(header?.querySelector('h1')?.textContent).toBe('Users')
|
||||||
|
expect(header?.textContent).toContain('Manage platform operators.')
|
||||||
|
expect(header?.textContent).toContain('24 active')
|
||||||
|
expect(header?.textContent).toContain('Synced')
|
||||||
|
expect(actions?.textContent).toBe('Invite user')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps filter controls grouped with a useful accessible name and optional summary', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<FilterBar title="User filters" summary="2 filters active">
|
||||||
|
<label htmlFor="search-users">Search</label>
|
||||||
|
<input id="search-users" defaultValue="admin" />
|
||||||
|
<Button variant="outline">Reset</Button>
|
||||||
|
</FilterBar>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const group = container?.querySelector('[role="search"][aria-label="User filters"]')
|
||||||
|
const input = container?.querySelector('#search-users') as HTMLInputElement
|
||||||
|
|
||||||
|
expect(group?.textContent).toContain('User filters')
|
||||||
|
expect(group?.textContent).toContain('2 filters active')
|
||||||
|
expect(group?.textContent).toContain('Reset')
|
||||||
|
expect(input.value).toBe('admin')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders empty states with perceivable icon, message, and recovery action', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<EmptyState
|
||||||
|
icon={<span aria-hidden="true">∅</span>}
|
||||||
|
title="No users found"
|
||||||
|
description="Adjust filters or invite a new operator."
|
||||||
|
action={<Button>Invite user</Button>}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const status = container?.querySelector('[role="status"]')
|
||||||
|
|
||||||
|
expect(status?.querySelector('[aria-hidden="true"]')?.textContent).toBe('∅')
|
||||||
|
expect(status?.querySelector('h2')?.textContent).toBe('No users found')
|
||||||
|
expect(status?.textContent).toContain('Adjust filters or invite a new operator.')
|
||||||
|
expect(status?.textContent).toContain('Invite user')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps data table helpers semantic and provides loading and empty regions', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<DataTable aria-label="Users table">
|
||||||
|
<DataTableHeader>
|
||||||
|
<DataTableRow>
|
||||||
|
<DataTableHead>Name</DataTableHead>
|
||||||
|
<DataTableHead>Status</DataTableHead>
|
||||||
|
</DataTableRow>
|
||||||
|
</DataTableHeader>
|
||||||
|
<DataTableBody>
|
||||||
|
<DataTableRow>
|
||||||
|
<DataTableCell>Ada Lovelace</DataTableCell>
|
||||||
|
<DataTableCell>Active</DataTableCell>
|
||||||
|
</DataTableRow>
|
||||||
|
</DataTableBody>
|
||||||
|
</DataTable>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const table = container?.querySelector('table[aria-label="Users table"]')
|
||||||
|
const headers = Array.from(container?.querySelectorAll('th') ?? [])
|
||||||
|
const cells = Array.from(container?.querySelectorAll('td') ?? [])
|
||||||
|
|
||||||
|
expect(table).not.toBeNull()
|
||||||
|
expect(headers.map((header) => header.textContent)).toEqual(['Name', 'Status'])
|
||||||
|
expect(cells.map((cell) => cell.textContent)).toEqual(['Ada Lovelace', 'Active'])
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(<DataTable aria-label="Users table" isLoading loadingLabel="Loading users" />)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(container?.querySelector('[role="status"]')?.textContent).toBe('Loading users')
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(<DataTable aria-label="Users table" emptyState={<EmptyState title="No rows" />} />)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(container?.textContent).toContain('No rows')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps narrow viewport header, filters, table, and actions in reachable named regions', async () => {
|
||||||
|
Object.defineProperty(window, 'innerWidth', { configurable: true, value: 375 })
|
||||||
|
window.dispatchEvent(new Event('resize'))
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="Users"
|
||||||
|
description="Manage platform operators from a phone."
|
||||||
|
actions={<Button>Invite user</Button>}
|
||||||
|
/>
|
||||||
|
<FilterBar title="User filters" summary="1 filter active">
|
||||||
|
<label htmlFor="narrow-search">Search users</label>
|
||||||
|
<input id="narrow-search" defaultValue="ada" />
|
||||||
|
</FilterBar>
|
||||||
|
<DataTable aria-label="Users table">
|
||||||
|
<DataTableHeader>
|
||||||
|
<DataTableRow>
|
||||||
|
<DataTableHead>Name</DataTableHead>
|
||||||
|
<DataTableHead>Action</DataTableHead>
|
||||||
|
</DataTableRow>
|
||||||
|
</DataTableHeader>
|
||||||
|
<DataTableBody>
|
||||||
|
<DataTableRow>
|
||||||
|
<DataTableCell>Ada Lovelace</DataTableCell>
|
||||||
|
<DataTableCell>
|
||||||
|
<Button>Reset password</Button>
|
||||||
|
</DataTableCell>
|
||||||
|
</DataTableRow>
|
||||||
|
</DataTableBody>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(container?.querySelector('header')?.textContent).toContain('Invite user')
|
||||||
|
expect(container?.querySelector('[role="search"][aria-label="User filters"]')?.textContent).toContain('Search users')
|
||||||
|
expect((container?.querySelector('#narrow-search') as HTMLInputElement).value).toBe('ada')
|
||||||
|
const tableScrollRegion = container?.querySelector('[role="region"][aria-label="Users table scroll area"]')
|
||||||
|
|
||||||
|
expect(tableScrollRegion).not.toBeNull()
|
||||||
|
expect(tableScrollRegion?.textContent).toContain('Reset password')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('confirms destructive actions through a controlled dialog and disables pending confirmation', async () => {
|
||||||
|
const onConfirm = vi.fn()
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<ConfirmDialog
|
||||||
|
open
|
||||||
|
onOpenChange={() => undefined}
|
||||||
|
title="Delete user"
|
||||||
|
description="This action cannot be undone."
|
||||||
|
confirmLabel="Delete"
|
||||||
|
variant="destructive"
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(document.body.querySelector('[role="alertdialog"]')?.textContent).toContain('Delete user')
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
;(document.body.querySelector('button[data-action="confirm"]') as HTMLButtonElement).click()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(onConfirm).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<ConfirmDialog
|
||||||
|
open
|
||||||
|
onOpenChange={() => undefined}
|
||||||
|
title="Delete user"
|
||||||
|
confirmLabel="Deleting"
|
||||||
|
isPending
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect((document.body.querySelector('button[data-action="confirm"]') as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { Button } from '../ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '../ui/dialog'
|
||||||
|
|
||||||
|
type ConfirmDialogProps = {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
title: React.ReactNode
|
||||||
|
description?: React.ReactNode
|
||||||
|
confirmLabel: React.ReactNode
|
||||||
|
cancelLabel?: React.ReactNode
|
||||||
|
variant?: 'default' | 'destructive'
|
||||||
|
isPending?: boolean
|
||||||
|
onConfirm: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function ConfirmDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
confirmLabel,
|
||||||
|
cancelLabel = 'Cancel',
|
||||||
|
variant = 'default',
|
||||||
|
isPending = false,
|
||||||
|
onConfirm,
|
||||||
|
}: ConfirmDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent role="alertdialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
{typeof description === 'string' ? (
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
) : description ? (
|
||||||
|
<DialogDescription asChild>
|
||||||
|
<div className="text-sm text-muted-foreground">{description}</div>
|
||||||
|
</DialogDescription>
|
||||||
|
) : null}
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button type="button" variant="outline" disabled={isPending}>
|
||||||
|
{cancelLabel}
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
data-action="confirm"
|
||||||
|
variant={variant === 'destructive' ? 'destructive' : 'default'}
|
||||||
|
disabled={isPending}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { ConfirmDialog }
|
||||||
|
export type { ConfirmDialogProps }
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils/cn'
|
||||||
|
|
||||||
|
type DataTableProps = React.ComponentProps<'table'> & {
|
||||||
|
isLoading?: boolean
|
||||||
|
loadingLabel?: string
|
||||||
|
emptyState?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataTable({ isLoading, loadingLabel = 'Loading', emptyState, className, children, ...props }: DataTableProps) {
|
||||||
|
const tableLabel = props['aria-label']
|
||||||
|
const scrollRegionLabel = typeof tableLabel === 'string' ? `${tableLabel} scroll area` : undefined
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div data-slot="data-table-loading" role="status" className="rounded-2xl border border-white/10 p-6 text-sm text-muted-foreground">
|
||||||
|
{loadingLabel}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emptyState) {
|
||||||
|
return <div data-slot="data-table-empty">{emptyState}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="data-table-wrapper"
|
||||||
|
role={scrollRegionLabel ? 'region' : undefined}
|
||||||
|
aria-label={scrollRegionLabel}
|
||||||
|
className="overflow-x-auto rounded-2xl border border-white/10 bg-card/80"
|
||||||
|
>
|
||||||
|
<table className={cn('w-full caption-bottom text-sm', className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataTableHeader({ className, ...props }: React.ComponentProps<'thead'>) {
|
||||||
|
return <thead className={cn('border-b border-white/10', className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataTableBody({ className, ...props }: React.ComponentProps<'tbody'>) {
|
||||||
|
return <tbody className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataTableRow({ className, ...props }: React.ComponentProps<'tr'>) {
|
||||||
|
return <tr className={cn('border-b border-white/10 transition-colors hover:bg-primary/5', className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataTableHead({ className, ...props }: React.ComponentProps<'th'>) {
|
||||||
|
return <th className={cn('h-10 px-4 text-left align-middle font-medium text-muted-foreground', className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataTableCell({ className, ...props }: React.ComponentProps<'td'>) {
|
||||||
|
return <td className={cn('px-4 py-3 align-middle text-foreground', className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { DataTable, DataTableBody, DataTableCell, DataTableHead, DataTableHeader, DataTableRow }
|
||||||
|
export type { DataTableProps }
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils/cn'
|
||||||
|
|
||||||
|
type EmptyStateProps = React.ComponentProps<'div'> & {
|
||||||
|
icon?: React.ReactNode
|
||||||
|
title: React.ReactNode
|
||||||
|
description?: React.ReactNode
|
||||||
|
action?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyState({ icon, title, description, action, className, ...props }: EmptyStateProps) {
|
||||||
|
const titleId = React.useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
data-slot="empty-state"
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-40 flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-white/10 bg-background/40 p-8 text-center',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{icon && <div className="text-primary/80">{icon}</div>}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h2 id={titleId} className="text-base font-semibold text-foreground">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
{description && <p className="max-w-md text-sm text-muted-foreground">{description}</p>}
|
||||||
|
</div>
|
||||||
|
{action && <div className="pt-2">{action}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { EmptyState }
|
||||||
|
export type { EmptyStateProps }
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils/cn'
|
||||||
|
|
||||||
|
type FilterBarProps = React.ComponentProps<'section'> & {
|
||||||
|
title: React.ReactNode
|
||||||
|
summary?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function FilterBar({ title, summary, className, children, ...props }: FilterBarProps) {
|
||||||
|
const titleText = typeof title === 'string' ? title : undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
role="search"
|
||||||
|
aria-label={props['aria-label'] ?? titleText}
|
||||||
|
data-slot="filter-bar"
|
||||||
|
className={cn(
|
||||||
|
'rounded-2xl border border-white/10 bg-card/80 p-4 shadow-xl shadow-black/10 backdrop-blur-sm',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h2 className="text-sm font-medium text-foreground">{title}</h2>
|
||||||
|
{summary && <p className="text-xs text-muted-foreground">{summary}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:items-end">{children}</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { FilterBar }
|
||||||
|
export type { FilterBarProps }
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils/cn'
|
||||||
|
|
||||||
|
type PageHeaderProps = React.ComponentProps<'header'> & {
|
||||||
|
title: React.ReactNode
|
||||||
|
description?: React.ReactNode
|
||||||
|
meta?: React.ReactNode
|
||||||
|
status?: React.ReactNode
|
||||||
|
actions?: React.ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
meta,
|
||||||
|
status,
|
||||||
|
actions,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: PageHeaderProps) {
|
||||||
|
const titleId = React.useId()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
data-slot="page-header"
|
||||||
|
className={cn('flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 space-y-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<h1 id={titleId} className="text-2xl font-semibold tracking-tight text-foreground">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
{status && <div data-slot="page-header-status">{status}</div>}
|
||||||
|
</div>
|
||||||
|
{description && <p className="max-w-3xl text-sm text-muted-foreground">{description}</p>}
|
||||||
|
{meta && <div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">{meta}</div>}
|
||||||
|
</div>
|
||||||
|
{actions && (
|
||||||
|
<div aria-label="Page actions" className="flex shrink-0 flex-wrap items-center gap-2">
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { PageHeader }
|
||||||
|
export type { PageHeaderProps }
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { act } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||||
|
import { Field } from './field'
|
||||||
|
import { IconButton } from './icon-button'
|
||||||
|
import { Input } from './input'
|
||||||
|
import { StatusBadge } from './status-badge'
|
||||||
|
|
||||||
|
describe('admin UI primitive foundations', () => {
|
||||||
|
let container: HTMLDivElement | null
|
||||||
|
let root: ReturnType<typeof createRoot> | null
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
container = document.createElement('div')
|
||||||
|
document.body.appendChild(container)
|
||||||
|
root = createRoot(container)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (root) {
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
container?.remove()
|
||||||
|
container = null
|
||||||
|
root = null
|
||||||
|
})
|
||||||
|
|
||||||
|
it('associates field labels, helper text, and errors with the input control', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<Field
|
||||||
|
label="Project name"
|
||||||
|
htmlFor="project-name"
|
||||||
|
description="Use the public project name."
|
||||||
|
error="Project name is required."
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<Input id="project-name" defaultValue="" />
|
||||||
|
</Field>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const input = container?.querySelector('input') as HTMLInputElement
|
||||||
|
const describedBy = input.getAttribute('aria-describedby')?.split(' ') ?? []
|
||||||
|
|
||||||
|
expect(container?.querySelector('label')?.getAttribute('for')).toBe('project-name')
|
||||||
|
expect(input.getAttribute('aria-invalid')).toBe('true')
|
||||||
|
expect(describedBy).toHaveLength(2)
|
||||||
|
expect(describedBy.map((id) => document.getElementById(id)?.textContent)).toEqual([
|
||||||
|
'Use the public project name.',
|
||||||
|
'Project name is required.',
|
||||||
|
])
|
||||||
|
expect(container?.textContent).toContain('Project name')
|
||||||
|
expect(container?.textContent).toContain('*')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves input semantics while rendering icon and action slots', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<Input
|
||||||
|
aria-label="Search wells"
|
||||||
|
defaultValue="Well A"
|
||||||
|
leftIcon={<span aria-hidden="true">⌕</span>}
|
||||||
|
rightSlot={<button type="button">Clear search</button>}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const input = container?.querySelector('input[aria-label="Search wells"]') as HTMLInputElement
|
||||||
|
const clearButton = container?.querySelector('button') as HTMLButtonElement
|
||||||
|
|
||||||
|
expect(input.value).toBe('Well A')
|
||||||
|
expect(input.disabled).toBe(false)
|
||||||
|
expect(container?.querySelector('[aria-hidden="true"]')?.textContent).toBe('⌕')
|
||||||
|
expect(clearButton.textContent).toBe('Clear search')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps icon-only actions keyboard-operable and accessible by name', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<IconButton aria-label="Reset filters" variant="outline">
|
||||||
|
<span aria-hidden="true">↺</span>
|
||||||
|
</IconButton>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const button = container?.querySelector('button[aria-label="Reset filters"]') as HTMLButtonElement
|
||||||
|
|
||||||
|
expect(button.type).toBe('button')
|
||||||
|
expect(button.disabled).toBe(false)
|
||||||
|
expect(button.textContent).toBe('↺')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps admin statuses to stable semantic labels and badge variants', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root?.render(
|
||||||
|
<div>
|
||||||
|
<StatusBadge status="success">Online</StatusBadge>
|
||||||
|
<StatusBadge status="warning">Pending</StatusBadge>
|
||||||
|
<StatusBadge status="destructive">Offline</StatusBadge>
|
||||||
|
<StatusBadge status="muted">Archived</StatusBadge>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const badges = Array.from(container?.querySelectorAll('[data-slot="status-badge"]') ?? [])
|
||||||
|
|
||||||
|
expect(badges.map((badge) => badge.getAttribute('aria-label'))).toEqual([
|
||||||
|
'Status: Online',
|
||||||
|
'Status: Pending',
|
||||||
|
'Status: Offline',
|
||||||
|
'Status: Archived',
|
||||||
|
])
|
||||||
|
expect(badges.map((badge) => badge.getAttribute('data-status'))).toEqual([
|
||||||
|
'success',
|
||||||
|
'warning',
|
||||||
|
'destructive',
|
||||||
|
'muted',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
72
services/frontend-admin/src/components/ui/field.tsx
Normal file
72
services/frontend-admin/src/components/ui/field.tsx
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils/cn'
|
||||||
|
|
||||||
|
type FieldContextValue = {
|
||||||
|
controlId?: string
|
||||||
|
descriptionId?: string
|
||||||
|
errorId?: string
|
||||||
|
invalid: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const FieldContext = React.createContext<FieldContextValue | null>(null)
|
||||||
|
|
||||||
|
type FieldProps = React.ComponentProps<'div'> & {
|
||||||
|
label: React.ReactNode
|
||||||
|
htmlFor?: string
|
||||||
|
description?: React.ReactNode
|
||||||
|
error?: React.ReactNode
|
||||||
|
required?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
htmlFor,
|
||||||
|
description,
|
||||||
|
error,
|
||||||
|
required = false,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: FieldProps) {
|
||||||
|
const generatedId = React.useId()
|
||||||
|
const controlId = htmlFor ?? generatedId
|
||||||
|
const descriptionId = description ? `${controlId}-description` : undefined
|
||||||
|
const errorId = error ? `${controlId}-error` : undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FieldContext.Provider
|
||||||
|
value={{
|
||||||
|
controlId,
|
||||||
|
descriptionId,
|
||||||
|
errorId,
|
||||||
|
invalid: Boolean(error),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div data-slot="field" className={cn('grid gap-2', className)} {...props}>
|
||||||
|
<label htmlFor={controlId} className="text-sm font-medium text-foreground">
|
||||||
|
{label}
|
||||||
|
{required && <span aria-hidden="true" className="ml-1 text-primary">*</span>}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
{description && (
|
||||||
|
<p id={descriptionId} className="text-xs text-muted-foreground">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p id={errorId} className="text-xs font-medium text-destructive" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</FieldContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useFieldContext() {
|
||||||
|
return React.useContext(FieldContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Field, useFieldContext }
|
||||||
|
export type { FieldProps }
|
||||||
15
services/frontend-admin/src/components/ui/icon-button.tsx
Normal file
15
services/frontend-admin/src/components/ui/icon-button.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { Button } from './button'
|
||||||
|
|
||||||
|
type IconButtonProps = Omit<React.ComponentProps<typeof Button>, 'size' | 'aria-label'> & {
|
||||||
|
'aria-label': string
|
||||||
|
size?: 'icon' | 'icon-xs' | 'icon-sm' | 'icon-lg'
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconButton({ size = 'icon-sm', type = 'button', ...props }: IconButtonProps) {
|
||||||
|
return <Button data-slot="icon-button" size={size} type={type} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { IconButton }
|
||||||
|
export type { IconButtonProps }
|
||||||
47
services/frontend-admin/src/components/ui/input.tsx
Normal file
47
services/frontend-admin/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils/cn'
|
||||||
|
import { useFieldContext } from './field'
|
||||||
|
|
||||||
|
type InputProps = React.ComponentProps<'input'> & {
|
||||||
|
leftIcon?: React.ReactNode
|
||||||
|
rightSlot?: React.ReactNode
|
||||||
|
invalid?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, leftIcon, rightSlot, invalid, id, 'aria-describedby': ariaDescribedBy, ...props }, ref) => {
|
||||||
|
const field = useFieldContext()
|
||||||
|
const controlId = id ?? field?.controlId
|
||||||
|
const describedBy = [ariaDescribedBy, field?.descriptionId, field?.errorId]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ') || undefined
|
||||||
|
const isInvalid = invalid ?? field?.invalid ?? false
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-wrapper"
|
||||||
|
data-invalid={isInvalid || undefined}
|
||||||
|
className={cn(
|
||||||
|
'flex min-h-9 w-full items-center gap-2 rounded-xl border border-primary/20 bg-background/75 px-3 text-sm text-foreground shadow-sm shadow-orange-500/10 transition-colors focus-within:border-primary focus-within:ring-2 focus-within:ring-primary/30 has-disabled:cursor-not-allowed has-disabled:opacity-50 has-aria-invalid:border-destructive has-aria-invalid:ring-destructive/20',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{leftIcon && <span className="flex shrink-0 text-primary/80">{leftIcon}</span>}
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
id={controlId}
|
||||||
|
aria-describedby={describedBy}
|
||||||
|
aria-invalid={isInvalid || undefined}
|
||||||
|
className="min-w-0 flex-1 bg-transparent py-2 outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{rightSlot && <span className="flex shrink-0 items-center">{rightSlot}</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = 'Input'
|
||||||
|
|
||||||
|
export { Input }
|
||||||
|
export type { InputProps }
|
||||||
33
services/frontend-admin/src/components/ui/status-badge.tsx
Normal file
33
services/frontend-admin/src/components/ui/status-badge.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
|
||||||
|
import { Badge } from './badge'
|
||||||
|
|
||||||
|
type StatusBadgeStatus = 'success' | 'warning' | 'destructive' | 'muted'
|
||||||
|
|
||||||
|
type StatusBadgeProps = Omit<React.ComponentProps<typeof Badge>, 'variant'> & {
|
||||||
|
status: StatusBadgeStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusVariantByStatus: Record<StatusBadgeStatus, React.ComponentProps<typeof Badge>['variant']> = {
|
||||||
|
success: 'default',
|
||||||
|
warning: 'secondary',
|
||||||
|
destructive: 'destructive',
|
||||||
|
muted: 'outline',
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusBadge({ status, children, 'aria-label': ariaLabel, ...props }: StatusBadgeProps) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
data-slot="status-badge"
|
||||||
|
data-status={status}
|
||||||
|
variant={statusVariantByStatus[status]}
|
||||||
|
aria-label={ariaLabel ?? `Status: ${children}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { StatusBadge }
|
||||||
|
export type { StatusBadgeProps, StatusBadgeStatus }
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { act } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import UsersPage from './UsersPage'
|
||||||
|
import { getRoles, getUsers, resetPassword } from '../api/users-api'
|
||||||
|
|
||||||
|
vi.mock('../api/users-api', () => ({
|
||||||
|
getUsers: vi.fn(),
|
||||||
|
getRoles: vi.fn(),
|
||||||
|
updateUser: vi.fn(),
|
||||||
|
resetPassword: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../components/UserFormModal', () => ({
|
||||||
|
default: ({ onClose }: { onClose: () => void }) => (
|
||||||
|
<div role="dialog" aria-label="Formulario de usuario">
|
||||||
|
<button type="button" onClick={onClose}>Cerrar</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const users = [
|
||||||
|
{
|
||||||
|
id: 'user-1',
|
||||||
|
email: 'ada@example.com',
|
||||||
|
full_name: 'Ada Lovelace',
|
||||||
|
role_name: 'admin',
|
||||||
|
is_active: true,
|
||||||
|
project_count: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'user-2',
|
||||||
|
email: 'grace@example.com',
|
||||||
|
full_name: 'Grace Hopper',
|
||||||
|
role_name: 'operator',
|
||||||
|
is_active: false,
|
||||||
|
project_count: 1,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const roles = [
|
||||||
|
{ id: 1, name: 'admin', description: 'Administradores' },
|
||||||
|
{ id: 2, name: 'operator', description: 'Operadores' },
|
||||||
|
]
|
||||||
|
|
||||||
|
async function flushEffects() {
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve()
|
||||||
|
await Promise.resolve()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('UsersPage admin UI migration', () => {
|
||||||
|
let container: HTMLDivElement
|
||||||
|
let root: ReturnType<typeof createRoot>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(getUsers).mockResolvedValue(users)
|
||||||
|
vi.mocked(getRoles).mockResolvedValue(roles)
|
||||||
|
vi.mocked(resetPassword).mockResolvedValue(undefined)
|
||||||
|
vi.spyOn(window, 'alert').mockImplementation(() => undefined)
|
||||||
|
|
||||||
|
container = document.createElement('div')
|
||||||
|
document.body.appendChild(container)
|
||||||
|
root = createRoot(container)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount()
|
||||||
|
})
|
||||||
|
container.remove()
|
||||||
|
document.body.innerHTML = ''
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders users with shared page header, filters, status badges, and accessible row actions', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<UsersPage />)
|
||||||
|
})
|
||||||
|
await flushEffects()
|
||||||
|
|
||||||
|
expect(container.querySelector('header')?.textContent).toContain('Usuarios')
|
||||||
|
expect(container.querySelector('[role="search"]')?.textContent).toContain('Filtros de usuarios')
|
||||||
|
expect(container.querySelector('table[aria-label="Usuarios del sistema"]')).not.toBeNull()
|
||||||
|
expect(Array.from(container.querySelectorAll('th')).map((header) => header.textContent)).toEqual([
|
||||||
|
'Nombre',
|
||||||
|
'Email',
|
||||||
|
'Rol',
|
||||||
|
'Estado',
|
||||||
|
'Proyectos',
|
||||||
|
'Acciones',
|
||||||
|
])
|
||||||
|
expect(container.textContent).toContain('Ada Lovelace')
|
||||||
|
expect(container.textContent).toContain('Grace Hopper')
|
||||||
|
expect(Array.from(container.querySelectorAll('[data-slot="status-badge"]')).map((badge) => badge.getAttribute('aria-label'))).toEqual([
|
||||||
|
'Status: Activo',
|
||||||
|
'Status: Inactivo',
|
||||||
|
])
|
||||||
|
expect(container.querySelector('button[aria-label="Restablecer contrasena de Ada Lovelace"]')).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('filters visible table rows from the shared search input without refetching users', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<UsersPage />)
|
||||||
|
})
|
||||||
|
await flushEffects()
|
||||||
|
|
||||||
|
const searchInput = container.querySelector('input[aria-label="Buscar usuarios"]') as HTMLInputElement
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
searchInput.value = 'grace'
|
||||||
|
searchInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(container.textContent).not.toContain('Ada Lovelace')
|
||||||
|
expect(container.textContent).toContain('Grace Hopper')
|
||||||
|
expect(getUsers).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('opens the migrated reset password confirmation and submits the new password when valid', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
root.render(<UsersPage />)
|
||||||
|
})
|
||||||
|
await flushEffects()
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
;(container.querySelector('button[aria-label="Restablecer contrasena de Ada Lovelace"]') as HTMLButtonElement).click()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(document.body.querySelector('[role="alertdialog"]')?.textContent).toContain('Restablecer Contrasena')
|
||||||
|
|
||||||
|
const passwordInput = document.body.querySelector('input[aria-label="Nueva contrasena"]') as HTMLInputElement
|
||||||
|
await act(async () => {
|
||||||
|
passwordInput.value = 'nueva123'
|
||||||
|
passwordInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||||
|
})
|
||||||
|
await act(async () => {
|
||||||
|
;(document.body.querySelector('button[data-action="confirm"]') as HTMLButtonElement).click()
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(resetPassword).toHaveBeenCalledWith('user-1', { new_password: 'nueva123' })
|
||||||
|
expect(window.alert).toHaveBeenCalledWith('Contrasena restablecida exitosamente')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,9 +1,24 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Plus, Loader2, Search, KeyRound } from 'lucide-react'
|
import { KeyRound, Loader2, Plus, Search } from 'lucide-react'
|
||||||
|
import { ConfirmDialog } from '@/components/admin-ui/confirm-dialog'
|
||||||
|
import {
|
||||||
|
DataTable,
|
||||||
|
DataTableBody,
|
||||||
|
DataTableCell,
|
||||||
|
DataTableHead,
|
||||||
|
DataTableHeader,
|
||||||
|
DataTableRow,
|
||||||
|
} from '@/components/admin-ui/data-table'
|
||||||
|
import { EmptyState } from '@/components/admin-ui/empty-state'
|
||||||
|
import { FilterBar } from '@/components/admin-ui/filter-bar'
|
||||||
|
import { PageHeader } from '@/components/admin-ui/page-header'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card, CardContent } from '@/components/ui/card'
|
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Field } from '@/components/ui/field'
|
||||||
|
import { IconButton } from '@/components/ui/icon-button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
import { Select } from '@/components/ui/select'
|
import { Select } from '@/components/ui/select'
|
||||||
|
import { StatusBadge } from '@/components/ui/status-badge'
|
||||||
import {
|
import {
|
||||||
getUsers,
|
getUsers,
|
||||||
getRoles,
|
getRoles,
|
||||||
@@ -91,32 +106,28 @@ export default function UsersPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
<PageHeader
|
||||||
<div className="flex items-center justify-between">
|
title="Usuarios"
|
||||||
<div>
|
description="Gestiona los usuarios del sistema y su acceso a proyectos"
|
||||||
<h1 className="text-2xl font-bold text-foreground">Usuarios</h1>
|
meta={`${filteredUsers.length} usuarios visibles`}
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
actions={(
|
||||||
Gestiona los usuarios del sistema y su acceso a proyectos
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={() => { setEditUserId(null); setModalOpen(true) }} className="gap-2">
|
<Button onClick={() => { setEditUserId(null); setModalOpen(true) }} className="gap-2">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
Nuevo Usuario
|
Nuevo Usuario
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Filters */}
|
<FilterBar title="Filtros de usuarios" summary={`${filteredUsers.length} resultados`} className="relative z-40 overflow-visible">
|
||||||
<Card className="glass-card relative z-40 overflow-visible border-border/40">
|
<div className="min-w-[200px] flex-1">
|
||||||
<CardContent className="p-4">
|
<Input
|
||||||
<div className="flex flex-wrap gap-4 items-center">
|
aria-label="Buscar usuarios"
|
||||||
<div className="relative flex-1 min-w-[200px]">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<input
|
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Buscar por nombre o email..."
|
placeholder="Buscar por nombre o email..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="w-full pl-10 pr-4 py-2 rounded-lg bg-background/60 border border-border/50 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
|
onInput={(e) => setSearch(e.currentTarget.value)}
|
||||||
|
leftIcon={<Search className="h-4 w-4" />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<Select
|
||||||
@@ -135,59 +146,39 @@ export default function UsersPage() {
|
|||||||
options={activeOptions}
|
options={activeOptions}
|
||||||
triggerClassName="rounded-lg border-primary/30 bg-card/80"
|
triggerClassName="rounded-lg border-primary/30 bg-card/80"
|
||||||
/>
|
/>
|
||||||
</div>
|
</FilterBar>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Table */}
|
<DataTable
|
||||||
{loading ? (
|
aria-label="Usuarios del sistema"
|
||||||
<div className="flex items-center justify-center h-64">
|
isLoading={loading}
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
loadingLabel="Cargando usuarios"
|
||||||
</div>
|
emptyState={filteredUsers.length === 0 ? <EmptyState title="No se encontraron usuarios" /> : undefined}
|
||||||
) : (
|
|
||||||
<Card className="glass-card relative z-0 border-border/40 overflow-hidden">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-border/40">
|
|
||||||
<th className="text-left px-4 py-3 text-muted-foreground font-medium">Nombre</th>
|
|
||||||
<th className="text-left px-4 py-3 text-muted-foreground font-medium">Email</th>
|
|
||||||
<th className="text-left px-4 py-3 text-muted-foreground font-medium">Rol</th>
|
|
||||||
<th className="text-left px-4 py-3 text-muted-foreground font-medium">Estado</th>
|
|
||||||
<th className="text-left px-4 py-3 text-muted-foreground font-medium">Proyectos</th>
|
|
||||||
<th className="text-right px-4 py-3 text-muted-foreground font-medium">Acciones</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{filteredUsers.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={6} className="text-center py-12 text-muted-foreground">
|
|
||||||
No se encontraron usuarios
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
filteredUsers.map((user) => (
|
|
||||||
<tr key={user.id} className="border-b border-border/20 hover:bg-secondary/20 transition-colors">
|
|
||||||
<td className="px-4 py-3 font-medium text-foreground">{user.full_name}</td>
|
|
||||||
<td className="px-4 py-3 text-muted-foreground">{user.email}</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<Badge variant="outline" className="capitalize">
|
|
||||||
{user.role_name}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3">
|
|
||||||
<Badge
|
|
||||||
className={
|
|
||||||
user.is_active
|
|
||||||
? 'bg-success/10 text-success border-0'
|
|
||||||
: 'bg-destructive/10 text-destructive border-0'
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
|
<DataTableHeader>
|
||||||
|
<DataTableRow>
|
||||||
|
<DataTableHead>Nombre</DataTableHead>
|
||||||
|
<DataTableHead>Email</DataTableHead>
|
||||||
|
<DataTableHead>Rol</DataTableHead>
|
||||||
|
<DataTableHead>Estado</DataTableHead>
|
||||||
|
<DataTableHead>Proyectos</DataTableHead>
|
||||||
|
<DataTableHead className="text-right">Acciones</DataTableHead>
|
||||||
|
</DataTableRow>
|
||||||
|
</DataTableHeader>
|
||||||
|
<DataTableBody>
|
||||||
|
{filteredUsers.map((user) => (
|
||||||
|
<DataTableRow key={user.id}>
|
||||||
|
<DataTableCell className="font-medium">{user.full_name}</DataTableCell>
|
||||||
|
<DataTableCell className="text-muted-foreground">{user.email}</DataTableCell>
|
||||||
|
<DataTableCell>
|
||||||
|
<Badge variant="outline" className="capitalize">{user.role_name}</Badge>
|
||||||
|
</DataTableCell>
|
||||||
|
<DataTableCell>
|
||||||
|
<StatusBadge status={user.is_active ? 'success' : 'destructive'}>
|
||||||
{user.is_active ? 'Activo' : 'Inactivo'}
|
{user.is_active ? 'Activo' : 'Inactivo'}
|
||||||
</Badge>
|
</StatusBadge>
|
||||||
</td>
|
</DataTableCell>
|
||||||
<td className="px-4 py-3 text-muted-foreground">{user.project_count}</td>
|
<DataTableCell className="text-muted-foreground">{user.project_count}</DataTableCell>
|
||||||
<td className="px-4 py-3 text-right">
|
<DataTableCell>
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -196,14 +187,13 @@ export default function UsersPage() {
|
|||||||
>
|
>
|
||||||
Editar
|
Editar
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<IconButton
|
||||||
|
aria-label={`Restablecer contrasena de ${user.full_name}`}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
|
||||||
onClick={() => setResetPwUserId(user.id)}
|
onClick={() => setResetPwUserId(user.id)}
|
||||||
title="Restablecer contrasena"
|
|
||||||
>
|
>
|
||||||
<KeyRound className="h-4 w-4" />
|
<KeyRound className="h-4 w-4" />
|
||||||
</Button>
|
</IconButton>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -213,42 +203,38 @@ export default function UsersPage() {
|
|||||||
{user.is_active ? 'Desactivar' : 'Activar'}
|
{user.is_active ? 'Desactivar' : 'Activar'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</DataTableCell>
|
||||||
</tr>
|
</DataTableRow>
|
||||||
))
|
))}
|
||||||
)}
|
</DataTableBody>
|
||||||
</tbody>
|
</DataTable>
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Reset Password Dialog */}
|
<ConfirmDialog
|
||||||
{resetPwUserId && (
|
open={Boolean(resetPwUserId)}
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
onOpenChange={(open) => {
|
||||||
<Card className="w-full max-w-md glass-card border-border/40">
|
if (!open) {
|
||||||
<CardContent className="p-6 space-y-4">
|
setResetPwUserId(null)
|
||||||
<h3 className="text-lg font-semibold text-foreground">Restablecer Contrasena</h3>
|
setNewPassword('')
|
||||||
<input
|
}
|
||||||
|
}}
|
||||||
|
title="Restablecer Contrasena"
|
||||||
|
description={(
|
||||||
|
<Field label="Nueva contrasena" description="Mínimo 6 caracteres">
|
||||||
|
<Input
|
||||||
|
aria-label="Nueva contrasena"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="Nueva contrasena (min. 6 caracteres)"
|
placeholder="Nueva contrasena (min. 6 caracteres)"
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChange={(e) => setNewPassword(e.target.value)}
|
onChange={(e) => setNewPassword(e.target.value)}
|
||||||
className="w-full px-3 py-2 rounded-lg bg-background/60 border border-border/50 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
|
onInput={(e) => setNewPassword(e.currentTarget.value)}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-end gap-2">
|
</Field>
|
||||||
<Button variant="ghost" onClick={() => { setResetPwUserId(null); setNewPassword('') }}>
|
|
||||||
Cancelar
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleResetPassword} disabled={newPassword.length < 6 || resetLoading}>
|
|
||||||
{resetLoading ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
|
|
||||||
Restablecer
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
cancelLabel="Cancelar"
|
||||||
|
confirmLabel={resetLoading ? <><Loader2 className="h-4 w-4 animate-spin mr-2" />Restablecer</> : 'Restablecer'}
|
||||||
|
isPending={newPassword.length < 6 || resetLoading}
|
||||||
|
onConfirm={handleResetPassword}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Create/Edit Modal */}
|
{/* Create/Edit Modal */}
|
||||||
{modalOpen && (
|
{modalOpen && (
|
||||||
|
|||||||
Reference in New Issue
Block a user