fix(erp): enable erp pages and menu items

This commit is contained in:
Erik Silva
2025-12-29 17:23:59 -03:00
parent e124a64a5d
commit adbff9bb1e
13990 changed files with 1110936 additions and 59 deletions

View File

@@ -0,0 +1,29 @@
'use client';
import React from 'react';
interface BadgeProps {
children: React.ReactNode;
variant?: 'default' | 'success' | 'warning' | 'error' | 'info';
className?: string;
}
export default function Badge({ children, variant = 'default', className = '' }: BadgeProps) {
const variants = {
default: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400',
success: 'bg-emerald-50 text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400',
warning: 'bg-amber-50 text-amber-600 dark:bg-amber-500/10 dark:text-amber-400',
error: 'bg-rose-50 text-rose-600 dark:bg-rose-500/10 dark:text-rose-400',
info: 'bg-brand-50 text-brand-600 dark:bg-brand-500/10 dark:text-brand-400',
};
return (
<span className={`
px-2.5 py-0.5 rounded-full text-[10px] font-black uppercase tracking-widest inline-flex items-center
${variants[variant]}
${className}
`}>
{children}
</span>
);
}

View File

@@ -0,0 +1,64 @@
import React from 'react';
import { XMarkIcon } from '@heroicons/react/24/outline';
export interface BulkAction {
label: string;
icon?: React.ReactNode;
onClick: () => void;
variant?: 'danger' | 'primary' | 'secondary';
}
interface BulkActionBarProps {
selectedCount: number;
actions: BulkAction[];
onClearSelection: () => void;
}
/**
* BulkActionBar Component
* A floating bar that appears when items are selected in a list/table.
* Supports light/dark modes and custom actions.
*/
export default function BulkActionBar({ selectedCount, actions, onClearSelection }: BulkActionBarProps) {
if (selectedCount === 0) return null;
return (
<div className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 animate-in slide-in-from-bottom-10 duration-300">
<div className="bg-white dark:bg-black border border-zinc-200 dark:border-zinc-900 text-zinc-900 dark:text-white rounded-[32px] px-8 py-4 shadow-[0_20px_50px_rgba(0,0,0,0.1)] dark:shadow-[0_20px_50px_rgba(0,0,0,0.5)] flex items-center gap-8 backdrop-blur-xl">
<div className="flex items-center gap-3 pr-8 border-r border-zinc-200 dark:border-zinc-900">
<span className="flex items-center justify-center w-8 h-8 bg-brand-500 rounded-full text-xs font-black text-white">
{selectedCount}
</span>
<span className="text-sm font-bold text-zinc-500 dark:text-zinc-400">Selecionados</span>
</div>
<div className="flex items-center gap-4">
{actions.map((action, idx) => (
<button
key={idx}
onClick={action.onClick}
className={`flex items-center gap-2 px-4 py-2 rounded-xl transition-all text-sm font-bold
${action.variant === 'danger'
? 'text-rose-500 hover:bg-rose-500/10'
: action.variant === 'primary'
? 'text-brand-600 dark:text-brand-400 hover:bg-brand-500/10'
: 'text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800'
}`}
>
{action.icon}
{action.label}
</button>
))}
</div>
<button
onClick={onClearSelection}
className="ml-4 p-2 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors"
title="Limpar seleção"
>
<XMarkIcon className="w-5 h-5" />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
"use client";
import { ReactNode } from "react";
interface CardProps {
children: ReactNode;
title?: string;
description?: string;
className?: string;
headerAction?: ReactNode;
noPadding?: boolean;
onClick?: () => void;
allowOverflow?: boolean;
}
export default function Card({
children,
title,
description,
className = "",
headerAction,
noPadding = false,
onClick,
allowOverflow = false
}: CardProps) {
return (
<div
onClick={onClick}
className={`bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-200 dark:border-zinc-800 transition-all ${allowOverflow ? '' : 'overflow-hidden'} ${className}`}
>
{(title || description || headerAction) && (
<div className="px-6 py-4 border-b border-zinc-100 dark:border-zinc-800 flex items-center justify-between">
<div>
{title && (
<h3 className="text-base font-bold text-zinc-900 dark:text-white">
{title}
</h3>
)}
{description && (
<p className="text-xs text-zinc-500 dark:text-zinc-400 mt-0.5">
{description}
</p>
)}
</div>
{headerAction && (
<div>{headerAction}</div>
)}
</div>
)}
<div className={noPadding ? "" : "p-6"}>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,103 @@
"use client";
import { Fragment, useState } from "react";
import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from "@headlessui/react";
import { CheckIcon, ChevronUpDownIcon } from "@heroicons/react/20/solid";
export interface SelectOption {
label: string;
value: string | number;
icon?: React.ReactNode;
color?: string; // Cor para badge/ponto
}
interface CustomSelectProps {
options: SelectOption[];
value: string | number;
onChange: (value: any) => void;
label?: string;
placeholder?: string;
className?: string;
buttonClassName?: string;
}
export default function CustomSelect({
options,
value,
onChange,
label,
placeholder = "Selecione...",
className = "",
buttonClassName = ""
}: CustomSelectProps) {
const selected = options.find((opt) => opt.value === value) || null;
return (
<div className={`w-full ${className}`}>
{label && (
<label className="block text-xs font-bold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider mb-2">
{label}
</label>
)}
<Listbox value={value} onChange={onChange}>
<div className="relative">
<ListboxButton
className={`
relative w-full cursor-pointer rounded-xl bg-white dark:bg-zinc-900 py-2.5 pl-4 pr-10 text-left text-sm font-semibold transition-all border
${buttonClassName || 'border-zinc-200 dark:border-zinc-800 text-zinc-700 dark:text-zinc-300 hover:border-zinc-400'}
focus:outline-none focus:border-zinc-400 dark:focus:border-zinc-500
`}
>
<span className="flex items-center gap-2 truncate">
{selected?.color && (
<span className={`w-2 h-2 rounded-full ${selected.color}`} />
)}
{selected?.icon && <span>{selected.icon}</span>}
{selected ? selected.label : placeholder}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-zinc-400" aria-hidden="true" />
</span>
</ListboxButton>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions className="absolute z-50 mt-2 max-h-60 w-full overflow-auto rounded-xl bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 py-1 focus:outline-none sm:text-sm">
{options.map((option, idx) => (
<ListboxOption
key={idx}
className={({ active }) =>
`relative cursor-pointer select-none py-2.5 pl-10 pr-4 transition-colors ${active ? "bg-zinc-50 dark:bg-zinc-800 text-brand-600 dark:text-brand-400" : "text-zinc-700 dark:text-zinc-300"
}`
}
value={option.value}
>
{({ selected: isSelected }) => (
<>
<span className={`flex items-center gap-2 truncate ${isSelected ? "font-bold" : "font-medium"}`}>
{option.color && (
<span className={`w-2 h-2 rounded-full ${option.color}`} />
)}
{option.icon && <span>{option.icon}</span>}
{option.label}
</span>
{isSelected ? (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-brand-600 dark:text-brand-400">
<CheckIcon className="h-5 w-5" aria-hidden="true" />
</span>
) : null}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</Transition>
</div>
</Listbox>
</div>
);
}

View File

@@ -0,0 +1,178 @@
"use client";
import { ReactNode } from "react";
import { Button } from "./index";
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/24/outline";
interface Column<T> {
header: string;
accessor: keyof T | ((item: T) => ReactNode);
className?: string;
align?: 'left' | 'center' | 'right';
}
interface DataTableProps<T> {
columns: Column<T>[];
data: T[];
isLoading?: boolean;
emptyMessage?: string;
pagination?: {
currentPage: number;
totalPages: number;
onPageChange: (page: number) => void;
totalItems: number;
};
onRowClick?: (item: T) => void;
selectable?: boolean;
selectedIds?: (string | number)[];
onSelectionChange?: (ids: (string | number)[]) => void;
}
export default function DataTable<T extends { id: string | number }>({
columns,
data,
isLoading = false,
emptyMessage = "Nenhum resultado encontrado.",
pagination,
onRowClick,
selectable = false,
selectedIds = [],
onSelectionChange
}: DataTableProps<T>) {
const handleSelectAll = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!onSelectionChange) return;
if (e.target.checked) {
onSelectionChange(data.map(item => item.id));
} else {
onSelectionChange([]);
}
};
const handleSelectItem = (id: string | number) => {
if (!onSelectionChange) return;
if (selectedIds.includes(id)) {
onSelectionChange(selectedIds.filter(i => i !== id));
} else {
onSelectionChange([...selectedIds, id]);
}
};
const isAllSelected = data.length > 0 && selectedIds.length === data.length;
return (
<div className="w-full">
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="bg-zinc-50/50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-800">
{selectable && (
<th className="px-6 py-4 w-10 text-left">
<div className="flex items-center">
<input
type="checkbox"
checked={isAllSelected}
onChange={handleSelectAll}
className="w-4 h-4 rounded border-zinc-300 text-brand-600 focus:ring-brand-500 cursor-pointer"
/>
</div>
</th>
)}
{columns.map((column, index) => (
<th
key={index}
className={`px-6 py-4 text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider ${column.align === 'right' ? 'text-right' :
column.align === 'center' ? 'text-center' : 'text-left'
} ${column.className || ''}`}
>
{column.header}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
{isLoading ? (
Array.from({ length: 3 }).map((_, i) => (
<tr key={i} className="animate-pulse">
{columns.map((_, j) => (
<td key={j} className="px-6 py-4">
<div className="h-4 bg-zinc-100 dark:bg-zinc-800 rounded w-full"></div>
</td>
))}
</tr>
))
) : data.length === 0 ? (
<tr>
<td colSpan={columns.length + (selectable ? 1 : 0)} className="px-6 py-12 text-center text-sm text-zinc-500 dark:text-zinc-400">
{emptyMessage}
</td>
</tr>
) : (
data.map((item) => {
const isSelected = selectedIds.includes(item.id);
return (
<tr
key={item.id}
onClick={() => onRowClick?.(item)}
className={`transition-colors group ${isSelected ? 'bg-brand-50/30 dark:bg-brand-500/5' : ''} ${onRowClick ? 'cursor-pointer hover:bg-zinc-50 dark:hover:bg-zinc-800/50' : 'hover:bg-zinc-50/50 dark:hover:bg-zinc-800/30'}`}
>
{selectable && (
<td className="px-6 py-4 w-10" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center">
<input
type="checkbox"
checked={isSelected}
onChange={() => handleSelectItem(item.id)}
className="w-4 h-4 rounded border-zinc-300 text-brand-600 focus:ring-brand-500 cursor-pointer"
/>
</div>
</td>
)}
{columns.map((column, index) => (
<td
key={index}
className={`px-6 py-4 text-sm text-zinc-600 dark:text-zinc-300 ${column.align === 'right' ? 'text-right' :
column.align === 'center' ? 'text-center' : 'text-left'
} ${column.className || ''}`}
>
{typeof column.accessor === 'function'
? column.accessor(item)
: (item[column.accessor] as ReactNode)}
</td>
))}
</tr>
);
})
)}
</tbody>
</table>
</div>
{pagination && (
<div className="p-4 bg-zinc-50/30 dark:bg-zinc-900/30 border-t border-zinc-200 dark:border-zinc-800 flex items-center justify-between">
<span className="text-xs text-zinc-500 italic">
Mostrando {data.length} de {pagination.totalItems} resultados
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={pagination.currentPage <= 1 || isLoading}
onClick={() => pagination.onPageChange(pagination.currentPage - 1)}
>
<ChevronLeftIcon className="w-4 h-4 mr-1" />
Anterior
</Button>
<Button
variant="outline"
size="sm"
disabled={pagination.currentPage >= pagination.totalPages || isLoading}
onClick={() => pagination.onPageChange(pagination.currentPage + 1)}
>
Próximo
<ChevronRightIcon className="w-4 h-4 ml-1" />
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,242 @@
"use client";
import { useState, Fragment } from "react";
import { Popover, PopoverButton, PopoverPanel, Transition } from "@headlessui/react";
import { CalendarIcon, ChevronLeftIcon, ChevronRightIcon, ClockIcon } from "@heroicons/react/24/outline";
import {
format,
addMonths,
subMonths,
startOfMonth,
endOfMonth,
startOfWeek,
endOfWeek,
isSameMonth,
isSameDay,
addDays,
eachDayOfInterval,
isWithinInterval,
isBefore,
subDays
} from "date-fns";
import { ptBR } from "date-fns/locale";
interface DatePickerProps {
value?: { start: Date | null; end: Date | null } | Date | null;
onChange: (val: any) => void;
placeholder?: string;
className?: string;
buttonClassName?: string;
mode?: 'single' | 'range';
label?: string;
}
export default function DatePicker({
value,
onChange,
placeholder,
className = "",
buttonClassName = "",
mode = 'range',
label
}: DatePickerProps) {
const [currentMonth, setCurrentMonth] = useState(new Date());
// Helper to normalize value
const getRange = () => {
if (mode === 'single') {
const date = value instanceof Date ? value : (value as any)?.start || null;
return { start: date, end: date };
}
const range = (value as { start: Date | null; end: Date | null }) || { start: null, end: null };
return range;
};
const range = getRange();
const quickRanges = [
{ label: 'Hoje', getValue: () => ({ start: new Date(), end: new Date() }) },
{ label: 'Últimos 7 dias', getValue: () => ({ start: subDays(new Date(), 7), end: new Date() }) },
{ label: 'Últimos 14 dias', getValue: () => ({ start: subDays(new Date(), 14), end: new Date() }) },
{ label: 'Últimos 30 dias', getValue: () => ({ start: subDays(new Date(), 30), end: new Date() }) },
{ label: 'Este Mês', getValue: () => ({ start: startOfMonth(new Date()), end: endOfMonth(new Date()) }) },
];
const handleDateClick = (day: Date) => {
if (mode === 'single') {
onChange(day);
return;
}
if (!range.start || (range.start && range.end)) {
onChange({ start: day, end: null });
} else {
if (isBefore(day, range.start)) {
onChange({ start: day, end: range.start });
} else {
onChange({ start: range.start, end: day });
}
}
};
const isInRange = (day: Date) => {
if (range.start && range.end) {
return isWithinInterval(day, { start: range.start, end: range.end });
}
return false;
};
const displayValue = () => {
if (!range.start) return placeholder || (mode === 'single' ? "Selecionar data" : "Selecionar período");
if (mode === 'single') return format(range.start, "dd/MM/yyyy", { locale: ptBR });
if (!range.end || isSameDay(range.start, range.end)) return format(range.start, "dd MMM yyyy", { locale: ptBR });
return `${format(range.start, "dd MMM", { locale: ptBR })} - ${format(range.end, "dd MMM yyyy", { locale: ptBR })}`;
};
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation();
onChange(mode === 'single' ? null : { start: null, end: null });
};
return (
<Popover className={`relative ${className}`}>
{label && (
<label className="block text-sm font-bold text-zinc-700 dark:text-zinc-300 mb-2">
{label}
</label>
)}
<PopoverButton
className={`
w-full flex items-center gap-3 px-4 py-2.5 text-sm font-bold transition-all outline-none border rounded-xl group
${buttonClassName || 'bg-white dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 text-zinc-700 dark:text-zinc-300 hover:border-zinc-400'}
focus:border-zinc-400 dark:focus:border-zinc-500
`}
>
<CalendarIcon className="w-4.5 h-4.5 text-zinc-400 group-hover:text-brand-500 transition-colors shrink-0" />
<span className="flex-1 text-left truncate">
{displayValue()}
</span>
{range.start && (
<button
type="button"
onClick={handleClear}
className="p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg text-zinc-400 hover:text-rose-500 transition-colors"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.6} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</PopoverButton>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<PopoverPanel
anchor="bottom end"
className="flex bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-2xl shadow-xl overflow-hidden outline-none [--anchor-gap:8px] min-w-[480px]"
>
{/* Filtros Rápidos (Apenas para Range) */}
{mode === 'range' && (
<div className="w-48 bg-zinc-50/80 dark:bg-zinc-900/80 border-r border-zinc-100 dark:border-zinc-800 p-4 space-y-1.5">
<div className="flex items-center gap-2 px-1 py-1 mb-2">
<ClockIcon className="w-3.5 h-3.5 text-zinc-400" />
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-[0.1em]">Atalhos</p>
</div>
{quickRanges.map((r) => (
<button
key={r.label}
type="button"
onClick={() => onChange(r.getValue())}
className="w-full px-3 py-2 text-left text-[11px] font-bold text-zinc-600 dark:text-zinc-400 hover:bg-white dark:hover:bg-zinc-800 hover:text-brand-600 dark:hover:text-brand-400 rounded-xl transition-all border border-transparent hover:border-zinc-100 dark:hover:border-zinc-700 hover:shadow-sm"
>
{r.label}
</button>
))}
<div className="pt-4 mt-4 border-t border-zinc-100 dark:border-zinc-800">
<button
type="button"
onClick={() => onChange({ start: null, end: null })}
className="w-full px-3 py-2 text-left text-[11px] font-bold text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-500/10 rounded-xl transition-all"
>
Limpar Filtro
</button>
</div>
</div>
)}
{/* Calendário */}
<div className="flex-1 p-4">
<div className="flex items-center justify-between mb-4 px-1">
<span className="text-sm font-bold text-zinc-900 dark:text-white capitalize">
{format(currentMonth, "MMMM yyyy", { locale: ptBR })}
</span>
<div className="flex gap-1">
<button
type="button"
onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}
className="p-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md transition-colors border border-zinc-100 dark:border-zinc-800"
>
<ChevronLeftIcon className="w-4 h-4 text-zinc-500" />
</button>
<button
type="button"
onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}
className="p-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md transition-colors border border-zinc-100 dark:border-zinc-800"
>
<ChevronRightIcon className="w-4 h-4 text-zinc-500" />
</button>
</div>
</div>
<div className="grid grid-cols-7 mb-2">
{["D", "S", "T", "Q", "Q", "S", "S"].map((day, idx) => (
<div key={idx} className="text-center text-[10px] font-bold text-zinc-400 py-1">
{day}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{eachDayOfInterval({
start: startOfWeek(startOfMonth(currentMonth)),
end: endOfWeek(endOfMonth(currentMonth))
}).map((day, idx) => {
const isStart = range.start && isSameDay(day, range.start);
const isEnd = range.end && isSameDay(day, range.end);
const isSelected = isStart || isEnd;
const isRange = isInRange(day);
const isCurrentMonth = isSameMonth(day, startOfMonth(currentMonth));
const isTodayDate = isSameDay(day, new Date());
return (
<button
key={idx}
type="button"
onClick={() => handleDateClick(day)}
className={`
relative py-2.5 text-[11px] transition-all outline-none flex items-center justify-center font-bold h-10 w-10
${!isCurrentMonth ? 'text-zinc-300 dark:text-zinc-600' : 'text-zinc-900 dark:text-zinc-100'}
${isSelected ? 'bg-brand-500 text-white rounded-xl z-10 scale-105 shadow-lg shadow-brand-500/20' : ''}
${isRange && !isSelected && mode === 'range' ? 'bg-brand-50 dark:bg-brand-500/10 text-brand-600 dark:text-brand-400' : ''}
${!isSelected ? 'hover:bg-zinc-100 dark:hover:bg-zinc-800 hover:rounded-xl' : ''}
${isTodayDate && !isSelected ? 'text-brand-600 ring-1 ring-brand-100 dark:ring-brand-500/30 rounded-xl' : ''}
`}
>
<span>{format(day, "d")}</span>
</button>
);
})}
</div>
</div>
</PopoverPanel>
</Transition>
</Popover>
);
}

View File

@@ -58,7 +58,7 @@ const Input = forwardRef<HTMLInputElement, InputProps>(
${isPassword || rightIcon ? "pr-11" : ""}
${error
? "border-red-500 focus:border-red-500 focus:ring-4 focus:ring-red-500/10"
: "border-gray-200 dark:border-gray-700 focus:border-brand-500 focus:ring-4 focus:ring-brand-500/10"
: "border-zinc-200 dark:border-zinc-700 focus:border-zinc-400 dark:focus:border-zinc-500 focus:ring-0"
}
outline-none
disabled:bg-gray-50 disabled:text-gray-500 disabled:cursor-not-allowed

View File

@@ -0,0 +1,76 @@
"use client";
import { ReactNode } from "react";
import Button from "./Button";
interface PageHeaderProps {
title: string;
description?: string;
primaryAction?: {
label: string;
onClick?: () => void;
icon?: ReactNode;
isLoading?: boolean;
};
secondaryAction?: {
label: string;
onClick?: () => void;
icon?: ReactNode;
};
children?: ReactNode;
}
export default function PageHeader({
title,
description,
primaryAction,
secondaryAction,
children
}: PageHeaderProps) {
return (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
<div className="flex-1 min-w-0">
<h1 className="text-2xl font-bold text-zinc-900 dark:text-white tracking-tight truncate">
{title}
</h1>
{description && (
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
{description}
</p>
)}
</div>
<div className="flex items-center gap-3">
{secondaryAction && (
<Button
variant="outline"
onClick={secondaryAction.onClick}
className="bg-white dark:bg-zinc-900"
>
{secondaryAction.icon && (
<span className="mr-2">{secondaryAction.icon}</span>
)}
{secondaryAction.label}
</Button>
)}
{primaryAction && (
<Button
variant="primary"
onClick={primaryAction.onClick}
isLoading={primaryAction.isLoading}
className="shadow-lg shadow-brand-500/20"
style={{ background: 'var(--gradient)' }}
>
{primaryAction.icon && !primaryAction.isLoading && (
<span className="mr-2">{primaryAction.icon}</span>
)}
{primaryAction.label}
</Button>
)}
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,67 @@
"use client";
import { ReactNode } from "react";
import { ArrowTrendingUpIcon as TrendingUpIcon, ArrowTrendingDownIcon as TrendingDownIcon } from "@heroicons/react/24/outline";
interface StatsCardProps {
title: string;
value: string | number;
icon: ReactNode;
trend?: {
value: string | number;
label: string;
type: "up" | "down" | "neutral";
};
description?: string;
}
export default function StatsCard({
title,
value,
icon,
trend,
description
}: StatsCardProps) {
return (
<div className="bg-white dark:bg-zinc-900 rounded-2xl border border-zinc-200 dark:border-zinc-800 p-6 transition-all group">
<div className="flex items-start justify-between">
<div>
<p className="text-sm font-medium text-zinc-500 dark:text-zinc-400">
{title}
</p>
<h3 className="mt-1 text-2xl font-bold text-zinc-900 dark:text-white group-hover:text-brand-500 transition-colors">
{value}
</h3>
{trend && (
<div className="mt-2 flex items-center gap-1.5">
<div className={`flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[10px] font-bold ${trend.type === 'up'
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-400'
: trend.type === 'down'
? 'bg-red-100 text-red-700 dark:bg-red-900/20 dark:text-red-400'
: 'bg-zinc-100 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-400'
}`}>
{trend.type === 'up' && <TrendingUpIcon className="w-3 h-3" />}
{trend.type === 'down' && <TrendingDownIcon className="w-3 h-3" />}
{trend.value}
</div>
<span className="text-[10px] text-zinc-500 dark:text-zinc-500">
{trend.label}
</span>
</div>
)}
{description && !trend && (
<p className="mt-2 text-xs text-zinc-500 dark:text-zinc-500">
{description}
</p>
)}
</div>
<div className="p-3 bg-zinc-50 dark:bg-zinc-800 rounded-xl text-zinc-500 dark:text-zinc-400 group-hover:bg-brand-50 dark:group-hover:bg-brand-900/10 group-hover:text-brand-500 transition-all">
{icon}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,69 @@
"use client";
import { Fragment, ReactNode } from 'react';
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/react';
interface TabItem {
label: string;
icon?: ReactNode;
content: ReactNode;
disabled?: boolean;
}
interface TabsProps {
items: TabItem[];
defaultIndex?: number;
onChange?: (index: number) => void;
className?: string;
variant?: 'pills' | 'underline';
}
function classNames(...classes: string[]) {
return classes.filter(Boolean).join(' ');
}
export default function Tabs({
items,
defaultIndex = 0,
onChange,
className = "",
variant = 'pills'
}: TabsProps) {
return (
<TabGroup defaultIndex={defaultIndex} onChange={onChange} className={className}>
<TabList className={classNames(
'flex space-x-1 p-1 mb-6',
variant === 'pills' ? 'bg-zinc-100 dark:bg-zinc-800/50 rounded-xl' : 'border-b border-zinc-200 dark:border-zinc-800 bg-transparent'
)}>
{items.map((item, index) => (
<Tab
key={index}
disabled={item.disabled}
className={({ selected }) =>
classNames(
'flex items-center justify-center gap-2 py-2.5 text-sm font-bold transition-all outline-none rounded-lg flex-1 cursor-pointer',
variant === 'pills'
? selected
? 'bg-white dark:bg-zinc-700 text-zinc-900 dark:text-white shadow-sm ring-1 ring-black/5'
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300 hover:bg-white/50 dark:hover:bg-zinc-700/30'
: selected
? 'border-b-2 border-brand-500 text-brand-500 rounded-none'
: 'text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300 border-b-2 border-transparent rounded-none'
)
}
>
{item.icon && <span className="w-4 h-4">{item.icon}</span>}
{item.label}
</Tab>
))}
</TabList>
<TabPanels>
{items.map((item, index) => (
<TabPanel key={index} className="outline-none">
{item.content}
</TabPanel>
))}
</TabPanels>
</TabGroup>
);
}

View File

@@ -4,3 +4,13 @@ export { default as Checkbox } from "./Checkbox";
export { default as Select } from "./Select";
export { default as SearchableSelect } from "./SearchableSelect";
export { default as Dialog } from "./Dialog";
export { default as PageHeader } from "./PageHeader";
export { default as Card } from "./Card";
export { default as StatsCard } from "./StatsCard";
export { default as Tabs } from "./Tabs";
export { default as DataTable } from "./DataTable";
export { default as DatePicker } from "./DatePicker";
export { default as CustomSelect } from "./CustomSelect";
export { default as Badge } from "./Badge";
export { default as BulkActionBar } from "./BulkActionBar";
export { default as ConfirmDialog } from "../layout/ConfirmDialog";