.
This commit is contained in:
parent
090f8acf5c
commit
e6498b4104
13
client/jest.config.ts
Normal file
13
client/jest.config.ts
Normal file
@ -0,0 +1,13 @@
|
||||
// jest.config.ts
|
||||
|
||||
export default {
|
||||
preset: "ts-jest",
|
||||
testEnvironment: "jest-environment-jsdom",
|
||||
transform: {
|
||||
"^.+\\.tsx?$": "ts-jest",
|
||||
// process `*.tsx` files with `ts-jest`
|
||||
},
|
||||
moduleNameMapper: {
|
||||
"\\.(gif|ttf|eot|svg|png)$": "<rootDir>/test/__ mocks __/fileMock.js",
|
||||
},
|
||||
};
|
||||
@ -8,9 +8,12 @@
|
||||
"dev": "vite --host",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "jest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@dnd-kit/sortable": "^8.0.0",
|
||||
"@hookform/resolvers": "^3.5.0",
|
||||
"@radix-ui/react-accordion": "^1.1.2",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
@ -40,6 +43,7 @@
|
||||
"@radix-ui/react-toggle-group": "^1.0.4",
|
||||
"@radix-ui/react-tooltip": "^1.0.7",
|
||||
"@tanstack/react-query": "^5.39.0",
|
||||
"@tanstack/react-table": "^8.17.3",
|
||||
"axios": "^1.7.2",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"cmdk": "^1.0.0",
|
||||
@ -62,6 +66,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/react-query-devtools": "^5.39.0",
|
||||
"@testing-library/jest-dom": "^6.4.6",
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/react": "^18.2.66",
|
||||
"@types/react-dom": "^18.2.22",
|
||||
@ -73,10 +81,15 @@
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.6",
|
||||
"identity-obj-proxy": "^3.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"postcss": "^8.4.38",
|
||||
"postcss-import": "^16.1.0",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"ts-jest": "^29.1.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.2.12"
|
||||
}
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import { RouterProvider, createBrowserRouter } from "react-router-dom";
|
||||
import { LoginPage, LogoutPage, SettingsPage, StartPage } from "./app";
|
||||
import { CatalogList } from "./app/catalog";
|
||||
import { Outlet, RouterProvider, createBrowserRouter } from "react-router-dom";
|
||||
import { DealerLayout, DealersList, LoginPage, LogoutPage, SettingsPage, StartPage } from "./app";
|
||||
import { CatalogLayout, CatalogList } from "./app/catalog";
|
||||
import { DashboardPage } from "./app/dashboard";
|
||||
import { DealersList } from "./app/dealers/list";
|
||||
import { QuotesList } from "./app/quotes/list";
|
||||
import { ProtectedRoute } from "./components";
|
||||
|
||||
@ -29,17 +28,33 @@ export const Routes = () => {
|
||||
path: "/catalog",
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<CatalogList />
|
||||
<CatalogLayout>
|
||||
<Outlet />
|
||||
</CatalogLayout>
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <CatalogList />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/dealers",
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<DealersList />
|
||||
<DealerLayout>
|
||||
<Outlet />
|
||||
</DealerLayout>
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{
|
||||
index: true,
|
||||
element: <DealersList />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/quotes",
|
||||
|
||||
54
client/src/app/catalog/CatalogActions.ts
Normal file
54
client/src/app/catalog/CatalogActions.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { IDataSource } from '@/lib/hooks/useDataSource/DataSource';
|
||||
|
||||
export type SuccessNotificationResponse = {
|
||||
message: string;
|
||||
description?: string;
|
||||
};
|
||||
|
||||
export type PermissionResponse = unknown;
|
||||
|
||||
export type IdentityResponse = unknown;
|
||||
|
||||
export type CatalogActionCheckResponse = {
|
||||
authenticated: boolean;
|
||||
redirectTo?: string;
|
||||
logout?: boolean;
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
export type CatalogActionOnErrorResponse = {
|
||||
redirectTo?: string;
|
||||
logout?: boolean;
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
export type CatalogActionResponse = {
|
||||
success: boolean;
|
||||
redirectTo?: string;
|
||||
error?: Error;
|
||||
[key: string]: unknown;
|
||||
successNotification?: SuccessNotificationResponse;
|
||||
};
|
||||
|
||||
export interface ICatalogActions {
|
||||
listCatalog: (
|
||||
dataSource: IDataSource,
|
||||
pagination: {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
} = {
|
||||
pageIndex: INITIAL_PAGE_INDEX,
|
||||
pageSize: INITIAL_PAGE_SIZE,
|
||||
},
|
||||
quickSearchTerm: string = "",
|
||||
): Promise<IListResponse_DTO<IListCustomerInvoices_Response_DTO>> => {
|
||||
return dataProvider.getList({
|
||||
resource: "invoices",
|
||||
quickSearchTerm,
|
||||
pagination,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
21
client/src/app/catalog/CatalogContext.tsx
Normal file
21
client/src/app/catalog/CatalogContext.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { usePagination } from "@/lib/hooks";
|
||||
import { PropsWithChildren, createContext } from "react";
|
||||
|
||||
export interface ICatalogContextState {}
|
||||
|
||||
export const CatalogContext = createContext<ICatalogContextState | null>(null);
|
||||
|
||||
export const CatalogProvider = ({ children }: PropsWithChildren) => {
|
||||
const [pagination, setPagination] = usePagination();
|
||||
|
||||
return (
|
||||
<CatalogContext.Provider
|
||||
value={{
|
||||
pagination,
|
||||
setPagination,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</CatalogContext.Provider>
|
||||
);
|
||||
};
|
||||
36
client/src/app/catalog/components/CatalogDataTable.tsx
Normal file
36
client/src/app/catalog/components/CatalogDataTable.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { DataTable } from "@/components";
|
||||
import { useDataTable } from "@/lib/hooks";
|
||||
import { IListArticles_Response_DTO, IListResponse_DTO } from "@shared/contexts";
|
||||
import { useMemo } from "react";
|
||||
|
||||
type CatalogTableViewProps = {
|
||||
data: IListResponse_DTO<IListArticles_Response_DTO>;
|
||||
};
|
||||
|
||||
export const CatalogDataTable = ({ data }: CatalogTableViewProps) => {
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "description" as const,
|
||||
accessorKey: "description",
|
||||
size: 400,
|
||||
enableHiding: false,
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: data?.items ?? [],
|
||||
columns: columns,
|
||||
pageCount: data?.total_pages ?? -1,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable table={table} paginationOptions={{ visible: true }} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
1
client/src/app/catalog/components/index.ts
Normal file
1
client/src/app/catalog/components/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./CatalogDataTable";
|
||||
77
client/src/app/catalog/components/useCatalogTableColumns.tsx
Normal file
77
client/src/app/catalog/components/useCatalogTableColumns.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import { IListArticles_Response_DTO } from "@shared/contexts";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
|
||||
import { DataTablaRowActionFunction, DataTableRowActions } from "@/components";
|
||||
import { Badge } from "@/ui";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export const useCustomerInvoiceDataTableColumns = (
|
||||
actions: DataTablaRowActionFunction<IListArticles_Response_DTO>
|
||||
): ColumnDef<IListArticles_Response_DTO>[] => {
|
||||
const customerColumns: ColumnDef<IListArticles_Response_DTO>[] = useMemo(
|
||||
() => [
|
||||
/*{
|
||||
id: "complete_name",
|
||||
header: "Nombre",
|
||||
accessorFn: (row) => (
|
||||
<div className="flex items-center justify-between space-x-4">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Avatar>
|
||||
<AvatarImage src={row.photo_url} />
|
||||
<AvatarFallback>
|
||||
{acronym(`${row.first_name} ${row.last_name}`)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="text-base font-semibold">{`${row.first_name} ${row.last_name}`}</p>
|
||||
<p className="mt-1">{row.job_title}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
enableSorting: true,
|
||||
sortingFn: "alphanumeric",
|
||||
enableHiding: false,
|
||||
|
||||
cell: ({ renderValue }) => (
|
||||
<span className="w-full">
|
||||
<>{renderValue()}</>
|
||||
</span>
|
||||
),
|
||||
},*/
|
||||
{
|
||||
id: "company",
|
||||
accessorKey: "company_name",
|
||||
header: "Compañía",
|
||||
enableSorting: true,
|
||||
sortingFn: "alphanumeric",
|
||||
},
|
||||
{
|
||||
id: "state",
|
||||
accessorKey: "state",
|
||||
header: "Estado",
|
||||
cell: ({ renderValue }) => (
|
||||
<Badge variant={"destructive"}>
|
||||
<>{renderValue()}</>
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "phone",
|
||||
accessorKey: "phone",
|
||||
header: "Phone",
|
||||
enableSorting: true,
|
||||
sortingFn: "alphanumeric",
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: "Acciones",
|
||||
cell: ({ row }) => {
|
||||
return <DataTableRowActions row={row} actions={actions} />;
|
||||
},
|
||||
},
|
||||
],
|
||||
[actions]
|
||||
);
|
||||
return customerColumns;
|
||||
};
|
||||
1
client/src/app/catalog/hooks/index.ts
Normal file
1
client/src/app/catalog/hooks/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./useCatalogList";
|
||||
40
client/src/app/catalog/hooks/useCatalogList.tsx
Normal file
40
client/src/app/catalog/hooks/useCatalogList.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { UseListQueryResult, useList } from "@/lib/hooks/useDataSource";
|
||||
import { useDataSource } from "@/lib/hooks/useDataSource/useDataSource";
|
||||
import { useQueryKey } from "@/lib/hooks/useQueryKey";
|
||||
import { IListArticles_Response_DTO, IListResponse_DTO } from "@shared/contexts";
|
||||
|
||||
export type UseCatalogListParams = {
|
||||
pagination: {
|
||||
pageIndex: number;
|
||||
pageSize: number;
|
||||
};
|
||||
searchTerm?: string;
|
||||
enabled?: boolean;
|
||||
queryOptions?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type UseCatalogListResponse = UseListQueryResult<
|
||||
IListResponse_DTO<IListArticles_Response_DTO>,
|
||||
unknown
|
||||
>;
|
||||
|
||||
export const useCatalogList = (params: UseCatalogListParams): UseCatalogListResponse => {
|
||||
const dataSource = useDataSource();
|
||||
const keys = useQueryKey();
|
||||
|
||||
const { pagination, searchTerm = undefined, enabled = true, queryOptions } = params;
|
||||
|
||||
return useList({
|
||||
queryKey: keys().data().resource("catalog").action("list").params(params).get(),
|
||||
queryFn: () => {
|
||||
console.log(pagination);
|
||||
return dataSource.getList({
|
||||
resource: "catalog",
|
||||
quickSearchTerm: searchTerm,
|
||||
pagination,
|
||||
});
|
||||
},
|
||||
enabled,
|
||||
queryOptions,
|
||||
});
|
||||
};
|
||||
@ -1 +1,2 @@
|
||||
export * from "./layout";
|
||||
export * from "./list";
|
||||
|
||||
17
client/src/app/catalog/layout.tsx
Normal file
17
client/src/app/catalog/layout.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { Layout, LayoutContent, LayoutHeader } from "@/components";
|
||||
import { DataTableProvider } from "@/lib/hooks";
|
||||
import { PropsWithChildren } from "react";
|
||||
import { CatalogProvider } from "./CatalogContext";
|
||||
|
||||
export const CatalogLayout = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<CatalogProvider>
|
||||
<Layout>
|
||||
<LayoutHeader />
|
||||
<LayoutContent>
|
||||
<DataTableProvider>{children}</DataTableProvider>
|
||||
</LayoutContent>
|
||||
</Layout>
|
||||
</CatalogProvider>
|
||||
);
|
||||
};
|
||||
@ -28,289 +28,383 @@ import {
|
||||
|
||||
import { File, ListFilter, MoreHorizontal, PlusCircle } from "lucide-react";
|
||||
|
||||
import { Layout, LayoutContent, LayoutHeader } from "@/components";
|
||||
import { DataTable, DataTableSkeleton, ErrorOverlay, SimpleEmptyState } from "@/components";
|
||||
|
||||
import { useDataTable, useDataTableContext } from "@/lib/hooks";
|
||||
import { useMemo } from "react";
|
||||
import { Trans } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { CatalogDataTable } from "./components";
|
||||
import { useCatalogList } from "./hooks/useCatalogList";
|
||||
|
||||
export const CatalogList = () => {
|
||||
return (
|
||||
<Layout>
|
||||
<LayoutHeader />
|
||||
<LayoutContent>
|
||||
<div className='flex items-center'>
|
||||
<h1 className='text-lg font-semibold md:text-2xl'>Catalog</h1>
|
||||
</div>
|
||||
const navigate = useNavigate();
|
||||
|
||||
<Tabs defaultValue='all'>
|
||||
<div className='flex items-center'>
|
||||
<TabsList>
|
||||
<TabsTrigger value='all'>All</TabsTrigger>
|
||||
<TabsTrigger value='active'>Active</TabsTrigger>
|
||||
<TabsTrigger value='draft'>Draft</TabsTrigger>
|
||||
<TabsTrigger value='archived' className='hidden sm:flex'>
|
||||
Archived
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className='flex items-center gap-2 ml-auto'>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='outline' size='sm' className='h-8 gap-1'>
|
||||
<ListFilter className='h-3.5 w-3.5' />
|
||||
<span className='sr-only sm:not-sr-only sm:whitespace-nowrap'>Filter</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>Filter by</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem checked>Active</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem>Draft</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem>Archived</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button size='sm' variant='outline' className='h-8 gap-1'>
|
||||
<File className='h-3.5 w-3.5' />
|
||||
<span className='sr-only sm:not-sr-only sm:whitespace-nowrap'>Export</span>
|
||||
</Button>
|
||||
<Button size='sm' className='h-8 gap-1'>
|
||||
<PlusCircle className='h-3.5 w-3.5' />
|
||||
<span className='sr-only sm:not-sr-only sm:whitespace-nowrap'>Add Product</span>
|
||||
</Button>
|
||||
</div>
|
||||
const { pagination } = useDataTableContext();
|
||||
console.log("pagination PADRE => ", pagination);
|
||||
|
||||
const { data, isPending, isError, error, refetch } = useCatalogList({
|
||||
pagination: {
|
||||
pageIndex: pagination.pageIndex,
|
||||
pageSize: pagination.pageSize,
|
||||
},
|
||||
});
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "description" as const,
|
||||
accessorKey: "description",
|
||||
size: 400,
|
||||
enableHiding: false,
|
||||
enableSorting: false,
|
||||
enableResizing: false,
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const { table } = useDataTable({
|
||||
data: data?.items ?? [],
|
||||
columns: columns,
|
||||
pageCount: data?.total_pages ?? -1,
|
||||
});
|
||||
|
||||
return <DataTable table={table} paginationOptions={{ visible: true }} />;
|
||||
|
||||
if (isError || isPending) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setPagination({
|
||||
pageIndex: pagination.pageIndex + 1,
|
||||
});
|
||||
}}
|
||||
>
|
||||
+ Página
|
||||
</Button>
|
||||
{data.items.map((row) => (
|
||||
<p>{row.description}</p>
|
||||
))}
|
||||
<CatalogDataTable data={data} />;
|
||||
</>
|
||||
);
|
||||
|
||||
if (isError) {
|
||||
return <ErrorOverlay subtitle={(error as Error).message} />;
|
||||
}
|
||||
|
||||
if (isPending) {
|
||||
return (
|
||||
<Card x-chunk='dashboard-06-chunk-0'>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey='catalog.title' />
|
||||
</CardTitle>
|
||||
<CardDescription>Manage your products and view their sales performance.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<DataTableSkeleton
|
||||
columnCount={6}
|
||||
searchableColumnCount={1}
|
||||
filterableColumnCount={2}
|
||||
//cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem"]}
|
||||
shrinkZero
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (data?.total_items === 0) {
|
||||
return (
|
||||
<SimpleEmptyState
|
||||
subtitle='Empieza cargando los artículos del catálogo'
|
||||
buttonText=''
|
||||
onButtonClick={() => navigate("/catalog/add")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tabs defaultValue='all'>
|
||||
<div className='flex items-center'>
|
||||
<TabsList>
|
||||
<TabsTrigger value='all'>All</TabsTrigger>
|
||||
<TabsTrigger value='active'>Active</TabsTrigger>
|
||||
<TabsTrigger value='draft'>Draft</TabsTrigger>
|
||||
<TabsTrigger value='archived' className='hidden sm:flex'>
|
||||
Archived
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className='flex items-center gap-2 ml-auto'>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='outline' size='sm' className='h-8 gap-1'>
|
||||
<ListFilter className='h-3.5 w-3.5' />
|
||||
<span className='sr-only sm:not-sr-only sm:whitespace-nowrap'>Filter</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>Filter by</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem checked>Active</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem>Draft</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem>Archived</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button size='sm' variant='outline' className='h-8 gap-1'>
|
||||
<File className='h-3.5 w-3.5' />
|
||||
<span className='sr-only sm:not-sr-only sm:whitespace-nowrap'>Export</span>
|
||||
</Button>
|
||||
<Button size='sm' className='h-8 gap-1'>
|
||||
<PlusCircle className='h-3.5 w-3.5' />
|
||||
<span className='sr-only sm:not-sr-only sm:whitespace-nowrap'>Add Product</span>
|
||||
</Button>
|
||||
</div>
|
||||
<TabsContent value='all'>
|
||||
<Card x-chunk='dashboard-06-chunk-0'>
|
||||
<CardHeader>
|
||||
<CardTitle>Products</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your products and view their sales performance.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='hidden w-[100px] sm:table-cell'>
|
||||
<span className='sr-only'>Image</span>
|
||||
</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className='hidden md:table-cell'>Price</TableHead>
|
||||
<TableHead className='hidden md:table-cell'>Total Sales</TableHead>
|
||||
<TableHead className='hidden md:table-cell'>Created at</TableHead>
|
||||
<TableHead>
|
||||
<span className='sr-only'>Actions</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<img
|
||||
alt='Product image'
|
||||
className='object-cover rounded-md aspect-square'
|
||||
height='64'
|
||||
src='/placeholder.svg'
|
||||
width='64'
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='font-medium'>Laser Lemonade Machine</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant='outline'>Draft</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>$499.99</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>25</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2023-07-12 10:42 AM</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button aria-haspopup='true' size='icon' variant='ghost'>
|
||||
<MoreHorizontal className='w-4 h-4' />
|
||||
<span className='sr-only'>Toggle menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem>Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<img
|
||||
alt='Product image'
|
||||
className='object-cover rounded-md aspect-square'
|
||||
height='64'
|
||||
src='/placeholder.svg'
|
||||
width='64'
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='font-medium'>Hypernova Headphones</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant='outline'>Active</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>$129.99</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>100</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2023-10-18 03:21 PM</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button aria-haspopup='true' size='icon' variant='ghost'>
|
||||
<MoreHorizontal className='w-4 h-4' />
|
||||
<span className='sr-only'>Toggle menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem>Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<img
|
||||
alt='Product image'
|
||||
className='object-cover rounded-md aspect-square'
|
||||
height='64'
|
||||
src='/placeholder.svg'
|
||||
width='64'
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='font-medium'>AeroGlow Desk Lamp</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant='outline'>Active</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>$39.99</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>50</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2023-11-29 08:15 AM</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button aria-haspopup='true' size='icon' variant='ghost'>
|
||||
<MoreHorizontal className='w-4 h-4' />
|
||||
<span className='sr-only'>Toggle menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem>Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<img
|
||||
alt='Product image'
|
||||
className='object-cover rounded-md aspect-square'
|
||||
height='64'
|
||||
src='/placeholder.svg'
|
||||
width='64'
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='font-medium'>TechTonic Energy Drink</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant='secondary'>Draft</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>$2.99</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>0</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2023-12-25 11:59 PM</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button aria-haspopup='true' size='icon' variant='ghost'>
|
||||
<MoreHorizontal className='w-4 h-4' />
|
||||
<span className='sr-only'>Toggle menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem>Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<img
|
||||
alt='Product image'
|
||||
className='object-cover rounded-md aspect-square'
|
||||
height='64'
|
||||
src='/placeholder.svg'
|
||||
width='64'
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='font-medium'>Gamer Gear Pro Controller</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant='outline'>Active</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>$59.99</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>75</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2024-01-01 12:00 AM</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button aria-haspopup='true' size='icon' variant='ghost'>
|
||||
<MoreHorizontal className='w-4 h-4' />
|
||||
<span className='sr-only'>Toggle menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem>Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<img
|
||||
alt='Product image'
|
||||
className='object-cover rounded-md aspect-square'
|
||||
height='64'
|
||||
src='/placeholder.svg'
|
||||
width='64'
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='font-medium'>Luminous VR Headset</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant='outline'>Active</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>$199.99</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>30</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2024-02-14 02:14 PM</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button aria-haspopup='true' size='icon' variant='ghost'>
|
||||
<MoreHorizontal className='w-4 h-4' />
|
||||
<span className='sr-only'>Toggle menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem>Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
Showing <strong>1-10</strong> of <strong>32</strong> products
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</LayoutContent>
|
||||
</Layout>
|
||||
</div>
|
||||
<TabsContent value='all'>
|
||||
<Card x-chunk='dashboard-06-chunk-0'>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey='catalog.title' />
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your products and view their sales performance.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='hidden w-[100px] sm:table-cell'>
|
||||
<span className='sr-only'>Image</span>
|
||||
</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className='hidden md:table-cell'>Price</TableHead>
|
||||
<TableHead className='hidden md:table-cell'>Total Sales</TableHead>
|
||||
<TableHead className='hidden md:table-cell'>Created at</TableHead>
|
||||
<TableHead>
|
||||
<span className='sr-only'>Actions</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<img
|
||||
alt='Product image'
|
||||
className='object-cover rounded-md aspect-square'
|
||||
height='64'
|
||||
src='/placeholder.svg'
|
||||
width='64'
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='font-medium'>Laser Lemonade Machine</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant='outline'>Draft</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>$499.99</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>25</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2023-07-12 10:42 AM</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button aria-haspopup='true' size='icon' variant='ghost'>
|
||||
<MoreHorizontal className='w-4 h-4' />
|
||||
<span className='sr-only'>Toggle menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem>Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<img
|
||||
alt='Product image'
|
||||
className='object-cover rounded-md aspect-square'
|
||||
height='64'
|
||||
src='/placeholder.svg'
|
||||
width='64'
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='font-medium'>Hypernova Headphones</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant='outline'>Active</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>$129.99</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>100</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2023-10-18 03:21 PM</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button aria-haspopup='true' size='icon' variant='ghost'>
|
||||
<MoreHorizontal className='w-4 h-4' />
|
||||
<span className='sr-only'>Toggle menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem>Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<img
|
||||
alt='Product image'
|
||||
className='object-cover rounded-md aspect-square'
|
||||
height='64'
|
||||
src='/placeholder.svg'
|
||||
width='64'
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='font-medium'>AeroGlow Desk Lamp</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant='outline'>Active</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>$39.99</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>50</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2023-11-29 08:15 AM</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button aria-haspopup='true' size='icon' variant='ghost'>
|
||||
<MoreHorizontal className='w-4 h-4' />
|
||||
<span className='sr-only'>Toggle menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem>Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<img
|
||||
alt='Product image'
|
||||
className='object-cover rounded-md aspect-square'
|
||||
height='64'
|
||||
src='/placeholder.svg'
|
||||
width='64'
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='font-medium'>TechTonic Energy Drink</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant='secondary'>Draft</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>$2.99</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>0</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2023-12-25 11:59 PM</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button aria-haspopup='true' size='icon' variant='ghost'>
|
||||
<MoreHorizontal className='w-4 h-4' />
|
||||
<span className='sr-only'>Toggle menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem>Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<img
|
||||
alt='Product image'
|
||||
className='object-cover rounded-md aspect-square'
|
||||
height='64'
|
||||
src='/placeholder.svg'
|
||||
width='64'
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='font-medium'>Gamer Gear Pro Controller</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant='outline'>Active</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>$59.99</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>75</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2024-01-01 12:00 AM</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button aria-haspopup='true' size='icon' variant='ghost'>
|
||||
<MoreHorizontal className='w-4 h-4' />
|
||||
<span className='sr-only'>Toggle menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem>Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<img
|
||||
alt='Product image'
|
||||
className='object-cover rounded-md aspect-square'
|
||||
height='64'
|
||||
src='/placeholder.svg'
|
||||
width='64'
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='font-medium'>Luminous VR Headset</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant='outline'>Active</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>$199.99</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>30</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2024-02-14 02:14 PM</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button aria-haspopup='true' size='icon' variant='ghost'>
|
||||
<MoreHorizontal className='w-4 h-4' />
|
||||
<span className='sr-only'>Toggle menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem>Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
Showing <strong>1-10</strong> of <strong>32</strong> products
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
8
client/src/app/catalog/useCatalogContext.tsx
Normal file
8
client/src/app/catalog/useCatalogContext.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
import { useContext } from "react";
|
||||
import { CatalogContext } from "./CatalogContext";
|
||||
|
||||
export const useCatalogContext = () => {
|
||||
const context = useContext(CatalogContext);
|
||||
if (context === null) throw new Error("useCatalog must be used within a CatalogProvider");
|
||||
return context;
|
||||
};
|
||||
@ -10,6 +10,7 @@ import {
|
||||
} from "lucide-react";
|
||||
|
||||
import { Layout, LayoutContent, LayoutHeader } from "@/components";
|
||||
import { useGetIdentity } from "@/lib/hooks";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@ -42,383 +43,396 @@ import {
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/ui";
|
||||
import { t } from "i18next";
|
||||
|
||||
export const DashboardPage = () => {
|
||||
const { data, status } = useGetIdentity();
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<LayoutHeader />
|
||||
<LayoutContent className='grid items-start flex-1 gap-4 p-4 sm:px-6 sm:py-0 md:gap-8 lg:grid-cols-3 xl:grid-cols-3'>
|
||||
<div className='grid items-start gap-4 auto-rows-max md:gap-8 lg:col-span-2'>
|
||||
<div className='grid gap-4 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-2 xl:grid-cols-4'>
|
||||
<Card className='sm:col-span-2' x-chunk='dashboard-05-chunk-0'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle>Tus Cotizaciones</CardTitle>
|
||||
<CardDescription className='max-w-lg leading-relaxed text-balance'>
|
||||
Introducing Our Dynamic Orders Dashboard for Seamless Management and Insightful
|
||||
Analysis.
|
||||
</CardDescription>
|
||||
<LayoutContent>
|
||||
{status === "success" && (
|
||||
<div className='flex items-center'>
|
||||
<h1 className='text-lg font-semibold md:text-2xl'>{`${t("dashboard.welcome")}, ${
|
||||
data?.name
|
||||
}`}</h1>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='grid items-start flex-1 gap-4 p-4 sm:px-6 sm:py-0 md:gap-8 lg:grid-cols-3 xl:grid-cols-3'>
|
||||
<div className='grid items-start gap-4 auto-rows-max md:gap-8 lg:col-span-2'>
|
||||
<div className='grid gap-4 sm:grid-cols-2 md:grid-cols-4 lg:grid-cols-2 xl:grid-cols-4'>
|
||||
<Card className='sm:col-span-2' x-chunk='dashboard-05-chunk-0'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle>Tus Cotizaciones</CardTitle>
|
||||
<CardDescription className='max-w-lg leading-relaxed text-balance'>
|
||||
Introducing Our Dynamic Orders Dashboard for Seamless Management and Insightful
|
||||
Analysis.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter>
|
||||
<Button>Crear nueva cotización</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card x-chunk='dashboard-05-chunk-1'>
|
||||
<CardHeader className='pb-2'>
|
||||
<CardDescription>This Week</CardDescription>
|
||||
<CardTitle className='text-4xl'>$1,329</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-xs text-muted-foreground'>+25% from last week</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Progress value={25} aria-label='25% increase' />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card x-chunk='dashboard-05-chunk-2'>
|
||||
<CardHeader className='pb-2'>
|
||||
<CardDescription>This Month</CardDescription>
|
||||
<CardTitle className='text-4xl'>$5,329</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-xs text-muted-foreground'>+10% from last month</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Progress value={12} aria-label='12% increase' />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
<Tabs defaultValue='week'>
|
||||
<div className='flex items-center'>
|
||||
<TabsList>
|
||||
<TabsTrigger value='week'>Week</TabsTrigger>
|
||||
<TabsTrigger value='month'>Month</TabsTrigger>
|
||||
<TabsTrigger value='year'>Year</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className='flex items-center gap-2 ml-auto'>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='outline' size='sm' className='gap-1 text-sm h-7'>
|
||||
<ListFilter className='h-3.5 w-3.5' />
|
||||
<span className='sr-only sm:not-sr-only'>Filter</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>Filter by</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem checked>Fulfilled</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem>Declined</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem>Refunded</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button size='sm' variant='outline' className='gap-1 text-sm h-7'>
|
||||
<File className='h-3.5 w-3.5' />
|
||||
<span className='sr-only sm:not-sr-only'>Export</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent value='week'>
|
||||
<Card x-chunk='dashboard-05-chunk-3'>
|
||||
<CardHeader className='px-7'>
|
||||
<CardTitle>Orders</CardTitle>
|
||||
<CardDescription>Recent orders from your store.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Customer</TableHead>
|
||||
<TableHead className='hidden sm:table-cell'>Type</TableHead>
|
||||
<TableHead className='hidden sm:table-cell'>Status</TableHead>
|
||||
<TableHead className='hidden md:table-cell'>Date</TableHead>
|
||||
<TableHead className='text-right'>Amount</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow className='bg-accent'>
|
||||
<TableCell>
|
||||
<div className='font-medium'>Liam Johnson</div>
|
||||
<div className='hidden text-sm text-muted-foreground md:inline'>
|
||||
liam@example.com
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>Sale</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<Badge className='text-xs' variant='secondary'>
|
||||
Fulfilled
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2023-06-23</TableCell>
|
||||
<TableCell className='text-right'>$250.00</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className='font-medium'>Olivia Smith</div>
|
||||
<div className='hidden text-sm text-muted-foreground md:inline'>
|
||||
olivia@example.com
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>Refund</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<Badge className='text-xs' variant='outline'>
|
||||
Declined
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2023-06-24</TableCell>
|
||||
<TableCell className='text-right'>$150.00</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className='font-medium'>Noah Williams</div>
|
||||
<div className='hidden text-sm text-muted-foreground md:inline'>
|
||||
noah@example.com
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>Subscription</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<Badge className='text-xs' variant='secondary'>
|
||||
Fulfilled
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2023-06-25</TableCell>
|
||||
<TableCell className='text-right'>$350.00</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className='font-medium'>Emma Brown</div>
|
||||
<div className='hidden text-sm text-muted-foreground md:inline'>
|
||||
emma@example.com
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>Sale</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<Badge className='text-xs' variant='secondary'>
|
||||
Fulfilled
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2023-06-26</TableCell>
|
||||
<TableCell className='text-right'>$450.00</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className='font-medium'>Liam Johnson</div>
|
||||
<div className='hidden text-sm text-muted-foreground md:inline'>
|
||||
liam@example.com
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>Sale</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<Badge className='text-xs' variant='secondary'>
|
||||
Fulfilled
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2023-06-23</TableCell>
|
||||
<TableCell className='text-right'>$250.00</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className='font-medium'>Liam Johnson</div>
|
||||
<div className='hidden text-sm text-muted-foreground md:inline'>
|
||||
liam@example.com
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>Sale</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<Badge className='text-xs' variant='secondary'>
|
||||
Fulfilled
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2023-06-23</TableCell>
|
||||
<TableCell className='text-right'>$250.00</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className='font-medium'>Olivia Smith</div>
|
||||
<div className='hidden text-sm text-muted-foreground md:inline'>
|
||||
olivia@example.com
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>Refund</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<Badge className='text-xs' variant='outline'>
|
||||
Declined
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2023-06-24</TableCell>
|
||||
<TableCell className='text-right'>$150.00</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className='font-medium'>Emma Brown</div>
|
||||
<div className='hidden text-sm text-muted-foreground md:inline'>
|
||||
emma@example.com
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>Sale</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<Badge className='text-xs' variant='secondary'>
|
||||
Fulfilled
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2023-06-26</TableCell>
|
||||
<TableCell className='text-right'>$450.00</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div>
|
||||
<Card className='overflow-hidden' x-chunk='dashboard-05-chunk-4'>
|
||||
<CardHeader className='flex flex-row items-start bg-muted/50'>
|
||||
<div className='grid gap-0.5'>
|
||||
<CardTitle className='flex items-center gap-2 text-lg group'>
|
||||
Order Oe31b70H
|
||||
<Button
|
||||
size='icon'
|
||||
variant='outline'
|
||||
className='w-6 h-6 transition-opacity opacity-0 group-hover:opacity-100'
|
||||
>
|
||||
<Copy className='w-3 h-3' />
|
||||
<span className='sr-only'>Copy Order ID</span>
|
||||
</Button>
|
||||
</CardTitle>
|
||||
<CardDescription>Date: November 23, 2023</CardDescription>
|
||||
</div>
|
||||
<div className='flex items-center gap-1 ml-auto'>
|
||||
<Button size='sm' variant='outline' className='h-8 gap-1'>
|
||||
<Truck className='h-3.5 w-3.5' />
|
||||
<span className='lg:sr-only xl:not-sr-only xl:whitespace-nowrap'>
|
||||
Track Order
|
||||
</span>
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size='icon' variant='outline' className='w-8 h-8'>
|
||||
<MoreVertical className='h-3.5 w-3.5' />
|
||||
<span className='sr-only'>More</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem>Export</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Trash</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardFooter>
|
||||
<Button>Crear nueva cotización</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card x-chunk='dashboard-05-chunk-1'>
|
||||
<CardHeader className='pb-2'>
|
||||
<CardDescription>This Week</CardDescription>
|
||||
<CardTitle className='text-4xl'>$1,329</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-xs text-muted-foreground'>+25% from last week</div>
|
||||
<CardContent className='p-6 text-sm'>
|
||||
<div className='grid gap-3'>
|
||||
<div className='font-semibold'>Order Details</div>
|
||||
<ul className='grid gap-3'>
|
||||
<li className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground'>
|
||||
Glimmer Lamps x <span>2</span>
|
||||
</span>
|
||||
<span>$250.00</span>
|
||||
</li>
|
||||
<li className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground'>
|
||||
Aqua Filters x <span>1</span>
|
||||
</span>
|
||||
<span>$49.00</span>
|
||||
</li>
|
||||
</ul>
|
||||
<Separator className='my-2' />
|
||||
<ul className='grid gap-3'>
|
||||
<li className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground'>Subtotal</span>
|
||||
<span>$299.00</span>
|
||||
</li>
|
||||
<li className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground'>Shipping</span>
|
||||
<span>$5.00</span>
|
||||
</li>
|
||||
<li className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground'>Tax</span>
|
||||
<span>$25.00</span>
|
||||
</li>
|
||||
<li className='flex items-center justify-between font-semibold'>
|
||||
<span className='text-muted-foreground'>Total</span>
|
||||
<span>$329.00</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Separator className='my-4' />
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div className='grid gap-3'>
|
||||
<div className='font-semibold'>Shipping Information</div>
|
||||
<address className='grid gap-0.5 not-italic text-muted-foreground'>
|
||||
<span>Liam Johnson</span>
|
||||
<span>1234 Main St.</span>
|
||||
<span>Anytown, CA 12345</span>
|
||||
</address>
|
||||
</div>
|
||||
<div className='grid gap-3 auto-rows-max'>
|
||||
<div className='font-semibold'>Billing Information</div>
|
||||
<div className='text-muted-foreground'>Same as shipping address</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className='my-4' />
|
||||
<div className='grid gap-3'>
|
||||
<div className='font-semibold'>Customer Information</div>
|
||||
<dl className='grid gap-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<dt className='text-muted-foreground'>Customer</dt>
|
||||
<dd>Liam Johnson</dd>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<dt className='text-muted-foreground'>Email</dt>
|
||||
<dd>
|
||||
<a href='mailto:'>liam@acme.com</a>
|
||||
</dd>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<dt className='text-muted-foreground'>Phone</dt>
|
||||
<dd>
|
||||
<a href='tel:'>+1 234 567 890</a>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<Separator className='my-4' />
|
||||
<div className='grid gap-3'>
|
||||
<div className='font-semibold'>Payment Information</div>
|
||||
<dl className='grid gap-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<dt className='flex items-center gap-1 text-muted-foreground'>
|
||||
<CreditCard className='w-4 h-4' />
|
||||
Visa
|
||||
</dt>
|
||||
<dd>**** **** **** 4532</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Progress value={25} aria-label='25% increase' />
|
||||
</CardFooter>
|
||||
</Card>
|
||||
<Card x-chunk='dashboard-05-chunk-2'>
|
||||
<CardHeader className='pb-2'>
|
||||
<CardDescription>This Month</CardDescription>
|
||||
<CardTitle className='text-4xl'>$5,329</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className='text-xs text-muted-foreground'>+10% from last month</div>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Progress value={12} aria-label='12% increase' />
|
||||
<CardFooter className='flex flex-row items-center px-6 py-3 border-t bg-muted/50'>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
Updated <time dateTime='2023-11-23'>November 23, 2023</time>
|
||||
</div>
|
||||
<Pagination className='w-auto ml-auto mr-0'>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<Button size='icon' variant='outline' className='w-6 h-6'>
|
||||
<ChevronLeft className='h-3.5 w-3.5' />
|
||||
<span className='sr-only'>Previous Order</span>
|
||||
</Button>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<Button size='icon' variant='outline' className='w-6 h-6'>
|
||||
<ChevronRight className='h-3.5 w-3.5' />
|
||||
<span className='sr-only'>Next Order</span>
|
||||
</Button>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
<Tabs defaultValue='week'>
|
||||
<div className='flex items-center'>
|
||||
<TabsList>
|
||||
<TabsTrigger value='week'>Week</TabsTrigger>
|
||||
<TabsTrigger value='month'>Month</TabsTrigger>
|
||||
<TabsTrigger value='year'>Year</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className='flex items-center gap-2 ml-auto'>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='outline' size='sm' className='gap-1 text-sm h-7'>
|
||||
<ListFilter className='h-3.5 w-3.5' />
|
||||
<span className='sr-only sm:not-sr-only'>Filter</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>Filter by</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem checked>Fulfilled</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem>Declined</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem>Refunded</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button size='sm' variant='outline' className='gap-1 text-sm h-7'>
|
||||
<File className='h-3.5 w-3.5' />
|
||||
<span className='sr-only sm:not-sr-only'>Export</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TabsContent value='week'>
|
||||
<Card x-chunk='dashboard-05-chunk-3'>
|
||||
<CardHeader className='px-7'>
|
||||
<CardTitle>Orders</CardTitle>
|
||||
<CardDescription>Recent orders from your store.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Customer</TableHead>
|
||||
<TableHead className='hidden sm:table-cell'>Type</TableHead>
|
||||
<TableHead className='hidden sm:table-cell'>Status</TableHead>
|
||||
<TableHead className='hidden md:table-cell'>Date</TableHead>
|
||||
<TableHead className='text-right'>Amount</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow className='bg-accent'>
|
||||
<TableCell>
|
||||
<div className='font-medium'>Liam Johnson</div>
|
||||
<div className='hidden text-sm text-muted-foreground md:inline'>
|
||||
liam@example.com
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>Sale</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<Badge className='text-xs' variant='secondary'>
|
||||
Fulfilled
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2023-06-23</TableCell>
|
||||
<TableCell className='text-right'>$250.00</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className='font-medium'>Olivia Smith</div>
|
||||
<div className='hidden text-sm text-muted-foreground md:inline'>
|
||||
olivia@example.com
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>Refund</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<Badge className='text-xs' variant='outline'>
|
||||
Declined
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2023-06-24</TableCell>
|
||||
<TableCell className='text-right'>$150.00</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className='font-medium'>Noah Williams</div>
|
||||
<div className='hidden text-sm text-muted-foreground md:inline'>
|
||||
noah@example.com
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>Subscription</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<Badge className='text-xs' variant='secondary'>
|
||||
Fulfilled
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2023-06-25</TableCell>
|
||||
<TableCell className='text-right'>$350.00</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className='font-medium'>Emma Brown</div>
|
||||
<div className='hidden text-sm text-muted-foreground md:inline'>
|
||||
emma@example.com
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>Sale</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<Badge className='text-xs' variant='secondary'>
|
||||
Fulfilled
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2023-06-26</TableCell>
|
||||
<TableCell className='text-right'>$450.00</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className='font-medium'>Liam Johnson</div>
|
||||
<div className='hidden text-sm text-muted-foreground md:inline'>
|
||||
liam@example.com
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>Sale</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<Badge className='text-xs' variant='secondary'>
|
||||
Fulfilled
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2023-06-23</TableCell>
|
||||
<TableCell className='text-right'>$250.00</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className='font-medium'>Liam Johnson</div>
|
||||
<div className='hidden text-sm text-muted-foreground md:inline'>
|
||||
liam@example.com
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>Sale</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<Badge className='text-xs' variant='secondary'>
|
||||
Fulfilled
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2023-06-23</TableCell>
|
||||
<TableCell className='text-right'>$250.00</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className='font-medium'>Olivia Smith</div>
|
||||
<div className='hidden text-sm text-muted-foreground md:inline'>
|
||||
olivia@example.com
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>Refund</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<Badge className='text-xs' variant='outline'>
|
||||
Declined
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2023-06-24</TableCell>
|
||||
<TableCell className='text-right'>$150.00</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<div className='font-medium'>Emma Brown</div>
|
||||
<div className='hidden text-sm text-muted-foreground md:inline'>
|
||||
emma@example.com
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>Sale</TableCell>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<Badge className='text-xs' variant='secondary'>
|
||||
Fulfilled
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2023-06-26</TableCell>
|
||||
<TableCell className='text-right'>$450.00</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div>
|
||||
<Card className='overflow-hidden' x-chunk='dashboard-05-chunk-4'>
|
||||
<CardHeader className='flex flex-row items-start bg-muted/50'>
|
||||
<div className='grid gap-0.5'>
|
||||
<CardTitle className='flex items-center gap-2 text-lg group'>
|
||||
Order Oe31b70H
|
||||
<Button
|
||||
size='icon'
|
||||
variant='outline'
|
||||
className='w-6 h-6 transition-opacity opacity-0 group-hover:opacity-100'
|
||||
>
|
||||
<Copy className='w-3 h-3' />
|
||||
<span className='sr-only'>Copy Order ID</span>
|
||||
</Button>
|
||||
</CardTitle>
|
||||
<CardDescription>Date: November 23, 2023</CardDescription>
|
||||
</div>
|
||||
<div className='flex items-center gap-1 ml-auto'>
|
||||
<Button size='sm' variant='outline' className='h-8 gap-1'>
|
||||
<Truck className='h-3.5 w-3.5' />
|
||||
<span className='lg:sr-only xl:not-sr-only xl:whitespace-nowrap'>
|
||||
Track Order
|
||||
</span>
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size='icon' variant='outline' className='w-8 h-8'>
|
||||
<MoreVertical className='h-3.5 w-3.5' />
|
||||
<span className='sr-only'>More</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem>Export</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Trash</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className='p-6 text-sm'>
|
||||
<div className='grid gap-3'>
|
||||
<div className='font-semibold'>Order Details</div>
|
||||
<ul className='grid gap-3'>
|
||||
<li className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground'>
|
||||
Glimmer Lamps x <span>2</span>
|
||||
</span>
|
||||
<span>$250.00</span>
|
||||
</li>
|
||||
<li className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground'>
|
||||
Aqua Filters x <span>1</span>
|
||||
</span>
|
||||
<span>$49.00</span>
|
||||
</li>
|
||||
</ul>
|
||||
<Separator className='my-2' />
|
||||
<ul className='grid gap-3'>
|
||||
<li className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground'>Subtotal</span>
|
||||
<span>$299.00</span>
|
||||
</li>
|
||||
<li className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground'>Shipping</span>
|
||||
<span>$5.00</span>
|
||||
</li>
|
||||
<li className='flex items-center justify-between'>
|
||||
<span className='text-muted-foreground'>Tax</span>
|
||||
<span>$25.00</span>
|
||||
</li>
|
||||
<li className='flex items-center justify-between font-semibold'>
|
||||
<span className='text-muted-foreground'>Total</span>
|
||||
<span>$329.00</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<Separator className='my-4' />
|
||||
<div className='grid grid-cols-2 gap-4'>
|
||||
<div className='grid gap-3'>
|
||||
<div className='font-semibold'>Shipping Information</div>
|
||||
<address className='grid gap-0.5 not-italic text-muted-foreground'>
|
||||
<span>Liam Johnson</span>
|
||||
<span>1234 Main St.</span>
|
||||
<span>Anytown, CA 12345</span>
|
||||
</address>
|
||||
</div>
|
||||
<div className='grid gap-3 auto-rows-max'>
|
||||
<div className='font-semibold'>Billing Information</div>
|
||||
<div className='text-muted-foreground'>Same as shipping address</div>
|
||||
</div>
|
||||
</div>
|
||||
<Separator className='my-4' />
|
||||
<div className='grid gap-3'>
|
||||
<div className='font-semibold'>Customer Information</div>
|
||||
<dl className='grid gap-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<dt className='text-muted-foreground'>Customer</dt>
|
||||
<dd>Liam Johnson</dd>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<dt className='text-muted-foreground'>Email</dt>
|
||||
<dd>
|
||||
<a href='mailto:'>liam@acme.com</a>
|
||||
</dd>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<dt className='text-muted-foreground'>Phone</dt>
|
||||
<dd>
|
||||
<a href='tel:'>+1 234 567 890</a>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<Separator className='my-4' />
|
||||
<div className='grid gap-3'>
|
||||
<div className='font-semibold'>Payment Information</div>
|
||||
<dl className='grid gap-3'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<dt className='flex items-center gap-1 text-muted-foreground'>
|
||||
<CreditCard className='w-4 h-4' />
|
||||
Visa
|
||||
</dt>
|
||||
<dd>**** **** **** 4532</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className='flex flex-row items-center px-6 py-3 border-t bg-muted/50'>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
Updated <time dateTime='2023-11-23'>November 23, 2023</time>
|
||||
</div>
|
||||
<Pagination className='w-auto ml-auto mr-0'>
|
||||
<PaginationContent>
|
||||
<PaginationItem>
|
||||
<Button size='icon' variant='outline' className='w-6 h-6'>
|
||||
<ChevronLeft className='h-3.5 w-3.5' />
|
||||
<span className='sr-only'>Previous Order</span>
|
||||
</Button>
|
||||
</PaginationItem>
|
||||
<PaginationItem>
|
||||
<Button size='icon' variant='outline' className='w-6 h-6'>
|
||||
<ChevronRight className='h-3.5 w-3.5' />
|
||||
<span className='sr-only'>Next Order</span>
|
||||
</Button>
|
||||
</PaginationItem>
|
||||
</PaginationContent>
|
||||
</Pagination>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</LayoutContent>
|
||||
</Layout>
|
||||
|
||||
@ -1 +1,2 @@
|
||||
export * from "./layout";
|
||||
export * from "./list";
|
||||
|
||||
11
client/src/app/dealers/layout.tsx
Normal file
11
client/src/app/dealers/layout.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
import { Layout, LayoutContent, LayoutHeader } from "@/components";
|
||||
import { PropsWithChildren } from "react";
|
||||
|
||||
export const DealerLayout = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<Layout>
|
||||
<LayoutHeader />
|
||||
<LayoutContent>{children}</LayoutContent>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
@ -1,14 +1,311 @@
|
||||
import { Layout, LayoutContent, LayoutHeader } from "@/components";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/ui";
|
||||
|
||||
import { File, ListFilter, MoreHorizontal, PlusCircle } from "lucide-react";
|
||||
|
||||
import { Trans } from "react-i18next";
|
||||
|
||||
export const DealersList = () => {
|
||||
return (
|
||||
<Layout>
|
||||
<LayoutHeader />
|
||||
<LayoutContent>
|
||||
<>
|
||||
<Tabs defaultValue='all'>
|
||||
<div className='flex items-center'>
|
||||
<h1 className='text-lg font-semibold md:text-2xl'>Dealers</h1>
|
||||
<TabsList>
|
||||
<TabsTrigger value='all'>All</TabsTrigger>
|
||||
<TabsTrigger value='active'>Active</TabsTrigger>
|
||||
<TabsTrigger value='draft'>Draft</TabsTrigger>
|
||||
<TabsTrigger value='archived' className='hidden sm:flex'>
|
||||
Archived
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className='flex items-center gap-2 ml-auto'>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant='outline' size='sm' className='h-8 gap-1'>
|
||||
<ListFilter className='h-3.5 w-3.5' />
|
||||
<span className='sr-only sm:not-sr-only sm:whitespace-nowrap'>Filter</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>Filter by</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem checked>Active</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem>Draft</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem>Archived</DropdownMenuCheckboxItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button size='sm' variant='outline' className='h-8 gap-1'>
|
||||
<File className='h-3.5 w-3.5' />
|
||||
<span className='sr-only sm:not-sr-only sm:whitespace-nowrap'>Export</span>
|
||||
</Button>
|
||||
<Button size='sm' className='h-8 gap-1'>
|
||||
<PlusCircle className='h-3.5 w-3.5' />
|
||||
<span className='sr-only sm:not-sr-only sm:whitespace-nowrap'>Add Product</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutContent>
|
||||
</Layout>
|
||||
<TabsContent value='all'>
|
||||
<Card x-chunk='dashboard-06-chunk-0'>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey='catalog.title' />
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your products and view their sales performance.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className='hidden w-[100px] sm:table-cell'>
|
||||
<span className='sr-only'>Image</span>
|
||||
</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead className='hidden md:table-cell'>Price</TableHead>
|
||||
<TableHead className='hidden md:table-cell'>Total Sales</TableHead>
|
||||
<TableHead className='hidden md:table-cell'>Created at</TableHead>
|
||||
<TableHead>
|
||||
<span className='sr-only'>Actions</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<img
|
||||
alt='Product image'
|
||||
className='object-cover rounded-md aspect-square'
|
||||
height='64'
|
||||
src='/placeholder.svg'
|
||||
width='64'
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='font-medium'>Laser Lemonade Machine</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant='outline'>Draft</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>$499.99</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>25</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2023-07-12 10:42 AM</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button aria-haspopup='true' size='icon' variant='ghost'>
|
||||
<MoreHorizontal className='w-4 h-4' />
|
||||
<span className='sr-only'>Toggle menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem>Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<img
|
||||
alt='Product image'
|
||||
className='object-cover rounded-md aspect-square'
|
||||
height='64'
|
||||
src='/placeholder.svg'
|
||||
width='64'
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='font-medium'>Hypernova Headphones</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant='outline'>Active</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>$129.99</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>100</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2023-10-18 03:21 PM</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button aria-haspopup='true' size='icon' variant='ghost'>
|
||||
<MoreHorizontal className='w-4 h-4' />
|
||||
<span className='sr-only'>Toggle menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem>Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<img
|
||||
alt='Product image'
|
||||
className='object-cover rounded-md aspect-square'
|
||||
height='64'
|
||||
src='/placeholder.svg'
|
||||
width='64'
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='font-medium'>AeroGlow Desk Lamp</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant='outline'>Active</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>$39.99</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>50</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2023-11-29 08:15 AM</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button aria-haspopup='true' size='icon' variant='ghost'>
|
||||
<MoreHorizontal className='w-4 h-4' />
|
||||
<span className='sr-only'>Toggle menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem>Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<img
|
||||
alt='Product image'
|
||||
className='object-cover rounded-md aspect-square'
|
||||
height='64'
|
||||
src='/placeholder.svg'
|
||||
width='64'
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='font-medium'>TechTonic Energy Drink</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant='secondary'>Draft</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>$2.99</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>0</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2023-12-25 11:59 PM</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button aria-haspopup='true' size='icon' variant='ghost'>
|
||||
<MoreHorizontal className='w-4 h-4' />
|
||||
<span className='sr-only'>Toggle menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem>Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<img
|
||||
alt='Product image'
|
||||
className='object-cover rounded-md aspect-square'
|
||||
height='64'
|
||||
src='/placeholder.svg'
|
||||
width='64'
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='font-medium'>Gamer Gear Pro Controller</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant='outline'>Active</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>$59.99</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>75</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2024-01-01 12:00 AM</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button aria-haspopup='true' size='icon' variant='ghost'>
|
||||
<MoreHorizontal className='w-4 h-4' />
|
||||
<span className='sr-only'>Toggle menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem>Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className='hidden sm:table-cell'>
|
||||
<img
|
||||
alt='Product image'
|
||||
className='object-cover rounded-md aspect-square'
|
||||
height='64'
|
||||
src='/placeholder.svg'
|
||||
width='64'
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className='font-medium'>Luminous VR Headset</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant='outline'>Active</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>$199.99</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>30</TableCell>
|
||||
<TableCell className='hidden md:table-cell'>2024-02-14 02:14 PM</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button aria-haspopup='true' size='icon' variant='ghost'>
|
||||
<MoreHorizontal className='w-4 h-4' />
|
||||
<span className='sr-only'>Toggle menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align='end'>
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem>Edit</DropdownMenuItem>
|
||||
<DropdownMenuItem>Delete</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<div className='text-xs text-muted-foreground'>
|
||||
Showing <strong>1-10</strong> of <strong>32</strong> products
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
14
client/src/components/ButtonGroup/ButtonGroup.tsx
Normal file
14
client/src/components/ButtonGroup/ButtonGroup.tsx
Normal file
@ -0,0 +1,14 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import React from "react";
|
||||
|
||||
export const ButtonGroup = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("ml-auto flex items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ButtonGroup.displayName = "ButtonGroup";
|
||||
1
client/src/components/ButtonGroup/index.ts
Normal file
1
client/src/components/ButtonGroup/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./ButtonGroup";
|
||||
31
client/src/components/CustomButtons/BackHistoryButton.tsx
Normal file
31
client/src/components/CustomButtons/BackHistoryButton.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { Button, ButtonProps } from "@/ui";
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { To, useNavigate } from "react-router-dom";
|
||||
|
||||
export interface BackHistoryButtonProps extends ButtonProps {
|
||||
label?: string;
|
||||
url?: To;
|
||||
}
|
||||
|
||||
export const BackHistoryButton = ({
|
||||
label = "Volver atrás",
|
||||
size,
|
||||
url = undefined,
|
||||
...props
|
||||
}: BackHistoryButtonProps): JSX.Element => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
url ? navigate(url) : navigate(-1);
|
||||
}}
|
||||
size={size}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<span className={size === "icon" ? "sr-only" : "ml-2"}>{label}</span>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
1
client/src/components/CustomButtons/index.ts
Normal file
1
client/src/components/CustomButtons/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./BackHistoryButton";
|
||||
97
client/src/components/DataTable/DataTable.tsx
Normal file
97
client/src/components/DataTable/DataTable.tsx
Normal file
@ -0,0 +1,97 @@
|
||||
import { ColumnDef, Table as ReactTable, flexRender } from "@tanstack/react-table";
|
||||
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCaption,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/ui/table";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
import { Card, CardContent, CardFooter, CardHeader } from "@/ui";
|
||||
import { DataTableColumnHeader } from "./DataTableColumnHeader";
|
||||
import { DataTablePagination, DataTablePaginationProps } from "./DataTablePagination";
|
||||
import { DataTableToolbar } from "./DataTableToolbar";
|
||||
|
||||
export type DataTableColumnProps<TData, TValue> = ColumnDef<TData, TValue>;
|
||||
|
||||
export type DataTablePaginationOptionsProps<TData> = Pick<
|
||||
DataTablePaginationProps<TData>,
|
||||
"visible"
|
||||
>;
|
||||
|
||||
export type DataTableProps<TData> = {
|
||||
table: ReactTable<TData>;
|
||||
caption?: ReactNode;
|
||||
className?: string;
|
||||
|
||||
paginationOptions?: DataTablePaginationOptionsProps<TData>;
|
||||
};
|
||||
|
||||
export function DataTable<TData>({ table, caption, paginationOptions }: DataTableProps<TData>) {
|
||||
return (
|
||||
<>
|
||||
<DataTableToolbar table={table} />
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<DataTablePagination
|
||||
className='flex-1'
|
||||
visible={paginationOptions?.visible}
|
||||
table={table}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className='pt-6'>
|
||||
<Table>
|
||||
{typeof caption !== "undefined" && <TableCaption>{caption}</TableCaption>}
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
style={{ width: header.getSize() }}
|
||||
>
|
||||
<DataTableColumnHeader table={table} header={header} />
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} data-state={row.getIsSelected() && "selected"}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={table.getAllColumns.length} className='h-24 text-center'>
|
||||
No hay datos para mostrar
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<DataTablePagination
|
||||
className='flex-1'
|
||||
visible={paginationOptions?.visible}
|
||||
table={table}
|
||||
/>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
123
client/src/components/DataTable/DataTableColumnHeader.tsx
Normal file
123
client/src/components/DataTable/DataTableColumnHeader.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
import { Header, Table, flexRender } from "@tanstack/react-table";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
Separator,
|
||||
} from "@/ui";
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
ArrowDownUpIcon,
|
||||
ArrowUpIcon,
|
||||
EyeOffIcon,
|
||||
} from "lucide-react";
|
||||
|
||||
interface DataTableColumnHeaderProps<TData, TValue>
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
table: Table<TData>;
|
||||
header: Header<TData, TValue>;
|
||||
}
|
||||
|
||||
export function DataTableColumnHeader<TData, TValue>({
|
||||
table,
|
||||
header,
|
||||
className,
|
||||
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||
if (!header.column.getCanSort()) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"data-[state=open]:bg-accent font-semiboldw text-muted-foreground uppercase",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</div>
|
||||
{header.column.getCanResize() && (
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className={cn(
|
||||
"absolute top-0 h-full w-[5px] bg-black/10 cursor-col-resize",
|
||||
table.options.columnResizeDirection,
|
||||
header.column.getIsResizing() ? "bg-primary opacity-100" : "",
|
||||
)}
|
||||
{...{
|
||||
onDoubleClick: () => header.column.resetSize(),
|
||||
onMouseDown: header.getResizeHandler(),
|
||||
onTouchStart: header.getResizeHandler(),
|
||||
style: {
|
||||
transform:
|
||||
table.options.columnResizeMode === "onEnd" &&
|
||||
header.column.getIsResizing()
|
||||
? `translateX(${
|
||||
(table.options.columnResizeDirection === "rtl"
|
||||
? -1
|
||||
: 1) *
|
||||
(table.getState().columnSizingInfo.deltaOffset ?? 0)
|
||||
}px)`
|
||||
: "",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center space-x-2", className)}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={
|
||||
header.column.getIsSorted() === "desc"
|
||||
? "En orden descendente. Click para ordenar ascendentemente."
|
||||
: header.column.getIsSorted() === "asc"
|
||||
? "En order ascendente. Click para ordenar descendentemente."
|
||||
: "Sin orden. Click para ordenar ascendentemente."
|
||||
}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="-ml-3 h-8 data-[state=open]:bg-accent font-bold text-muted-foreground"
|
||||
>
|
||||
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||
{header.column.getIsSorted() === "desc" ? (
|
||||
<ArrowDownIcon className="w-4 h-4 ml-2" />
|
||||
) : header.column.getIsSorted() === "asc" ? (
|
||||
<ArrowUpIcon className="w-4 h-4 ml-2" />
|
||||
) : (
|
||||
<ArrowDownUpIcon className="w-4 h-4 ml-2 text-muted-foreground/30" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem onClick={() => header.column.toggleSorting(false)}>
|
||||
<ArrowUpIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Ascendente
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => header.column.toggleSorting(true)}>
|
||||
<ArrowDownIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Descendente
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{header.column.getCanHide() && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => header.column.toggleVisibility(false)}
|
||||
>
|
||||
<EyeOffIcon className="mr-2 h-3.5 w-3.5 text-muted-foreground/70" />
|
||||
Ocultar
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
149
client/src/components/DataTable/DataTableFacetedFilter.tsx
Normal file
149
client/src/components/DataTable/DataTableFacetedFilter.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import { Column } from "@tanstack/react-table"
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
} from "@/ui"
|
||||
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/ui"
|
||||
|
||||
import { Separator } from "@/ui"
|
||||
import { CheckIcon, PlusCircleIcon } from 'lucide-react'
|
||||
|
||||
interface DataTableFacetedFilterProps<TData, TValue> {
|
||||
column?: Column<TData, TValue>
|
||||
title?: string
|
||||
options: {
|
||||
label: string
|
||||
value: string
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
}[]
|
||||
}
|
||||
|
||||
export function DataTableFacetedFilter<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
options,
|
||||
}: DataTableFacetedFilterProps<TData, TValue>) {
|
||||
const facets = column?.getFacetedUniqueValues()
|
||||
const selectedValues = new Set(column?.getFilterValue() as string[])
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-8 border-dashed">
|
||||
<PlusCircleIcon className="w-4 h-4 mr-2" />
|
||||
{title}
|
||||
{selectedValues?.size > 0 && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="h-4 mx-2" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="px-1 font-normal rounded-sm lg:hidden"
|
||||
>
|
||||
{selectedValues.size}
|
||||
</Badge>
|
||||
<div className="hidden space-x-1 lg:flex">
|
||||
{selectedValues.size > 2 ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="px-1 font-normal rounded-sm"
|
||||
>
|
||||
{selectedValues.size} selected
|
||||
</Badge>
|
||||
) : (
|
||||
options
|
||||
.filter((option) => selectedValues.has(option.value))
|
||||
.map((option) => (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
key={option.value}
|
||||
className="px-1 font-normal rounded-sm"
|
||||
>
|
||||
{option.label}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandInput placeholder={title} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => {
|
||||
const isSelected = selectedValues.has(option.value)
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
onSelect={() => {
|
||||
if (isSelected) {
|
||||
selectedValues.delete(option.value)
|
||||
} else {
|
||||
selectedValues.add(option.value)
|
||||
}
|
||||
const filterValues = Array.from(selectedValues)
|
||||
column?.setFilterValue(
|
||||
filterValues.length ? filterValues : undefined
|
||||
)
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
|
||||
isSelected
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "opacity-50 [&_svg]:invisible"
|
||||
)}
|
||||
>
|
||||
<CheckIcon className={cn("h-4 w-4")} />
|
||||
</div>
|
||||
{option.icon && (
|
||||
<option.icon className="w-4 h-4 mr-2 text-muted-foreground" />
|
||||
)}
|
||||
<span>{option.label}</span>
|
||||
{facets?.get(option.value) && (
|
||||
<span className="flex items-center justify-center w-4 h-4 ml-auto font-mono text-xs">
|
||||
{facets.get(option.value)}
|
||||
</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
</CommandGroup>
|
||||
{selectedValues.size > 0 && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
onSelect={() => column?.setFilterValue(undefined)}
|
||||
className="justify-center text-center"
|
||||
>
|
||||
Clear filters
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
102
client/src/components/DataTable/DataTablePagination.tsx
Normal file
102
client/src/components/DataTable/DataTablePagination.tsx
Normal file
@ -0,0 +1,102 @@
|
||||
import { DEFAULT_PAGE_SIZES, INITIAL_PAGE_INDEX } from "@/lib/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/ui";
|
||||
import { Table } from "@tanstack/react-table";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronsLeftIcon,
|
||||
ChevronsRightIcon,
|
||||
} from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export type DataTablePaginationProps<TData> = {
|
||||
table: Table<TData>;
|
||||
className?: string;
|
||||
visible?: boolean | "auto";
|
||||
};
|
||||
|
||||
export function DataTablePagination<TData>({
|
||||
table,
|
||||
className,
|
||||
visible = "auto",
|
||||
}: DataTablePaginationProps<TData>) {
|
||||
const isVisible = useMemo(() => visible === true, [visible]);
|
||||
const isAuto = useMemo(() => visible === "auto", [visible]);
|
||||
|
||||
if (!isVisible || (isAuto && table.getPageCount() < 1)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center justify-between px-2", className)}>
|
||||
<div className='flex-1 text-base text-muted-foreground'>
|
||||
{table.getFilteredSelectedRowModel().rows.length} de{" "}
|
||||
{table.getFilteredRowModel().rows.length} filas(s) seleccionadas.
|
||||
</div>
|
||||
<div className='flex items-center space-x-6 lg:space-x-8'>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<p className='text-base font-medium'>Filas por página</p>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className='h-8 w-[70px]'>
|
||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side='top'>
|
||||
{DEFAULT_PAGE_SIZES.map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className='flex w-[100px] items-center justify-center text-base font-medium'>
|
||||
Página {table.getState().pagination.pageIndex + 1} de {table.getPageCount()}
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='hidden w-8 h-8 p-0 lg:flex'
|
||||
onClick={() => table.setPageIndex(INITIAL_PAGE_INDEX)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className='sr-only'>Ir a la primera página</span>
|
||||
<ChevronsLeftIcon className='w-4 h-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-8 h-8 p-0'
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className='sr-only'>Ir a la página anterior</span>
|
||||
<ChevronLeftIcon className='w-4 h-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='w-8 h-8 p-0'
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className='sr-only'>Ir a la página siguiente</span>
|
||||
<ChevronRightIcon className='w-4 h-4' />
|
||||
</Button>
|
||||
<Button
|
||||
variant='outline'
|
||||
className='hidden w-8 h-8 p-0 lg:flex'
|
||||
onClick={() => table.setPageIndex(table.getPageCount() + 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className='sr-only'>Ir a la última página</span>
|
||||
<ChevronsRightIcon className='w-4 h-4' />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
client/src/components/DataTable/DataTableRowActions.tsx
Normal file
75
client/src/components/DataTable/DataTableRowActions.tsx
Normal file
@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/ui";
|
||||
import { CellContext } from "@tanstack/react-table";
|
||||
import { MoreHorizontalIcon } from "lucide-react";
|
||||
|
||||
type DataTableRowActionContext<TData, TValue> = CellContext<TData, TValue>;
|
||||
|
||||
export type DataTablaRowActionFunction<TData, TValue> = (
|
||||
props: DataTableRowActionContext<TData, TValue>,
|
||||
) => DataTableRowActionDefinition<TData, TValue>[];
|
||||
|
||||
export type DataTableRowActionDefinition<TData, TValue> = {
|
||||
label: string | "-";
|
||||
shortcut?: string;
|
||||
onClick?: (
|
||||
props: DataTableRowActionContext<TData, TValue>,
|
||||
e: React.BaseSyntheticEvent,
|
||||
) => void;
|
||||
};
|
||||
|
||||
export type DataTableRowActionsProps<TData, TValue> = {
|
||||
props: DataTableRowActionContext<TData, TValue>;
|
||||
actions?: DataTablaRowActionFunction<TData, TValue>;
|
||||
};
|
||||
|
||||
export function DataTableRowActions<TData, TValue>({
|
||||
actions,
|
||||
...props
|
||||
}: DataTableRowActionsProps<TData, TValue>) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-haspopup="true"
|
||||
size="icon"
|
||||
variant="link"
|
||||
className="w-4 h-4 translate-y-[2px]"
|
||||
>
|
||||
<MoreHorizontalIcon className="w-4 h-4" />
|
||||
<span className="sr-only">Abrir menú</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Acciones</DropdownMenuLabel>
|
||||
{actions &&
|
||||
actions(props).map((action, index) =>
|
||||
action.label === "-" ? (
|
||||
<DropdownMenuSeparator key={index} />
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
key={index}
|
||||
onClick={(event) =>
|
||||
action.onClick ? action.onClick(props, event) : null
|
||||
}
|
||||
>
|
||||
{action.label}
|
||||
<DropdownMenuShortcut>{action.shortcut}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
),
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/ui";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { GripVerticalIcon } from "lucide-react";
|
||||
|
||||
export const DataTableRowDragHandleCell = ({ rowId }: { rowId: string }) => {
|
||||
const { attributes, listeners, isDragging } = useSortable({
|
||||
id: rowId,
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}}
|
||||
size="icon"
|
||||
variant="link"
|
||||
className={cn(
|
||||
isDragging ? "cursor-grabbing" : "cursor-grab",
|
||||
"w-4 h-4 translate-y-[2px]"
|
||||
)}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVerticalIcon className="w-4 h-4" />
|
||||
<span className="sr-only">Mover fila</span>
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
36
client/src/components/DataTable/DataTableSelectionColumn.tsx
Normal file
36
client/src/components/DataTable/DataTableSelectionColumn.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { Checkbox } from "@/ui";
|
||||
import { DataTableColumnProps } from "./DataTable";
|
||||
|
||||
export function getDataTableSelectionColumn<
|
||||
TData,
|
||||
TError,
|
||||
>(): DataTableColumnProps<TData, TError> {
|
||||
return {
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
id="select-all"
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Seleccionar todo"
|
||||
className="translate-y-[2px]"
|
||||
/>
|
||||
),
|
||||
cell: ({ row, table }) => (
|
||||
<Checkbox
|
||||
id={`select-row-${row.id}`}
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => {
|
||||
row.toggleSelected(!!value);
|
||||
}}
|
||||
aria-label="Seleccionar file"
|
||||
className="translate-y-[2px]"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
};
|
||||
}
|
||||
149
client/src/components/DataTable/DataTableSkeleton.tsx
Normal file
149
client/src/components/DataTable/DataTableSkeleton.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import {
|
||||
Skeleton,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/ui";
|
||||
|
||||
interface DataTableSkeletonProps {
|
||||
/**
|
||||
* The number of columns in the table.
|
||||
* @type number
|
||||
*/
|
||||
columnCount: number;
|
||||
|
||||
/**
|
||||
* The number of rows in the table.
|
||||
* @default 10
|
||||
* @type number | undefined
|
||||
*/
|
||||
rowCount?: number;
|
||||
|
||||
/**
|
||||
* The number of searchable columns in the table.
|
||||
* @default 0
|
||||
* @type number | undefined
|
||||
*/
|
||||
searchableColumnCount?: number;
|
||||
|
||||
/**
|
||||
* The number of filterable columns in the table.
|
||||
* @default 0
|
||||
* @type number | undefined
|
||||
*/
|
||||
filterableColumnCount?: number;
|
||||
|
||||
/**
|
||||
* Flag to show the table view options.
|
||||
* @default undefined
|
||||
* @type boolean | undefined
|
||||
*/
|
||||
showViewOptions?: boolean;
|
||||
|
||||
/**
|
||||
* The width of each cell in the table.
|
||||
* The length of the array should be equal to the columnCount.
|
||||
* Any valid CSS width value is accepted.
|
||||
* @default ["auto"]
|
||||
* @type string[] | undefined
|
||||
*/
|
||||
cellWidths?: string[];
|
||||
|
||||
/**
|
||||
* Flag to prevent the table from shrinking to fit the content.
|
||||
* @default false
|
||||
* @type boolean | undefined
|
||||
*/
|
||||
shrinkZero?: boolean;
|
||||
}
|
||||
|
||||
export function DataTableSkeleton({
|
||||
columnCount,
|
||||
rowCount = 10,
|
||||
searchableColumnCount = 0,
|
||||
filterableColumnCount = 0,
|
||||
showViewOptions = true,
|
||||
cellWidths = ["auto"],
|
||||
shrinkZero = false,
|
||||
}: DataTableSkeletonProps) {
|
||||
return (
|
||||
<div className="w-full space-y-3 overflow-auto">
|
||||
<div className="flex items-center justify-between w-full p-1 space-x-2 overflow-auto">
|
||||
<div className="flex items-center flex-1 space-x-2">
|
||||
{searchableColumnCount > 0
|
||||
? Array.from({ length: searchableColumnCount }).map((_, i) => (
|
||||
<Skeleton key={i} className="w-40 h-7 lg:w-60" />
|
||||
))
|
||||
: null}
|
||||
{filterableColumnCount > 0
|
||||
? Array.from({ length: filterableColumnCount }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-7 w-[4.5rem] border-dashed" />
|
||||
))
|
||||
: null}
|
||||
</div>
|
||||
{showViewOptions ? (
|
||||
<Skeleton className="ml-auto hidden h-7 w-[4.5rem] lg:flex" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{Array.from({ length: 1 }).map((_, i) => (
|
||||
<TableRow key={i} className="hover:bg-transparent">
|
||||
{Array.from({ length: columnCount }).map((_, j) => (
|
||||
<TableHead
|
||||
key={j}
|
||||
style={{
|
||||
width: cellWidths[j],
|
||||
minWidth: shrinkZero ? cellWidths[j] : "auto",
|
||||
}}
|
||||
>
|
||||
<Skeleton className="w-full h-6" />
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: rowCount }).map((_, i) => (
|
||||
<TableRow key={i} className="hover:bg-transparent">
|
||||
{Array.from({ length: columnCount }).map((_, j) => (
|
||||
<TableCell
|
||||
key={j}
|
||||
style={{
|
||||
width: cellWidths[j],
|
||||
minWidth: shrinkZero ? cellWidths[j] : "auto",
|
||||
}}
|
||||
>
|
||||
<Skeleton className="w-full h-6" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex flex-col-reverse items-center justify-between w-full gap-4 px-2 py-1 overflow-auto sm:flex-row sm:gap-8">
|
||||
<Skeleton className="w-40 h-8" />
|
||||
<div className="flex flex-col-reverse items-center gap-4 sm:flex-row sm:gap-6 lg:gap-8">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Skeleton className="w-24 h-8" />
|
||||
<Skeleton className="h-8 w-[4.5rem]" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center text-sm font-medium">
|
||||
<Skeleton className="w-20 h-8" />
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Skeleton className="hidden size-8 lg:block" />
|
||||
<Skeleton className="size-8" />
|
||||
<Skeleton className="size-8" />
|
||||
<Skeleton className="hidden size-8 lg:block" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,57 @@
|
||||
import { Table } from "@tanstack/react-table";
|
||||
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/ui";
|
||||
import { Settings2Icon } from "lucide-react";
|
||||
|
||||
interface DataTableColumnOptionsProps<TData> {
|
||||
table: Table<TData>;
|
||||
}
|
||||
|
||||
export function DataTableColumnOptions<TData>({
|
||||
table,
|
||||
}: DataTableColumnOptionsProps<TData>) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="hidden h-8 ml-auto lg:flex"
|
||||
>
|
||||
<Settings2Icon className="w-4 h-4 mr-2" />
|
||||
Columnas
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-[150px]">
|
||||
<DropdownMenuLabel>Columnas</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter(
|
||||
(column) =>
|
||||
typeof column.accessorFn !== "undefined" && column.getCanHide()
|
||||
)
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
<>{column.columnDef.header}</>
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,138 @@
|
||||
import { Table } from "@tanstack/react-table";
|
||||
|
||||
//import { priorities, statuses } from './Data'
|
||||
import { DataTableFilterField } from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button, Input } from "@/ui";
|
||||
import { CrossIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { DataTableFacetedFilter } from "../DataTableFacetedFilter";
|
||||
import { DataTableColumnOptions } from "./DataTableColumnOptions";
|
||||
|
||||
interface DataTableToolbarProps<TData>
|
||||
extends React.HTMLAttributes<HTMLDivElement> {
|
||||
table: Table<TData>;
|
||||
filterFields?: DataTableFilterField<TData>[];
|
||||
}
|
||||
|
||||
export function DataTableToolbar<TData>({
|
||||
table,
|
||||
filterFields = [],
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: DataTableToolbarProps<TData>) {
|
||||
const isFiltered = table.getState().columnFilters.length > 0;
|
||||
|
||||
// Memoize computation of searchableColumns and filterableColumns
|
||||
const { searchableColumns, filterableColumns } = useMemo(() => {
|
||||
return {
|
||||
searchableColumns: filterFields.filter((field) => !field.options),
|
||||
filterableColumns: filterFields.filter((field) => field.options),
|
||||
};
|
||||
}, [filterFields]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between space-x-2 overflow-auto p-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="flex items-center flex-1 space-x-2">
|
||||
{searchableColumns.length > 0 &&
|
||||
searchableColumns.map(
|
||||
(column) =>
|
||||
table.getColumn(column.value ? String(column.value) : "") && (
|
||||
<Input
|
||||
key={String(column.value)}
|
||||
placeholder={column.placeholder}
|
||||
value={
|
||||
(table
|
||||
.getColumn(String(column.value))
|
||||
?.getFilterValue() as string) ?? ""
|
||||
}
|
||||
onChange={(event) =>
|
||||
table
|
||||
.getColumn(String(column.value))
|
||||
?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="w-40 h-8 lg:w-64"
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{filterableColumns.length > 0 &&
|
||||
filterableColumns.map(
|
||||
(column) =>
|
||||
table.getColumn(column.value ? String(column.value) : "") && (
|
||||
<DataTableFacetedFilter
|
||||
key={String(column.value)}
|
||||
column={table.getColumn(
|
||||
column.value ? String(column.value) : "",
|
||||
)}
|
||||
title={column.label}
|
||||
options={column.options ?? []}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{isFiltered && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => table.resetColumnFilters()}
|
||||
className="h-8 px-2 lg:px-3"
|
||||
>
|
||||
Reset filters
|
||||
<CrossIcon className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{children}
|
||||
{table.options.enableHiding && <DataTableColumnOptions table={table} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
/*
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center flex-1 space-x-2">
|
||||
<Input
|
||||
placeholder="Filter tasks..."
|
||||
value={(table.getColumn("customer")?.getFilterValue() as string) ?? ""}
|
||||
onChange={(event) =>
|
||||
table.getColumn("customer")?.setFilterValue(event.target.value)
|
||||
}
|
||||
className="h-8 w-[150px] lg:w-[250px]"
|
||||
/>
|
||||
{table.getColumn("status") && (
|
||||
<DataTableFacetedFilter
|
||||
column={table.getColumn("status")}
|
||||
title="Status"
|
||||
options={statuses}
|
||||
/>
|
||||
)}
|
||||
{table.getColumn("priority") && (
|
||||
<DataTableFacetedFilter
|
||||
column={table.getColumn("priority")}
|
||||
title="Priority"
|
||||
options={priorities}
|
||||
/>
|
||||
)}
|
||||
{isFiltered && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => table.resetColumnFilters()}
|
||||
className="h-8 px-2 lg:px-3"
|
||||
>
|
||||
Reset
|
||||
<CrossIcon className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<DataTableViewOptions table={table} />
|
||||
</div>
|
||||
)
|
||||
*/
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
export * from "./DataTableToolbar";
|
||||
6
client/src/components/DataTable/index.ts
Normal file
6
client/src/components/DataTable/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export * from "./DataTable";
|
||||
export * from "./DataTableColumnHeader";
|
||||
export * from "./DataTableRowActions";
|
||||
export * from "./DataTableRowDragHandleCell";
|
||||
export * from "./DataTableSelectionColumn";
|
||||
export * from "./DataTableSkeleton";
|
||||
42
client/src/components/EmptyState/SimpleEmptyState.tsx
Normal file
42
client/src/components/EmptyState/SimpleEmptyState.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
import { Button } from "@/ui";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
|
||||
export const SimpleEmptyState = ({
|
||||
title = "Esto está muy vacío",
|
||||
subtitle = "Empieza dando de alta un item",
|
||||
buttonText = "Nuevo item",
|
||||
onButtonClick = () => {},
|
||||
actions = undefined,
|
||||
}) => {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<svg
|
||||
className="w-12 h-12 mx-auto text-slate-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
vectorEffect="non-scaling-stroke"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z"
|
||||
/>
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-semibold text-slate-900">{title}</h3>
|
||||
<p className="mt-1 text-sm text-slate-500">{subtitle}</p>
|
||||
|
||||
<div className="items-center mt-6">
|
||||
{actions && <>{actions}</>}
|
||||
{!actions && (
|
||||
<Button className="my-4" onClick={onButtonClick}>
|
||||
<PlusIcon />
|
||||
{buttonText}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1
client/src/components/EmptyState/index.ts
Normal file
1
client/src/components/EmptyState/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from "./SimpleEmptyState";
|
||||
20
client/src/components/ErrorOverlay/ErrorOverlay.module.css
Normal file
20
client/src/components/ErrorOverlay/ErrorOverlay.module.css
Normal file
@ -0,0 +1,20 @@
|
||||
@tailwind components;
|
||||
|
||||
@layer components {
|
||||
.ErrorOverlay {
|
||||
@apply fixed top-0 bottom-0 left-0 right-0 z-50 w-full h-screen overflow-hidden;
|
||||
@apply flex justify-center bg-slate-700 opacity-75;
|
||||
}
|
||||
|
||||
.ErrorOverlay__container {
|
||||
@apply flex flex-col items-center justify-center max-w-xs;
|
||||
}
|
||||
|
||||
.ErrorOverlay__title {
|
||||
@apply mt-6 text-xl font-semibold text-center text-white;
|
||||
}
|
||||
|
||||
.ErrorOverlay__subtitle {
|
||||
@apply text-center text-white;
|
||||
}
|
||||
}
|
||||
53
client/src/components/ErrorOverlay/ErrorOverlay.tsx
Normal file
53
client/src/components/ErrorOverlay/ErrorOverlay.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { Button } from "@/ui";
|
||||
import { HomeIcon } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { BackHistoryButton } from "../CustomButtons";
|
||||
|
||||
interface ErrorOverlayProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
|
||||
errorMessage?: string;
|
||||
//errorStatusCode?: number | string | undefined;
|
||||
}
|
||||
|
||||
/*const _DrawByStatusCode = {
|
||||
0: UndrawAutumn,
|
||||
404: UndrawNoData, //UndrawEmpty,
|
||||
};*/
|
||||
|
||||
export const ErrorOverlay = ({
|
||||
title = "Se ha producido un error",
|
||||
subtitle = undefined,
|
||||
description = undefined,
|
||||
errorMessage = undefined,
|
||||
}: //errorStatusCode = undefined,
|
||||
ErrorOverlayProps): JSX.Element => {
|
||||
const navigate = useNavigate();
|
||||
/*const ErrorIllustration = () =>
|
||||
String(errorStatusCode).length
|
||||
? _DrawByStatusCode[String(errorStatusCode)]
|
||||
: _DrawByStatusCode['0'];*/
|
||||
|
||||
return (
|
||||
<div className='grid h-screen place-items-center '>
|
||||
<div className='text-center'>
|
||||
<h2 className='mt-2 text-xl font-semibold text-center text-slate-900'>{title}</h2>
|
||||
<p className='mt-1 font-medium text-slate-500'>
|
||||
{subtitle || errorMessage}
|
||||
<br />
|
||||
{description}
|
||||
</p>
|
||||
<div className='mt-6 space-x-8 md:ml-4'>
|
||||
<BackHistoryButton />
|
||||
<Button onClick={() => navigate("/")}>
|
||||
<HomeIcon className='w-4 h-4 mr-2' /> Ir al inicio
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ErrorOverlay.displayName = "ErrorOverlay";
|
||||
1
client/src/components/ErrorOverlay/index.ts
Normal file
1
client/src/components/ErrorOverlay/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './ErrorOverlay';
|
||||
30
client/src/components/SorteableDataTable/AddNewRowButton.tsx
Normal file
30
client/src/components/SorteableDataTable/AddNewRowButton.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button, ButtonProps } from "@/ui";
|
||||
import { PlusCircleIcon } from "lucide-react";
|
||||
|
||||
export interface AddNewRowButtonProps extends ButtonProps {
|
||||
label?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const AddNewRowButton = ({
|
||||
label = "Añade nueva fila",
|
||||
className,
|
||||
...props
|
||||
}: AddNewRowButtonProps): JSX.Element => (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"w-full gap-1 border-dashed text-muted-foreground border-muted-foreground/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<PlusCircleIcon className={label ? "w-4 h-4 mr-2" : "w-4 h-4"} />
|
||||
{label && <>{label}</>}
|
||||
</Button>
|
||||
);
|
||||
|
||||
AddNewRowButton.displayName = "AddNewRowButton";
|
||||
463
client/src/components/SorteableDataTable/SortableDataTable.tsx
Normal file
463
client/src/components/SorteableDataTable/SortableDataTable.tsx
Normal file
@ -0,0 +1,463 @@
|
||||
import { DataTableColumnHeader } from "@/components";
|
||||
import { Badge } from "@/ui";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/ui/table";
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverlay,
|
||||
DragStartEvent,
|
||||
DropAnimation,
|
||||
KeyboardSensor,
|
||||
MeasuringStrategy,
|
||||
MouseSensor,
|
||||
PointerSensor,
|
||||
TouchSensor,
|
||||
UniqueIdentifier,
|
||||
closestCenter,
|
||||
defaultDropAnimation,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
ColumnDef,
|
||||
Row,
|
||||
RowData,
|
||||
RowSelectionState,
|
||||
VisibilityState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { FieldValues, UseFieldArrayReturn } from "react-hook-form";
|
||||
import { AddNewRowButton } from "./AddNewRowButton";
|
||||
import { SortableDataTableToolbar } from "./SortableDataTableToolbar";
|
||||
import { SortableTableRow } from "./SortableTableRow";
|
||||
|
||||
declare module "@tanstack/react-table" {
|
||||
interface TableMeta<TData extends RowData> {
|
||||
insertItem: (rowIndex: number, data: TData) => void;
|
||||
appendItem: (data: TData) => void;
|
||||
duplicateItems: (rowIndex?: number) => void;
|
||||
deleteItems: (rowIndex?: number | number[]) => void;
|
||||
updateItem: (rowIndex: number, rowData: TData, fieldName: string, value: unknown) => void;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SortableProps {
|
||||
id: UniqueIdentifier;
|
||||
}
|
||||
|
||||
export type SortableDataTableProps = {
|
||||
columns: ColumnDef<unknown, unknown>[];
|
||||
data: Record<"id", string>[];
|
||||
actions: Omit<UseFieldArrayReturn<FieldValues, "items">, "fields">;
|
||||
};
|
||||
|
||||
const measuringConfig = {
|
||||
droppable: {
|
||||
strategy: MeasuringStrategy.Always,
|
||||
},
|
||||
};
|
||||
|
||||
const dropAnimationConfig: DropAnimation = {
|
||||
keyframes({ transform }) {
|
||||
return [
|
||||
{ opacity: 1, transform: CSS.Transform.toString(transform.initial) },
|
||||
{
|
||||
opacity: 0,
|
||||
transform: CSS.Transform.toString({
|
||||
...transform.final,
|
||||
x: transform.final.x + 5,
|
||||
y: transform.final.y + 5,
|
||||
}),
|
||||
},
|
||||
];
|
||||
},
|
||||
easing: "ease-out",
|
||||
sideEffects({ active }) {
|
||||
active.node.animate([{ opacity: 0 }, { opacity: 1 }], {
|
||||
duration: defaultDropAnimation.duration,
|
||||
easing: defaultDropAnimation.easing,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
/*const defaultColumn: Partial<ColumnDef<unknown>> = {
|
||||
cell: ({ table, row: { index, original }, column, getValue }) => {
|
||||
const initialValue = getValue();
|
||||
|
||||
// We need to keep and update the state of the cell normally
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const [value, setValue] = useState(initialValue);
|
||||
|
||||
// If the initialValue is changed external, sync it up with our state
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useEffect(() => {
|
||||
setValue(initialValue);
|
||||
}, [initialValue]);
|
||||
|
||||
return (
|
||||
<input
|
||||
value={value as string}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onBlur={() => {
|
||||
console.log(column.id, value);
|
||||
table.options.meta?.updateItem(index, original, column.id, value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
};*/
|
||||
|
||||
export function SortableDataTable({ columns, data, actions }: SortableDataTableProps) {
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [activeId, setActiveId] = useState<UniqueIdentifier | null>();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const sorteableRowIds = useMemo(() => data.map((item) => item.id), [data]);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
enableColumnResizing: false,
|
||||
columnResizeMode: "onChange",
|
||||
//defaultColumn,
|
||||
state: {
|
||||
rowSelection,
|
||||
columnVisibility,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
enableMultiRowSelection: true,
|
||||
enableSorting: false,
|
||||
enableHiding: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getRowId: (originalRow: unknown) => originalRow?.id,
|
||||
debugHeaders: true,
|
||||
debugColumns: true,
|
||||
meta: {
|
||||
insertItem: (rowIndex: number, data: object = {}) => {
|
||||
actions.insert(rowIndex, data, { shouldFocus: true });
|
||||
},
|
||||
appendItem: (data: object = {}) => {
|
||||
actions.append(data, { shouldFocus: true });
|
||||
},
|
||||
duplicateItems: (rowIndex?: number) => {
|
||||
if (rowIndex != undefined) {
|
||||
const originalData = table.getRowModel().rows[rowIndex].original;
|
||||
actions.insert(rowIndex + 1, originalData, { shouldFocus: true });
|
||||
} else if (table.getSelectedRowModel().rows.length) {
|
||||
const lastIndex =
|
||||
table.getSelectedRowModel().rows[table.getSelectedRowModel().rows.length - 1].index;
|
||||
const data = table
|
||||
.getSelectedRowModel()
|
||||
.rows.map((row) => ({ ...row.original, id: undefined }));
|
||||
|
||||
if (table.getRowModel().rows.length < lastIndex + 1) {
|
||||
actions.append(data);
|
||||
} else {
|
||||
actions.insert(lastIndex + 1, data, { shouldFocus: true });
|
||||
}
|
||||
table.resetRowSelection();
|
||||
}
|
||||
},
|
||||
deleteItems: (rowIndex?: number | number[]) => {
|
||||
if (rowIndex != undefined) {
|
||||
actions.remove(rowIndex);
|
||||
} else if (table.getSelectedRowModel().rows.length > 0) {
|
||||
let start = table.getSelectedRowModel().rows.length - 1;
|
||||
for (; start >= 0; start--) {
|
||||
const oldIndex = sorteableRowIds.indexOf(
|
||||
String(table.getSelectedRowModel().rows[start].id)
|
||||
);
|
||||
actions.remove(oldIndex);
|
||||
sorteableRowIds.splice(oldIndex, 1);
|
||||
}
|
||||
|
||||
/*table.getSelectedRowModel().rows.forEach((row) => {
|
||||
});*/
|
||||
table.resetRowSelection();
|
||||
} else {
|
||||
actions.remove();
|
||||
}
|
||||
},
|
||||
updateItem: (rowIndex: number, rowData: unknown, fieldName: string, value: unknown) => {
|
||||
// Skip page index reset until after next rerender
|
||||
// skipAutoResetPageIndex();
|
||||
console.log({
|
||||
rowIndex,
|
||||
rowData,
|
||||
fieldName,
|
||||
value,
|
||||
});
|
||||
|
||||
actions.update(rowIndex, { ...rowData, [`${fieldName}`]: value });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(KeyboardSensor, {}),
|
||||
useSensor(PointerSensor, {})
|
||||
);
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
let activeId = event.active.id;
|
||||
let overId = event.over?.id;
|
||||
|
||||
if (overId !== undefined && activeId !== overId) {
|
||||
let newIndex = sorteableRowIds.indexOf(String(overId));
|
||||
|
||||
if (table.getSelectedRowModel().rows.length > 1) {
|
||||
table.getSelectedRowModel().rows.forEach((row, index) => {
|
||||
const oldIndex = sorteableRowIds.indexOf(String(row.id));
|
||||
|
||||
if (index > 0) {
|
||||
activeId = row.id;
|
||||
newIndex = sorteableRowIds.indexOf(String(overId));
|
||||
if (newIndex < oldIndex) {
|
||||
newIndex = newIndex + 1;
|
||||
}
|
||||
}
|
||||
|
||||
actions.move(oldIndex, newIndex);
|
||||
sorteableRowIds.splice(newIndex, 0, sorteableRowIds.splice(oldIndex, 1)[0]);
|
||||
|
||||
overId = row.id;
|
||||
});
|
||||
} else {
|
||||
const oldIndex = sorteableRowIds.indexOf(String(activeId));
|
||||
actions.move(oldIndex, newIndex);
|
||||
}
|
||||
}
|
||||
|
||||
setActiveId(null);
|
||||
}
|
||||
|
||||
function handleDragStart({ active }: DragStartEvent) {
|
||||
if (!table.getSelectedRowModel().rowsById[active.id]) {
|
||||
table.resetRowSelection();
|
||||
}
|
||||
|
||||
setActiveId(active.id);
|
||||
}
|
||||
|
||||
function handleDragCancel() {
|
||||
setActiveId(null);
|
||||
}
|
||||
|
||||
const hadleNewItem = useCallback(() => {
|
||||
actions.append([
|
||||
{
|
||||
description: "a",
|
||||
},
|
||||
{
|
||||
description: "b",
|
||||
},
|
||||
{
|
||||
description: "c",
|
||||
},
|
||||
{
|
||||
description: "d",
|
||||
},
|
||||
]);
|
||||
}, [actions]);
|
||||
|
||||
function filterItems(items: string[] | Row<unknown>[]) {
|
||||
if (!activeId) {
|
||||
return items;
|
||||
}
|
||||
|
||||
return items.filter((idOrRow) => {
|
||||
const id = typeof idOrRow === "string" ? idOrRow : idOrRow.id;
|
||||
return id === activeId || !table.getSelectedRowModel().rowsById[id];
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
measuring={measuringConfig}
|
||||
sensors={sensors}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragStart={handleDragStart}
|
||||
onDragCancel={handleDragCancel}
|
||||
collisionDetection={closestCenter}
|
||||
>
|
||||
<SortableDataTableToolbar table={table} />
|
||||
<Table className='table-auto'>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className='hover:bg-transparent'>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id} className='px-1'>
|
||||
{header.isPlaceholder ? null : (
|
||||
<DataTableColumnHeader table={table} header={header} />
|
||||
)}
|
||||
</TableHead>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<SortableContext
|
||||
items={filterItems(sorteableRowIds)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{filterItems(table.getRowModel().rows).map((row) => (
|
||||
<SortableTableRow key={row.id} id={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
className='p-1 align-top'
|
||||
key={cell.id}
|
||||
style={{ width: cell.column.getSize() }}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</SortableTableRow>
|
||||
))}
|
||||
</SortableContext>
|
||||
</TableBody>
|
||||
<TableFooter className='bg-default'>
|
||||
<TableRow className='hover:bg-default'>
|
||||
<TableCell colSpan={6} className='py-6'>
|
||||
<AddNewRowButton onClick={hadleNewItem} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
|
||||
{createPortal(
|
||||
<DragOverlay dropAnimation={dropAnimationConfig} className={"z-40 opacity-100"}>
|
||||
{activeId && (
|
||||
<div className='relative flex flex-wrap'>
|
||||
{table.getSelectedRowModel().rows.length ? (
|
||||
<Badge
|
||||
variant='destructive'
|
||||
className='absolute z-50 flex items-center justify-center w-2 h-2 p-3 rounded-full top left -left-2 -top-2'
|
||||
>
|
||||
{table.getSelectedRowModel().rows.length}
|
||||
</Badge>
|
||||
) : null}
|
||||
<div className='absolute z-40 bg-white border rounded shadow opacity-100 top left hover:bg-white border-muted-foreground/50'>
|
||||
<Table>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map(
|
||||
(row) =>
|
||||
row.id === activeId && (
|
||||
<TableRow key={row.id} id={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
className='p-1 align-top'
|
||||
key={cell.id}
|
||||
style={{ width: cell.column.getSize() }}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{table.getSelectedRowModel().rows.length > 1 && (
|
||||
<div className='absolute z-30 transform -translate-x-1 translate-y-1 bg-white border rounded shadow opacity-100 hover:bg-white border-muted-foreground/50 top left rotate-1'>
|
||||
<Table>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map(
|
||||
(row) =>
|
||||
row.id === activeId && (
|
||||
<TableRow key={row.id} id={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
className='p-1 align-top'
|
||||
key={cell.id}
|
||||
style={{ width: cell.column.getSize() }}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{table.getSelectedRowModel().rows.length > 2 && (
|
||||
<div className='absolute z-20 transform translate-x-1 -translate-y-1 bg-white border rounded shadow opacity-100 hover:bg-white border-muted-foreground/50 top left -rotate-1'>
|
||||
<Table>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map(
|
||||
(row) =>
|
||||
row.id === activeId && (
|
||||
<TableRow key={row.id} id={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
className='p-1 align-top'
|
||||
key={cell.id}
|
||||
style={{ width: cell.column.getSize() }}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{table.getSelectedRowModel().rows.length > 3 && (
|
||||
<div className='absolute z-10 transform translate-x-2 -translate-y-2 bg-white border rounded shadow opacity-100 hover:bg-white border-muted-foreground/50 top left rotate-2'>
|
||||
<Table>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.map(
|
||||
(row) =>
|
||||
row.id === activeId && (
|
||||
<TableRow key={row.id} id={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
className='p-1 align-top'
|
||||
key={cell.id}
|
||||
style={{ width: cell.column.getSize() }}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DragOverlay>,
|
||||
document.body
|
||||
)}
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,212 @@
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Separator,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/ui";
|
||||
import { Table } from "@tanstack/react-table";
|
||||
import {
|
||||
CalendarIcon,
|
||||
CirclePlusIcon,
|
||||
ClockIcon,
|
||||
CopyPlusIcon,
|
||||
ForwardIcon,
|
||||
MoreVerticalIcon,
|
||||
ReplyAllIcon,
|
||||
ReplyIcon,
|
||||
ScanIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
|
||||
export const SortableDataTableToolbar = ({
|
||||
table,
|
||||
}: {
|
||||
table: Table<unknown>;
|
||||
}) => {
|
||||
const selectedRowsCount = table.getSelectedRowModel().rows.length;
|
||||
|
||||
if (selectedRowsCount) {
|
||||
return (
|
||||
<div className="flex items-center h-12 p-1 text-white rounded-md bg-primary ">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button>{`${selectedRowsCount} filas seleccionadas`}</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={!table.getSelectedRowModel().rows.length}
|
||||
onClick={() => table.options.meta?.duplicateItems()}
|
||||
>
|
||||
<CopyPlusIcon className="w-4 h-4" />
|
||||
<span className="sm:sr-only">Duplicar</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Duplica las fila(s) seleccionadas(s)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={!table.getSelectedRowModel().rows.length}
|
||||
onClick={() => table.options.meta?.deleteItems()}
|
||||
>
|
||||
<Trash2Icon className="w-4 h-4" />
|
||||
<span className="sm:sr-only">Eliminar</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Elimina las fila(s) seleccionada(s)</TooltipContent>
|
||||
</Tooltip>
|
||||
<Separator orientation="vertical" className="h-6 mx-1 bg-muted/50" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={!table.getSelectedRowModel().rows.length}
|
||||
onClick={() => table.resetRowSelection()}
|
||||
>
|
||||
<ScanIcon className="w-4 h-4 md:mr-2" />
|
||||
<span className="sm:sr-only">Quitar selección</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Quita la selección</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center h-12 p-1 rounded-md bg-muted/50 text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => table.options.meta?.appendItem()}
|
||||
>
|
||||
<CirclePlusIcon className="w-4 h-4" />
|
||||
<span className="sr-only">Añadir</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Añadir fila</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="h-6 mx-1 bg-muted-foreground/30"
|
||||
/>
|
||||
<Tooltip>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ClockIcon className="w-4 h-4" />
|
||||
<span className="sr-only">Snooze</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="flex w-[535px] p-0">
|
||||
<div className="flex flex-col gap-2 px-2 py-4 border-r">
|
||||
<div className="px-4 text-sm font-medium">Snooze until</div>
|
||||
<div className="grid min-w-[250px] gap-1">
|
||||
<Button variant="ghost" className="justify-start font-normal">
|
||||
Later today{" "}
|
||||
<span className="ml-auto text-muted-foreground"></span>
|
||||
</Button>
|
||||
<Button variant="ghost" className="justify-start font-normal">
|
||||
Tomorrow
|
||||
<span className="ml-auto text-muted-foreground"></span>
|
||||
</Button>
|
||||
<Button variant="ghost" className="justify-start font-normal">
|
||||
This weekend
|
||||
<span className="ml-auto text-muted-foreground"></span>
|
||||
</Button>
|
||||
<Button variant="ghost" className="justify-start font-normal">
|
||||
Next week
|
||||
<span className="ml-auto text-muted-foreground"></span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<CalendarIcon />
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<TooltipContent>Snooze</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ReplyIcon className="w-4 h-4" />
|
||||
<span className="sr-only">Reply</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Reply</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ReplyAllIcon className="w-4 h-4" />
|
||||
<span className="sr-only">Reply all</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Reply all</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<ForwardIcon className="w-4 h-4" />
|
||||
<span className="sr-only">Forward</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Forward</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="h-6 mx-1 bg-muted-foreground/30"
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreVerticalIcon className="w-4 h-4" />
|
||||
<span className="sr-only">Columnas</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{table.getAllColumns().map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
disabled={!column.getCanHide()}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) => column.toggleVisibility(!!value)}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,80 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { TableRow } from "@/ui/table";
|
||||
import { DraggableSyntheticListeners } from "@dnd-kit/core";
|
||||
import { defaultAnimateLayoutChanges, useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import {
|
||||
CSSProperties,
|
||||
PropsWithChildren,
|
||||
createContext,
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { SortableProps } from "./SortableDataTable";
|
||||
|
||||
interface Context {
|
||||
attributes: Record<string, any>;
|
||||
listeners: DraggableSyntheticListeners;
|
||||
ref(node: HTMLElement | null): void;
|
||||
}
|
||||
export const SortableTableRowContext = createContext<Context>({
|
||||
attributes: {},
|
||||
listeners: undefined,
|
||||
ref() {},
|
||||
});
|
||||
|
||||
function animateLayoutChanges(args) {
|
||||
if (args.isSorting || args.wasDragging) {
|
||||
return defaultAnimateLayoutChanges(args);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function SortableTableRow({
|
||||
id,
|
||||
children,
|
||||
}: PropsWithChildren<SortableProps>) {
|
||||
const {
|
||||
attributes,
|
||||
isDragging,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({
|
||||
animateLayoutChanges,
|
||||
id,
|
||||
});
|
||||
|
||||
const style: CSSProperties = {
|
||||
transform: CSS.Translate.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const context = useMemo(
|
||||
() => ({
|
||||
attributes,
|
||||
listeners,
|
||||
ref: setActivatorNodeRef,
|
||||
}),
|
||||
[attributes, listeners, setActivatorNodeRef],
|
||||
);
|
||||
|
||||
return (
|
||||
<SortableTableRowContext.Provider value={context}>
|
||||
<TableRow
|
||||
key={id}
|
||||
id={String(id)}
|
||||
className={cn(
|
||||
isDragging ? "opacity-40" : "opacity-100",
|
||||
"hover:bg-muted/30 m-0",
|
||||
)}
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</TableRow>
|
||||
</SortableTableRowContext.Provider>
|
||||
);
|
||||
}
|
||||
1
client/src/components/SorteableDataTable/index.tsx
Normal file
1
client/src/components/SorteableDataTable/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export * from "./SortableDataTable";
|
||||
@ -1,8 +1,14 @@
|
||||
export * from "./ButtonGroup";
|
||||
export * from "./Container";
|
||||
export * from "./CustomButtons";
|
||||
export * from "./CustomDialog";
|
||||
export * from "./DataTable";
|
||||
export * from "./EmptyState";
|
||||
export * from "./ErrorOverlay";
|
||||
export * from "./Forms";
|
||||
export * from "./Layout";
|
||||
export * from "./LoadingIndicator";
|
||||
export * from "./LoadingOverlay";
|
||||
export * from "./ProtectedRoute";
|
||||
export * from "./SorteableDataTable";
|
||||
export * from "./TailwindIndicator";
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
/*export * from "./useBreadcrumbs";
|
||||
export * from "./useDataSource";
|
||||
export * from "./useDataTable";
|
||||
|
||||
export * from "./useForm";
|
||||
export * from "./useLoadingOvertime";
|
||||
export * from "./useMounted";
|
||||
@ -13,4 +13,6 @@ export * from "./useUrlId";
|
||||
|
||||
export * from "./useAuth";
|
||||
export * from "./useCustomDialog";
|
||||
export * from "./useDataTable";
|
||||
export * from "./usePagination";
|
||||
export * from "./useTheme";
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { PropsWithChildren, createContext } from "react";
|
||||
import { IDataSource } from "./DataSource";
|
||||
|
||||
export const DataSourceContext = createContext<IDataSource | null>(null);
|
||||
export const DataSourceContext = createContext<IDataSource | undefined>(undefined);
|
||||
|
||||
export const DataSourceProvider = ({
|
||||
dataSource,
|
||||
|
||||
21
client/src/lib/hooks/useDataTable/DataTableContext.tsx
Normal file
21
client/src/lib/hooks/useDataTable/DataTableContext.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { usePaginationParams } from "@/lib/hooks";
|
||||
import { PropsWithChildren, createContext } from "react";
|
||||
|
||||
export interface IDataTableContextState {}
|
||||
|
||||
export const DataTableContext = createContext<IDataTableContextState | null>(null);
|
||||
|
||||
export const DataTableProvider = ({ children }: PropsWithChildren) => {
|
||||
const [pagination, setPagination] = usePaginationParams();
|
||||
|
||||
return (
|
||||
<DataTableContext.Provider
|
||||
value={{
|
||||
pagination,
|
||||
setPagination,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</DataTableContext.Provider>
|
||||
);
|
||||
};
|
||||
@ -1,3 +1,5 @@
|
||||
export * from "./DataTableContext";
|
||||
export * from "./useDataTable";
|
||||
export * from "./useDataTableColumns";
|
||||
export * from "./useDataTableContext";
|
||||
export * from "./useQueryDataTable";
|
||||
|
||||
12
client/src/lib/hooks/useDataTable/types.ts
Normal file
12
client/src/lib/hooks/useDataTable/types.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export interface Option {
|
||||
label: string;
|
||||
value: string;
|
||||
icon?: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
export interface DataTableFilterField<TData> {
|
||||
label: string;
|
||||
value: keyof TData;
|
||||
placeholder?: string;
|
||||
options?: Option[];
|
||||
}
|
||||
@ -16,8 +16,8 @@ import {
|
||||
import { getDataTableSelectionColumn } from "@/components";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { DataTableFilterField } from "../../types";
|
||||
import { usePaginationParams } from "../usePagination";
|
||||
import { DataTableFilterField } from "./types";
|
||||
import { useDataTableContext } from "./useDataTableContext";
|
||||
|
||||
//import { useDebounce } from "@/hooks/use-debounce";
|
||||
|
||||
@ -110,13 +110,17 @@ export function useDataTable<TData, TValue>({
|
||||
enableSorting = false,
|
||||
enableHiding = false,
|
||||
enableRowSelection = false,
|
||||
onPaginationChange,
|
||||
filterFields = [],
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
enableAdvancedFilter = false,
|
||||
}: UseDataTableProps<TData, TValue>) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const [pagination, setPagination] = usePaginationParams();
|
||||
const { pagination, setPagination } = useDataTableContext();
|
||||
console.log("pagination TABLA =>", pagination);
|
||||
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
|
||||
// Memoize computation of searchableColumns and filterableColumns
|
||||
@ -149,12 +153,8 @@ export function useDataTable<TData, TValue>({
|
||||
const initialColumnFilters: ColumnFiltersState = useMemo(() => {
|
||||
return Array.from(searchParams.entries()).reduce<ColumnFiltersState>(
|
||||
(filters, [key, value]) => {
|
||||
const filterableColumn = filterableColumns.find(
|
||||
(column) => column.value === key
|
||||
);
|
||||
const searchableColumn = searchableColumns.find(
|
||||
(column) => column.value === key
|
||||
);
|
||||
const filterableColumn = filterableColumns.find((column) => column.value === key);
|
||||
const searchableColumn = searchableColumns.find((column) => column.value === key);
|
||||
|
||||
if (filterableColumn) {
|
||||
filters.push({
|
||||
@ -177,8 +177,7 @@ export function useDataTable<TData, TValue>({
|
||||
// Table states
|
||||
const [rowSelection, setRowSelection] = React.useState({});
|
||||
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
React.useState<VisibilityState>({});
|
||||
const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({});
|
||||
|
||||
const [columnFilters, setColumnFilters] =
|
||||
React.useState<ColumnFiltersState>(initialColumnFilters);
|
||||
@ -186,7 +185,6 @@ export function useDataTable<TData, TValue>({
|
||||
const paginationUpdater: OnChangeFn<PaginationState> = (updater) => {
|
||||
if (typeof updater === "function") {
|
||||
const newPagination = updater(pagination);
|
||||
console.log(newPagination);
|
||||
setPagination(newPagination);
|
||||
}
|
||||
};
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
import { useContext } from "react";
|
||||
import { DataTableContext } from "./DataTableContext";
|
||||
|
||||
export const useDataTableContext = () => {
|
||||
const context = useContext(DataTableContext);
|
||||
if (context === null)
|
||||
throw new Error("useDataTableContext must be used within a DataTableProvider");
|
||||
return context;
|
||||
};
|
||||
75
client/src/lib/hooks/usePagination/usePagination.test.ts
Normal file
75
client/src/lib/hooks/usePagination/usePagination.test.ts
Normal file
@ -0,0 +1,75 @@
|
||||
import { act, renderHook } from "@testing-library/react-hooks";
|
||||
import {
|
||||
INITIAL_PAGE_INDEX,
|
||||
INITIAL_PAGE_SIZE,
|
||||
MAX_PAGE_SIZE,
|
||||
MIN_PAGE_SIZE,
|
||||
PaginationState,
|
||||
usePagination,
|
||||
} from "./usePagination";
|
||||
|
||||
describe("usePagination", () => {
|
||||
it("should initialize with default values", () => {
|
||||
const { result } = renderHook(() => usePagination());
|
||||
const [pagination] = result.current;
|
||||
|
||||
expect(pagination.pageIndex).toBe(INITIAL_PAGE_INDEX);
|
||||
expect(pagination.pageSize).toBe(INITIAL_PAGE_SIZE);
|
||||
});
|
||||
|
||||
it("should initialize with custom values", () => {
|
||||
const customPageIndex = 2;
|
||||
const customPageSize = 20;
|
||||
const { result } = renderHook(() => usePagination(customPageIndex, customPageSize));
|
||||
const [pagination] = result.current;
|
||||
|
||||
expect(pagination.pageIndex).toBe(customPageIndex);
|
||||
expect(pagination.pageSize).toBe(customPageSize);
|
||||
});
|
||||
|
||||
it("should update pagination state correctly", () => {
|
||||
const { result } = renderHook(() => usePagination());
|
||||
const [pagination, updatePagination] = result.current;
|
||||
|
||||
const newPagination: PaginationState = { pageIndex: 1, pageSize: 25 };
|
||||
|
||||
act(() => {
|
||||
updatePagination(newPagination);
|
||||
});
|
||||
|
||||
expect(pagination.pageIndex).toBe(newPagination.pageIndex);
|
||||
expect(pagination.pageSize).toBe(newPagination.pageSize);
|
||||
});
|
||||
|
||||
it("should not update pageIndex below INITIAL_PAGE_INDEX", () => {
|
||||
const { result } = renderHook(() => usePagination());
|
||||
const [, updatePagination] = result.current;
|
||||
|
||||
act(() => {
|
||||
updatePagination({ pageIndex: -1, pageSize: INITIAL_PAGE_SIZE });
|
||||
});
|
||||
|
||||
const [pagination] = result.current;
|
||||
|
||||
expect(pagination.pageIndex).toBe(INITIAL_PAGE_INDEX);
|
||||
});
|
||||
|
||||
it("should not update pageSize below MIN_PAGE_SIZE or above MAX_PAGE_SIZE", () => {
|
||||
const { result } = renderHook(() => usePagination());
|
||||
const [, updatePagination] = result.current;
|
||||
|
||||
act(() => {
|
||||
updatePagination({ pageIndex: INITIAL_PAGE_INDEX, pageSize: MIN_PAGE_SIZE - 1 });
|
||||
});
|
||||
|
||||
let [pagination] = result.current;
|
||||
expect(pagination.pageSize).toBe(INITIAL_PAGE_SIZE); // No cambia porque pageSize es menor que el mínimo
|
||||
|
||||
act(() => {
|
||||
updatePagination({ pageIndex: INITIAL_PAGE_INDEX, pageSize: MAX_PAGE_SIZE + 1 });
|
||||
});
|
||||
|
||||
[pagination] = result.current;
|
||||
expect(pagination.pageSize).toBe(INITIAL_PAGE_SIZE); // No cambia porque pageSize es mayor que el máximo
|
||||
});
|
||||
});
|
||||
@ -6,7 +6,7 @@ export const INITIAL_PAGE_SIZE = 10;
|
||||
export const MIN_PAGE_INDEX = 0;
|
||||
export const MIN_PAGE_SIZE = 1;
|
||||
|
||||
export const MAX_PAGE_SIZE = Number.MAX_SAFE_INTEGER;
|
||||
export const MAX_PAGE_SIZE = 100; //Number.MAX_SAFE_INTEGER;
|
||||
|
||||
export const DEFAULT_PAGE_SIZES = [10, 25, 50, 100];
|
||||
|
||||
@ -31,19 +31,23 @@ export const usePagination = (
|
||||
|
||||
const updatePagination = (newPagination: PaginationState) => {
|
||||
// Realiza comprobaciones antes de actualizar el estado
|
||||
const validatedPagination = newPagination;
|
||||
|
||||
if (newPagination.pageIndex < INITIAL_PAGE_INDEX) {
|
||||
newPagination.pageIndex = INITIAL_PAGE_INDEX;
|
||||
if (validatedPagination.pageIndex < INITIAL_PAGE_INDEX) {
|
||||
validatedPagination.pageIndex = INITIAL_PAGE_INDEX;
|
||||
}
|
||||
|
||||
if (
|
||||
newPagination.pageSize < MIN_PAGE_SIZE ||
|
||||
newPagination.pageSize > MAX_PAGE_SIZE
|
||||
) {
|
||||
return;
|
||||
if (newPagination.pageSize < MIN_PAGE_SIZE || newPagination.pageSize > MAX_PAGE_SIZE) {
|
||||
validatedPagination.pageSize = MIN_PAGE_SIZE;
|
||||
}
|
||||
|
||||
setPagination(newPagination);
|
||||
setPagination((oldPagination) => ({
|
||||
...oldPagination,
|
||||
pageIndex: newPagination.pageIndex,
|
||||
pageSize: newPagination.pageSize,
|
||||
}));
|
||||
|
||||
return validatedPagination;
|
||||
};
|
||||
|
||||
return [pagination, updatePagination] as const;
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { PaginationState } from "@tanstack/react-table";
|
||||
import { useMemo } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
INITIAL_PAGE_INDEX,
|
||||
INITIAL_PAGE_SIZE,
|
||||
MAX_PAGE_SIZE,
|
||||
MIN_PAGE_SIZE,
|
||||
usePagination,
|
||||
} from "./usePagination";
|
||||
|
||||
@ -17,24 +20,29 @@ export const usePaginationParams = (
|
||||
|
||||
const calculatedPageIndex = useMemo(() => {
|
||||
const parsedPageIndex = parseInt(urlParamPageIndex ?? "", 10);
|
||||
return !isNaN(parsedPageIndex) ? parsedPageIndex : initialPageIndex;
|
||||
let result = !isNaN(parsedPageIndex) ? parsedPageIndex : initialPageIndex;
|
||||
|
||||
if (result < initialPageIndex) {
|
||||
result = initialPageIndex;
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [urlParamPageIndex, initialPageIndex]);
|
||||
|
||||
const calculatedPageSize = useMemo(() => {
|
||||
const parsedPageSize = parseInt(urlParamPageSize ?? "", 10);
|
||||
return !isNaN(parsedPageSize) ? parsedPageSize : initialPageSize;
|
||||
let result = !isNaN(parsedPageSize) ? parsedPageSize : initialPageSize;
|
||||
if (result < MIN_PAGE_SIZE || result > MAX_PAGE_SIZE) {
|
||||
result = initialPageSize;
|
||||
}
|
||||
return result;
|
||||
}, [urlParamPageSize, initialPageSize]);
|
||||
|
||||
const [pagination, setPagination] = usePagination(
|
||||
calculatedPageIndex,
|
||||
calculatedPageSize
|
||||
);
|
||||
const [pagination, setPagination] = usePagination(calculatedPageIndex, calculatedPageSize);
|
||||
|
||||
useEffect(() => {
|
||||
/*useEffect(() => {
|
||||
// Actualizar la URL cuando cambia la paginación
|
||||
const actualSearchParam = Object.fromEntries(
|
||||
new URLSearchParams(urlSearchParams)
|
||||
);
|
||||
const actualSearchParam = Object.fromEntries(new URLSearchParams(urlSearchParams));
|
||||
|
||||
if (
|
||||
String(pagination.pageIndex) !== actualSearchParam.page_index ||
|
||||
@ -46,7 +54,17 @@ export const usePaginationParams = (
|
||||
page_size: String(pagination.pageSize),
|
||||
});
|
||||
}
|
||||
}, [pagination]);
|
||||
}, [pagination]);*/
|
||||
|
||||
return [pagination, setPagination] as const;
|
||||
const updatePagination = (newPagination: PaginationState) => {
|
||||
const _validatedPagination = setPagination(newPagination);
|
||||
|
||||
setUrlSearchParams({
|
||||
//...actualSearchParam,
|
||||
page_index: String(_validatedPagination.pageIndex),
|
||||
page_size: String(_validatedPagination.pageSize),
|
||||
});
|
||||
};
|
||||
|
||||
return [pagination, updatePagination];
|
||||
};
|
||||
|
||||
@ -1,14 +1,36 @@
|
||||
{
|
||||
"translation": {
|
||||
"title": "Presupuestador para distribuidores",
|
||||
"common": {
|
||||
"cancel": "Cancel",
|
||||
"no": "No",
|
||||
"yes": "Yes",
|
||||
"Accept": "Accept"
|
||||
},
|
||||
"main_menu": {
|
||||
"home": "Home",
|
||||
"settings": "Settings",
|
||||
"dealers": "Dealers",
|
||||
"catalog": "Catalog",
|
||||
"quotes": "Quotes",
|
||||
"search_placeholder": "Search product, quotes, etc...",
|
||||
"user": {
|
||||
"user_menu": "User menu",
|
||||
"my_account": "My account",
|
||||
"profile": "Profile",
|
||||
"settings": "Settings",
|
||||
"support": "Support",
|
||||
"logout": "Logout"
|
||||
},
|
||||
"logout": {}
|
||||
},
|
||||
"login_page": {
|
||||
"title": "Presupuestador para distribuidores",
|
||||
"description": "Introduzca su dirección de correo electrónico y contraseña para acceder",
|
||||
"title": "Uecko Quotes",
|
||||
"description": "Enter your email address and password to login",
|
||||
"email_label": "Email",
|
||||
"email_placeholder": "micorreo@ejemplo.com",
|
||||
"email_placeholder": "user@sample.com",
|
||||
"password_label": "Password",
|
||||
"forgotten_password": "¿Has olvidado tu contraseña?",
|
||||
"become_dealer": "¿Quieres ser distribuidor de Uecko?",
|
||||
"forgotten_password": "Forgot your password?",
|
||||
"become_dealer": "Do you want to become a Uecko dealer?",
|
||||
"contact_us": "Contact us",
|
||||
"login": "Login",
|
||||
"error": "Error"
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
"dealers": "Distribuidores",
|
||||
"catalog": "Catálogo",
|
||||
"quotes": "Cotizaciones",
|
||||
"search_placeholder": "Buscar productos, distribuidores, etc...",
|
||||
"search_placeholder": "Buscar productos, cotizaciones, etc...",
|
||||
"user": {
|
||||
"user_menu": "Menú del usuario",
|
||||
"my_account": "Mi cuenta",
|
||||
@ -34,6 +34,12 @@
|
||||
"contact_us": "Contacta con nosotros",
|
||||
"login": "Entrar",
|
||||
"error": "Error"
|
||||
},
|
||||
"dashboard": {
|
||||
"welcome": "Bienvenido"
|
||||
},
|
||||
"catalog": {
|
||||
"title": "Catálogo de artículos"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user