This commit is contained in:
David Arranz 2024-06-29 21:39:25 +02:00
parent 52de165a52
commit d5281fb1f5
81 changed files with 2802 additions and 505 deletions

5
.vscode/launch.json vendored
View File

@ -13,7 +13,10 @@
{
"name": "Launch Chrome localhost",
"type": "pwa-chrome",
"port": 9222
"request": "launch",
"reAttach": true,
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}/client"
},
{

View File

@ -1,5 +1,5 @@
<!doctype html>
<html lang="es">
<html>
<head>
<meta charset="UTF-8" />

View File

@ -4,12 +4,14 @@ import {
DealersList,
LoginPage,
LogoutPage,
QuoteCreate,
SettingsEditor,
SettingsLayout,
StartPage,
} from "./app";
import { CatalogLayout, CatalogList } from "./app/catalog";
import { DashboardPage } from "./app/dashboard";
import { QuotesLayout } from "./app/quotes/layout";
import { QuotesList } from "./app/quotes/list";
import { ProtectedRoute } from "./components";
@ -68,9 +70,21 @@ export const Routes = () => {
path: "/quotes",
element: (
<ProtectedRoute>
<QuotesList />
<QuotesLayout>
<Outlet />
</QuotesLayout>
</ProtectedRoute>
),
children: [
{
index: true,
element: <QuotesList />,
},
{
path: "add",
element: <QuoteCreate />,
},
],
},
{
path: "/settings",
@ -107,11 +121,12 @@ export const Routes = () => {
];
// Combine and conditionally include routes based on authentication status
const router = createBrowserRouter([
...routesForPublic,
...routesForAuthenticatedOnly,
...routesForNotAuthenticatedOnly,
]);
const router = createBrowserRouter(
[...routesForPublic, ...routesForAuthenticatedOnly, ...routesForNotAuthenticatedOnly],
{
//basename: "/app",
}
);
// Provide the router configuration using RouterProvider
return <RouterProvider router={router} />;

View File

@ -5,8 +5,8 @@ import { DataTablaRowActionFunction, DataTableRowActions } from "@/components";
import { Badge } from "@/ui";
import { useMemo } from "react";
export const useCustomerInvoiceDataTableColumns = (
actions: DataTablaRowActionFunction<IListArticles_Response_DTO>
export const useCatalogTableColumns = (
actions: DataTablaRowActionFunction<IListArticles_Response_DTO, unknown>
): ColumnDef<IListArticles_Response_DTO>[] => {
const customerColumns: ColumnDef<IListArticles_Response_DTO>[] = useMemo(
() => [

View File

@ -0,0 +1,21 @@
import { usePagination } from "@/lib/hooks";
import { PropsWithChildren, createContext } from "react";
export interface IQuotesContextState {}
export const QuotesContext = createContext<IQuotesContextState | null>(null);
export const QuotesProvider = ({ children }: PropsWithChildren) => {
const [pagination, setPagination] = usePagination();
return (
<QuotesContext.Provider
value={{
pagination,
setPagination,
}}
>
{children}
</QuotesContext.Provider>
);
};

View 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";

View File

@ -0,0 +1,122 @@
import { Card, CardContent } from "@/ui";
import { DataTableSkeleton, ErrorOverlay, SimpleEmptyState } from "@/components";
import { DataTable } from "@/components";
import { DataTableToolbar } from "@/components/DataTable/DataTableToolbar";
import { useDataTable, useDataTableContext } from "@/lib/hooks";
import { IListQuotes_Response_DTO, MoneyValue } from "@shared/contexts";
import { ColumnDef, Row } from "@tanstack/react-table";
import { t } from "i18next";
import { useMemo } from "react";
import { useNavigate } from "react-router-dom";
import { useQuotesList } from "../hooks";
export const QuotesDataTable = () => {
const navigate = useNavigate();
const { pagination, globalFilter, isFiltered } = useDataTableContext();
const { data, isPending, isError, error } = useQuotesList({
pagination: {
pageIndex: pagination.pageIndex,
pageSize: pagination.pageSize,
},
searchTerm: globalFilter,
});
const columns = useMemo<ColumnDef<IListQuotes_Response_DTO, any>[]>(
() => [
{
id: "id" as const,
accessorKey: "id",
enableResizing: false,
size: 10,
},
{
id: "article_id" as const,
accessorKey: "id_article",
enableResizing: false,
size: 10,
},
{
id: "catalog_name" as const,
accessorKey: "catalog_name",
enableResizing: false,
size: 10,
},
{
id: "description" as const,
accessorKey: "description",
header: () => <>{t("catalog.list.columns.description")}</>,
enableResizing: false,
size: 100,
},
{
id: "points" as const,
accessorKey: "points",
header: () => <div className='text-right'>{t("catalog.list.columns.points")}</div>,
cell: ({ renderValue }: { renderValue: () => any }) => (
<div className='text-right'>{renderValue()}</div>
),
enableResizing: false,
size: 20,
},
{
id: "retail_price" as const,
accessorKey: "retail_price",
header: () => <div className='text-right'>{t("catalog.list.columns.retail_price")}</div>,
cell: ({ row }: { row: Row<any> }) => {
const price = MoneyValue.create(row.original.retail_price).object;
return <div className='text-right'>{price.toFormat()}</div>;
},
enableResizing: false,
size: 20,
},
],
[]
);
const { table } = useDataTable({
data: data?.items ?? [],
columns: columns,
pageCount: data?.total_pages ?? -1,
});
if (isError) {
return <ErrorOverlay subtitle={(error as Error).message} />;
}
if (isPending) {
return (
<Card>
<CardContent>
<DataTableSkeleton
columnCount={6}
searchableColumnCount={1}
filterableColumnCount={2}
//cellWidths={["10rem", "40rem", "12rem", "12rem", "8rem"]}
shrinkZero
/>
</CardContent>
</Card>
);
}
if (data?.total_items === 0 && !isFiltered) {
return (
<SimpleEmptyState
subtitle='Empieza creando alguna cotización'
buttonText=''
onButtonClick={() => navigate("add", { relative: "path" })}
/>
);
}
return (
<>
<DataTable table={table} paginationOptions={{ visible: true }}>
<DataTableToolbar table={table} />
</DataTable>
</>
);
};

View File

@ -0,0 +1,462 @@
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>();
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>
);
}

View File

@ -0,0 +1,200 @@
import {
Button,
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
Popover,
PopoverContent,
PopoverTrigger,
Separator,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/ui";
import { Table } from "@tanstack/react-table";
import { t } from "i18next";
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'>{t("common.duplicate_rows")}</span>
</Button>
</TooltipTrigger>
<TooltipContent>{t("common.duplicate_rows_tooltip")}</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-accent/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>
);
};

View File

@ -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>
);
}

View File

@ -0,0 +1,155 @@
import {
ButtonGroup,
CancelButton,
FormGroup,
FormMoneyField,
FormTextAreaField,
FormTextField,
SubmitButton,
} from "@/components";
import { Input } from "@/ui";
import { t } from "i18next";
import { HashIcon } from "lucide-react";
import { useFieldArray, useFormContext } from "react-hook-form";
import { useDetailColumns } from "../../hooks";
import { SortableDataTable } from "../SortableDataTable";
export const QuoteDetailsCardEditor = () => {
const { control, register, formState } = useFormContext();
const { fields, ...fieldActions } = useFieldArray({
control,
name: "items",
});
const columns = useDetailColumns(
[
{
id: "row_id" as const,
header: () => (
<HashIcon aria-label='Orden de fila' className='items-center justify-center w-4 h-4' />
),
accessorFn: (originalRow: unknown, index: number) => index + 1,
size: 26,
enableHiding: false,
enableSorting: false,
enableResizing: false,
},
{
id: "description" as const,
accessorKey: "description",
size: 400,
cell: ({ row: { index }, column: { id } }) => {
return (
<FormTextAreaField
autoSize
control={control}
{...register(`items.${index}.description`)}
/>
);
},
},
{
id: "quantity" as const,
accessorKey: "quantity",
header: "quantity",
size: 60,
cell: ({ row: { index }, column: { id } }) => {
return (
<FormTextField
type='number'
control={control}
{...register(`items.${index}.quantity`)}
/>
);
},
},
{
id: "unit_measure" as const,
accessorKey: "unit_measure",
header: "unit_measure",
size: 60,
cell: ({ row: { index }, column: { id } }) => {
return <Input key={id} {...register(`items.${index}.unit_measure`)} />;
},
},
{
id: "unit_price" as const,
accessorKey: "unit_price",
header: "unit_price",
cell: ({ row: { index }, column: { id } }) => {
return <FormMoneyField control={control} {...register(`items.${index}.unit_price`)} />;
},
} /*
{
id: "subtotal" as const,
accessorKey: "subtotal",
header: "subtotal",
},
{
id: "tax_amount" as const,
accessorKey: "tax_amount",
header: "tax_amount",
},
{
id: "total" as const,
accessorKey: "total",
header: "total",
},*/,
],
{
enableDragHandleColumn: true,
enableSelectionColumn: true,
enableActionsColumn: true,
rowActionFn: (props) => {
const { table, row } = props;
return [
{
label: "Duplicar",
onClick: () => table.options.meta?.duplicateItems(row.index),
},
{
label: "Insertar fila encima",
onClick: () => table.options.meta?.insertItem(row.index),
},
{
label: "Insertar fila debajo",
onClick: () => table.options.meta?.insertItem(row.index + 1),
},
{
label: "-",
},
{
label: "Eliminar",
shortcut: "⌘⌫",
onClick: () => {
table.options.meta?.deleteItems(row.index);
},
},
];
},
}
);
return (
<FormGroup
title={t("quotes.create.tabs.items.title")}
description={t("quotes.create.tabs.items.desc")}
actions={
<ButtonGroup className='md:hidden'>
<CancelButton onClick={() => null} size='sm' />
<SubmitButton
disabled={!formState.isDirty || formState.isSubmitting || formState.isLoading}
label='Guardar'
size='sm'
/>
</ButtonGroup>
}
>
<div className='gap-0'>
<SortableDataTable actions={fieldActions} columns={columns} data={fields} />
</div>
</FormGroup>
);
};

View File

@ -0,0 +1,84 @@
import { FormGroup } from "@/components";
import { Button, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/ui";
import { t } from "i18next";
import { Upload } from "lucide-react";
import { useFormContext } from "react-hook-form";
export const QuoteDocumentsCardEditor = () => {
/*const { uploadFiles, progresses, uploadedFiles, isUploading } = useUploadFile("imageUploader", {
defaultUploadedFiles: [],
});*/
const { control, register, formState } = useFormContext();
return (
<div className='grid gap-6 md:grid-cols-6'>
<FormGroup
className='md:col-span-4'
title={t("quotes.create.form_groups.documents.title")}
description={t("quotes.create.form_groups.documents.desc")}
>
<FormField
control={control}
name='images'
render={({ field }) => (
<div className='space-y-6'>
<FormItem className='w-full'>
<FormLabel>Images</FormLabel>
<FormControl></FormControl>
<FormMessage />
</FormItem>
</div>
)}
/>
<Button className='w-fit' disabled={formState.disabled}>
Save
</Button>
</FormGroup>
</div>
);
return (
<div className='grid gap-6 md:grid-cols-6'>
<FormGroup
className='md:col-span-4'
title={t("quotes.create.form_groups.documents.title")}
description={t("quotes.create.form_groups.documents.desc")}
>
<div className='grid gap-2'>
<img
alt='Product image'
className='object-cover w-full rounded-md aspect-square'
height='300'
src='https://placehold.co/300'
width='300'
/>
<div className='grid grid-cols-3 gap-2'>
<button>
<img
alt='Product image'
className='object-cover w-full rounded-md aspect-square'
height='84'
src='https://placehold.co/84'
width='84'
/>
</button>
<button>
<img
alt='Product image'
className='object-cover w-full rounded-md aspect-square'
height='84'
src='https://placehold.co/84'
width='84'
/>
</button>
<button className='flex items-center justify-center w-full border border-dashed rounded-md aspect-square'>
<Upload className='w-4 h-4 text-muted-foreground' />
<span className='sr-only'>{t("common.upload")}</span>
</button>
</div>
</div>
</FormGroup>
</div>
);
};

View File

@ -0,0 +1,125 @@
import { FormDatePickerField, FormGroup, FormTextAreaField, FormTextField } from "@/components";
import { Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/ui";
import { t } from "i18next";
import { useFormContext } from "react-hook-form";
export const QuoteGeneralCardEditor = () => {
const { register, formState } = useFormContext();
return (
<div className='grid gap-6 md:grid-cols-6'>
<FormGroup
className='md:col-span-4'
title={t("quotes.create.form_groups.general.title")}
description={t("quotes.create.form_groups.general.desc")}
>
<div className='grid grid-cols-2 grid-rows-2 gap-6'>
<FormTextAreaField
className='row-span-2'
required
label={t("quotes.create.form_fields.customer_information.label")}
description={t("quotes.create.form_fields.customer_information.desc")}
disabled={formState.disabled}
placeholder={t("quotes.create.form_fields.customer_information.placeholder")}
{...register("customer_information", {
required: true,
})}
errors={formState.errors}
/>
<FormTextField
label={t("quotes.create.form_fields.reference.label")}
description={t("quotes.create.form_fields.reference.desc")}
disabled={formState.disabled}
placeholder={t("quotes.create.form_fields.reference.placeholder")}
{...register("reference", {
required: false,
})}
/>
<FormDatePickerField
required
label={t("quotes.create.form_fields.date.label")}
description={t("quotes.create.form_fields.date.desc")}
disabled={formState.disabled}
placeholder={t("quotes.create.form_fields.date.placeholder")}
{...register("date", {
required: true,
})}
/>
</div>
<div className='grid grid-cols-2 grid-rows-2 gap-6'>
<FormTextField
label={t("quotes.create.form_fields.validity.label")}
description={t("quotes.create.form_fields.validity.desc")}
disabled={formState.disabled}
placeholder={t("quotes.create.form_fields.validity.placeholder")}
{...register("validity", {
required: false,
})}
/>
<FormTextAreaField
label={t("quotes.create.form_fields.payment_method.label")}
description={t("quotes.create.form_fields.payment_method.desc")}
disabled={formState.disabled}
placeholder={t("quotes.create.form_fields.payment_method.placeholder")}
{...register("payment_method", {
required: false,
})}
/>
<FormTextAreaField
className='col-span-2'
label={t("quotes.create.form_fields.notes.label")}
description={t("quotes.create.form_fields.notes.desc")}
disabled={formState.disabled}
placeholder={t("quotes.create.form_fields.notes.placeholder")}
{...register("notes", {
required: false,
})}
/>
</div>
</FormGroup>
<FormGroup
className='md:col-span-2'
title={t("quotes.create.form_groups.status.title")}
description={t("quotes.create.form_groups.status.desc")}
>
<div className='grid gap-6'>
<div className='grid gap-3'>
<Label htmlFor='status'>Status</Label>
<Select>
<SelectTrigger id='status' aria-label='Select status'>
<SelectValue placeholder='Select status' />
</SelectTrigger>
<SelectContent>
<SelectItem value='draft'>Draft</SelectItem>
<SelectItem value='published'>Active</SelectItem>
<SelectItem value='archived'>Archived</SelectItem>
</SelectContent>
</Select>
</div>
<FormTextField
required
label={t("quotes.create.form_fields.lang_code.label")}
description={t("quotes.create.form_fields.lang_code.desc")}
disabled={formState.disabled}
placeholder={t("quotes.create.form_fields.lang_code.placeholder")}
{...register("lang_code", {
required: true,
})}
/>
<FormTextField
required
label={t("quotes.create.form_fields.currency_code.label")}
description={t("quotes.create.form_fields.currency_code.desc")}
disabled={formState.disabled}
placeholder={t("quotes.create.form_fields.currency_code.placeholder")}
{...register("currency_code", {
required: true,
})}
/>
</div>
</FormGroup>
</div>
);
};

View File

@ -0,0 +1,3 @@
export * from "./QuoteDetailsCardEditor";
export * from "./QuoteDocumentsCardEditor";
export * from "./QuoteGeneralCardEditor";

View File

@ -0,0 +1 @@
export * from "./QuotesDataTable";

View File

@ -0,0 +1,70 @@
import { IListQuotes_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 useQuoteDataTableColumns = (
actions: DataTablaRowActionFunction<IListQuotes_Response_DTO, unknown>
): ColumnDef<IListQuotes_Response_DTO>[] => {
const customerColumns: ColumnDef<IListQuotes_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: "state",
accessorKey: "state",
header: "Estado",
cell: ({ renderValue }) => (
<Badge variant={"destructive"}>
<>{renderValue()}</>
</Badge>
),
},
{
id: "client",
accessorKey: "client",
header: "Cliente",
enableSorting: false,
sortingFn: "alphanumeric",
},
{
id: "actions",
header: "Acciones",
cell: ({ row }) => {
return <DataTableRowActions row={row} actions={actions} />;
},
},
],
[actions]
);
return customerColumns;
};

View File

@ -0,0 +1,116 @@
import { ChevronLeft } from "lucide-react";
import { SubmitButton } from "@/components";
import { useGetIdentity } from "@/lib/hooks";
import { Badge, Button, Form, Tabs, TabsContent, TabsList, TabsTrigger } from "@/ui";
import { t } from "i18next";
import { useState } from "react";
import { SubmitHandler, useForm } from "react-hook-form";
import { QuoteGeneralCardEditor } from "./components/editors";
import { useQuotes } from "./hooks";
type QuoteDataForm = {
id: string;
status: string;
date: string;
reference: string;
customer_information: string;
lang_code: string;
currency_code: string;
payment_method: string;
notes: string;
validity: string;
items: any[];
};
type QuoteCreateProps = {
isOverModal?: boolean;
};
export const QuoteCreate = ({ isOverModal }: QuoteCreateProps) => {
const [loading, setLoading] = useState(false);
const { data: userIdentity } = useGetIdentity();
console.log(userIdentity);
const { useQuery, useMutation } = useQuotes();
const { data } = useQuery;
const { mutate } = useMutation;
const form = useForm<QuoteDataForm>({
mode: "onBlur",
values: data,
defaultValues: {
date: "",
reference: "",
customer_information: "",
lang_code: "",
currency_code: "",
payment_method: "",
notes: "",
validity: "",
items: [],
},
});
const onSubmit: SubmitHandler<QuoteDataForm> = async (data) => {
try {
setLoading(true);
data.currency_code = "EUR";
data.lang_code = String(userIdentity?.language);
mutate(data);
} finally {
setLoading(false);
}
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className='mx-auto grid max-w-[90rem] flex-1 auto-rows-max gap-6'>
<div className='flex items-center gap-4'>
<Button variant='outline' size='icon' className='h-7 w-7'>
<ChevronLeft className='w-4 h-4' />
<span className='sr-only'>{t("quotes.common.back")}</span>
</Button>
<h1 className='flex-1 text-xl font-semibold tracking-tight shrink-0 whitespace-nowrap sm:grow-0'>
{t("quotes.create.title")}
</h1>
<Badge variant='default' className='ml-auto sm:ml-0'>
{t("quotes.status.draft")}
</Badge>
<div className='items-center hidden gap-2 md:ml-auto md:flex'>
<Button variant='outline' size='sm'>
{t("quotes.create.buttons.discard")}
</Button>
<SubmitButton variant={form.formState.isDirty ? "default" : "outline"} size='sm'>
{t("quotes.create.buttons.save_quote")}
</SubmitButton>
</div>
</div>
<Tabs defaultValue='general' className='space-y-4'>
<TabsList>
<TabsTrigger value='general'>{t("quotes.create.tabs.general")}</TabsTrigger>
<TabsTrigger value='items'>{t("quotes.create.tabs.items")}</TabsTrigger>
<TabsTrigger value='documents'>{t("quotes.create.tabs.documents")}</TabsTrigger>
<TabsTrigger value='history'>{t("quotes.create.tabs.history")}</TabsTrigger>
</TabsList>
<TabsContent value='general'>
<QuoteGeneralCardEditor />
</TabsContent>
<TabsContent value='items'></TabsContent>
<TabsContent value='documents'></TabsContent>
<TabsContent value='history'></TabsContent>
</Tabs>
<div className='flex items-center justify-center gap-2 md:hidden'>
<Button variant='outline' size='sm'>
{t("quotes.create.buttons.discard")}
</Button>
<Button size='sm'>{t("quotes.create.buttons.save_quote")}</Button>
</div>
</div>
</form>
</Form>
);
};

View File

@ -0,0 +1,3 @@
export * from "./useDetailColumns";
export * from "./useQuotes";
export * from "./useQuotesList";

View File

@ -0,0 +1,124 @@
import {
DataTablaRowActionFunction,
DataTableRowActions,
DataTableRowDragHandleCell,
} from "@/components";
import { Checkbox } from "@/ui";
import { ColumnDef, Row, Table } from "@tanstack/react-table";
import { MoreHorizontalIcon, UnfoldVertical } from "lucide-react";
import { useMemo } from "react";
/*function getSelectedRowRange<T>(
rows: Row<T>[],
currentID: number,
selectedID: number,
): Row<T>[] {
const rangeStart = selectedID > currentID ? currentID : selectedID;
const rangeEnd = rangeStart === currentID ? selectedID : currentID;
return rows.slice(rangeStart, rangeEnd + 1);
}*/
export function useDetailColumns<TData, TValue>(
columns: ColumnDef<TData>[],
options: {
enableDragHandleColumn?: boolean;
enableSelectionColumn?: boolean;
enableActionsColumn?: boolean;
rowActionFn?: DataTablaRowActionFunction<TData, TValue>;
} = {}
): ColumnDef<TData>[] {
const {
enableDragHandleColumn = false,
enableSelectionColumn = false,
enableActionsColumn = false,
rowActionFn = undefined,
} = options;
// const lastSelectedId = "";
return useMemo(() => {
if (enableSelectionColumn) {
columns.unshift({
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]'
/>
),
// eslint-disable-next-line @typescript-eslint/no-unused-vars
cell: ({ row, table }: { row: Row<TData>; table: Table<TData> }) => (
<Checkbox
id={`select-row-${row.id}`}
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
onCheckedChange={row.getToggleSelectedHandler()}
aria-label='Seleccionar fila'
className='translate-y-[2px]'
/*onClick={(e) => {
if (e.shiftKey) {
const { rows, rowsById } = table.getRowModel();
const rowsToToggle = getSelectedRowRange(
rows,
Number(row.id),
Number(lastSelectedId),
);
const isCellSelected = rowsById[row.id].getIsSelected();
rowsToToggle.forEach((_row) =>
_row.toggleSelected(!isCellSelected),
);
} else {
row.toggleSelected();
}
lastSelectedId = row.id;
}}*/
/>
),
enableSorting: false,
enableHiding: false,
size: 30,
});
}
if (enableDragHandleColumn) {
columns.unshift({
id: "row_drag_handle",
header: () => (
<UnfoldVertical aria-label='Mover fila' className='items-center justify-center w-4 h-4' />
),
cell: ({ row }: { row: Row<TData> }) => <DataTableRowDragHandleCell rowId={row.id} />,
size: 16,
enableSorting: false,
enableHiding: false,
});
}
if (enableActionsColumn) {
columns.push({
id: "row_actions",
header: () => (
<MoreHorizontalIcon
aria-label='Acciones'
className='items-center justify-center w-4 h-4'
/>
),
cell: (props) => {
return <DataTableRowActions {...props} actions={rowActionFn} />;
},
size: 16,
enableSorting: false,
enableHiding: false,
});
}
return columns;
}, []);
}

View File

@ -0,0 +1,48 @@
import { useOne, useSave } from "@/lib/hooks/useDataSource";
import { TDataSourceError } from "@/lib/hooks/useDataSource/types";
import { useDataSource } from "@/lib/hooks/useDataSource/useDataSource";
import { useQueryKey } from "@/lib/hooks/useQueryKey";
import {
ICreateQuote_Request_DTO,
ICreateQuote_Response_DTO,
IGetQuote_Response_DTO,
UniqueID,
} from "@shared/contexts";
export type UseQuotesGetParamsType = {
enabled?: boolean;
queryOptions?: Record<string, unknown>;
};
export const useQuotes = (params?: UseQuotesGetParamsType) => {
const dataSource = useDataSource();
const keys = useQueryKey();
return {
useQuery: useOne<IGetQuote_Response_DTO>({
queryKey: keys().data().resource("quotes").action("one").id("").params().get(),
queryFn: () =>
dataSource.getOne({
resource: "quotes",
id: "",
}),
...params,
}),
useMutation: useSave<ICreateQuote_Response_DTO, TDataSourceError, ICreateQuote_Request_DTO>({
mutationKey: keys().data().resource("quotes").action("one").id("").params().get(),
mutationFn: (data) => {
let { id } = data;
if (!id) {
id = UniqueID.generateNewID().object.toString();
}
return dataSource.updateOne({
resource: "quotes",
data,
id,
});
},
}),
};
};

View File

@ -0,0 +1,39 @@
import { UseListQueryResult, useList } from "@/lib/hooks/useDataSource";
import { useDataSource } from "@/lib/hooks/useDataSource/useDataSource";
import { useQueryKey } from "@/lib/hooks/useQueryKey";
import { IListQuotes_Response_DTO, IListResponse_DTO } from "@shared/contexts";
export type UseQuotesListParams = {
pagination: {
pageIndex: number;
pageSize: number;
};
searchTerm?: string;
enabled?: boolean;
queryOptions?: Record<string, unknown>;
};
export type UseQuotesListResponse = UseListQueryResult<
IListResponse_DTO<IListQuotes_Response_DTO>,
unknown
>;
export const useQuotesList = (params: UseQuotesListParams): UseQuotesListResponse => {
const dataSource = useDataSource();
const keys = useQueryKey();
const { pagination, searchTerm = undefined, enabled = true, queryOptions } = params;
return useList({
queryKey: keys().data().resource("quotes").action("list").params(params).get(),
queryFn: () => {
return dataSource.getList({
resource: "quotes",
quickSearchTerm: searchTerm,
pagination,
});
},
enabled,
queryOptions,
});
};

View File

@ -1 +1,2 @@
export * from "./create";
export * from "./list";

View File

@ -0,0 +1,14 @@
import { Layout, LayoutContent, LayoutHeader } from "@/components";
import { PropsWithChildren } from "react";
import { QuotesProvider } from "./QuotesContext";
export const QuotesLayout = ({ children }: PropsWithChildren) => {
return (
<QuotesProvider>
<Layout>
<LayoutHeader />
<LayoutContent>{children}</LayoutContent>
</Layout>
</QuotesProvider>
);
};

View File

@ -1,14 +1,15 @@
import { Layout, LayoutContent, LayoutHeader } from "@/components";
import { DataTableProvider } from "@/lib/hooks";
import { Trans } from "react-i18next";
import { QuotesDataTable } from "./components";
export const QuotesList = () => {
return (
<Layout>
<LayoutHeader />
<LayoutContent>
<div className='flex items-center'>
<h1 className='text-lg font-semibold md:text-2xl'>Quotes</h1>
</div>
</LayoutContent>
</Layout>
);
};
export const QuotesList = () => (
<DataTableProvider>
<div className='flex items-center'>
<h1 className='text-lg font-semibold md:text-2xl'>
<Trans i18nKey='quotes.title' />
</h1>
</div>
<QuotesDataTable />
</DataTableProvider>
);

View File

@ -0,0 +1,8 @@
import { useContext } from "react";
import { QuotesContext } from "./QuotesContext";
export const useQuotesContext = () => {
const context = useContext(QuotesContext);
if (context === null) throw new Error("useQuotes must be used within a QuotesProvider");
return context;
};

View File

@ -0,0 +1,18 @@
import { Button, ButtonProps } from "@/ui";
export interface CancelButtonProps extends ButtonProps {
label?: string;
}
export const CancelButton = ({
label = "Cancelar",
...props
}: CancelButtonProps): JSX.Element => {
return (
<Button type="button" variant="secondary" {...props}>
{label}
</Button>
);
};
CancelButton.displayName = "CancelButton";

View File

@ -0,0 +1,39 @@
import { cn } from "@/lib/utils";
import { Button, ButtonProps } from "@/ui";
import { cva, type VariantProps } from "class-variance-authority";
import { LucideIcon } from "lucide-react";
import React from "react";
const customButtonVariants = cva("", {
variants: {
size: {
default: "w-4 h-4",
sm: "h-3.5 w-3.5",
lg: "h-6 w-6",
icon: "w-7 h-7",
},
},
defaultVariants: {
size: "default",
},
});
export interface CustomButtonProps
extends ButtonProps,
VariantProps<typeof customButtonVariants> {
icon: LucideIcon; // Propiedad para proporcionar el icono personalizado
label?: string;
}
const CustomButton = React.forwardRef<HTMLButtonElement, CustomButtonProps>(
({ className, label, size, icon: Icon, children, ...props }, ref) => (
<Button ref={ref} size={size} className={cn("gap-1", className)} {...props}>
<Icon className={cn(customButtonVariants({ size }))} />
<>{label ? label : children}</>
</Button>
)
);
CustomButton.displayName = "CustomButton";
export { CustomButton, customButtonVariants };

View File

@ -0,0 +1,13 @@
import { ButtonProps } from "@/ui";
import { SaveIcon } from "lucide-react";
import { CustomButton } from "./CustomButton";
export interface SubmitButtonProps extends ButtonProps {
label?: string;
}
export const SubmitButton = ({ label = "Guardar", ...props }: SubmitButtonProps) => (
<CustomButton type='submit' label={label} icon={SaveIcon} variant='default' {...props} />
);
SubmitButton.displayName = "SubmitButton";

View File

@ -0,0 +1,2 @@
export * from "./CancelButton";
export * from "./SubmitButton";

View File

@ -10,30 +10,30 @@ import {
DropdownMenuShortcut,
DropdownMenuTrigger,
} from "@/ui";
import { CellContext } from "@tanstack/react-table";
import { CellContext, Row } from "@tanstack/react-table";
import { t } from "i18next";
import { MoreHorizontalIcon } from "lucide-react";
type DataTableRowActionContext<TData, TValue> = CellContext<TData, TValue>;
type DataTableRowActionContext<TData, TValue> = CellContext<TData, TValue> & {
row: Row<TData>;
};
export type DataTablaRowActionFunction<TData, TValue> = (
props: DataTableRowActionContext<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;
onClick?: (props: DataTableRowActionContext<TData, TValue>, e: React.BaseSyntheticEvent) => void;
};
export type DataTableRowActionsProps<TData, TValue> = {
props: DataTableRowActionContext<TData, TValue>;
actions?: DataTablaRowActionFunction<TData, TValue>;
row?: DataTableRowActionContext<TData, TValue>;
};
export function DataTableRowActions<TData, TValue>({
export function DataTableRowActions<TData = any, TValue = any>({
actions,
...props
}: DataTableRowActionsProps<TData, TValue>) {
@ -41,18 +41,18 @@ export function DataTableRowActions<TData, TValue>({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-haspopup="true"
size="icon"
variant="link"
className="w-4 h-4 translate-y-[2px]"
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>
<MoreHorizontalIcon className='w-4 h-4' />
<span className='sr-only'>{t("common.open_menu")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Acciones</DropdownMenuLabel>
<DropdownMenuContent align='end'>
<DropdownMenuLabel>{t("common.actions")} </DropdownMenuLabel>
{actions &&
actions(props).map((action, index) =>
action.label === "-" ? (
@ -60,14 +60,12 @@ export function DataTableRowActions<TData, TValue>({
) : (
<DropdownMenuItem
key={index}
onClick={(event) =>
action.onClick ? action.onClick(props, event) : null
}
onClick={(event) => (action.onClick ? action.onClick(props, event) : null)}
>
{action.label}
<DropdownMenuShortcut>{action.shortcut}</DropdownMenuShortcut>
</DropdownMenuItem>
),
)
)}
</DropdownMenuContent>
</DropdownMenu>

View File

@ -9,29 +9,29 @@ export const SimpleEmptyState = ({
actions = undefined,
}) => {
return (
<div className="text-center">
<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"
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"
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"
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>
<h3 className='mt-2 text-lg font-semibold text-slate-900'>{title}</h3>
<p className='mt-1 text-base text-slate-500'>{subtitle}</p>
<div className="items-center mt-6">
<div className='items-center mt-6'>
{actions && <>{actions}</>}
{!actions && (
<Button className="my-4" onClick={onButtonClick}>
<Button className='my-4' onClick={onButtonClick}>
<PlusIcon />
{buttonText}
</Button>

View File

@ -0,0 +1,126 @@
import * as React from "react";
import { useState } from "react";
import { FieldPath, FieldValues, useFormContext } from "react-hook-form";
// ShadCn
import {
Button,
Calendar,
FormControl,
FormDescription,
FormField,
FormItem,
FormMessage,
InputProps,
Popover,
PopoverContent,
PopoverTrigger,
} from "@/ui";
import { UseControllerProps } from "react-hook-form";
import { FormLabel, FormLabelProps } from "./FormLabel";
import { FormInputProps } from "./FormProps";
import { CalendarIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { t } from "i18next";
type FormDatePickerFieldProps<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
> = InputProps &
FormInputProps &
Partial<FormLabelProps> &
UseControllerProps<TFieldValues, TName> & {};
/*const loadDateFnsLocale = async (locale: Locale) => {
return await import(`date-fns/locale/${locale.code}/index.js`);
};*/
export const FormDatePickerField = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & FormDatePickerFieldProps
>((props: FormDatePickerFieldProps, ref) => {
const {
label,
placeholder,
hint,
description,
required,
className,
disabled,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
errors,
name,
type,
} = props;
const { control } = useFormContext();
//const { locale } = loadDateFnsLocale();
/*if (locale) {
setDefaultOptions({ locale: locale.default });
}*/
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
return (
<FormField
control={control}
name={name}
rules={{ required }}
disabled={disabled}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
render={({ field, fieldState, formState }) => (
<FormItem ref={ref} className={cn(className, "flex flex-col")}>
{label && <FormLabel label={label} hint={hint} />}
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverTrigger asChild>
<FormControl>
<Button
variant={"outline"}
className={cn(
"pl-3 text-left font-normal",
!field.value && "text-muted-foreground"
)}
>
{field.value ? (
new Date(field.value).toLocaleDateString() //"en-US", DATE_OPTIONS)
) : (
<span>{t("common.pick_date")}</span>
)}
<CalendarIcon className='w-4 h-4 ml-auto text-input' />
</Button>
</FormControl>
</PopoverTrigger>
<PopoverContent className='w-auto p-0' align='start'>
<Calendar
className='bg-background'
mode='single'
captionLayout='buttons'
defaultMonth={field.value}
selected={new Date(field.value)}
onSelect={(e) => {
field.onChange(e);
setIsPopoverOpen(false);
}}
disabled={(date) => date < new Date("2024-06-01")}
weekStartsOn={1}
fixedWeeks={true}
fromYear={2024}
toYear={new Date().getFullYear() + 1}
initialFocus
/>
</PopoverContent>
</Popover>
{description && <FormDescription>{description}</FormDescription>}
<FormMessage />
</FormItem>
)}
/>
);
});

View File

@ -15,10 +15,11 @@ export const FormLabel = React.forwardRef<
Pick<FormInputProps, "required">
>(({ label, hint, required, ...props }, ref) => {
const _hint = hint ? hint : required ? "obligatorio" : undefined;
const _hintClassName = required ? "text-destructive" : "";
return (
<UI.FormLabel ref={ref} className="flex justify-between" {...props}>
<span className="block font-semibold">{label}</span>
{_hint && <span className="font-normal">{_hint}</span>}
<UI.FormLabel ref={ref} className='flex justify-between text-sm' {...props}>
<span className='block font-semibold'>{label}</span>
{_hint && <span className={`text-sm font-medium ${_hintClassName}`}>{_hint}</span>}
</UI.FormLabel>
);
});

View File

@ -10,7 +10,13 @@ import {
} from "@/ui";
import * as React from "react";
import { FieldErrors, FieldPath, FieldValues, UseControllerProps } from "react-hook-form";
import {
FieldErrors,
FieldPath,
FieldValues,
UseControllerProps,
useFormContext,
} from "react-hook-form";
import { FormLabel, FormLabelProps } from "./FormLabel";
export type FormTextAreaFieldProps<
@ -29,36 +35,50 @@ export type FormTextAreaFieldProps<
export const FormTextAreaField = React.forwardRef<
HTMLDivElement,
React.TextareaHTMLAttributes<HTMLTextAreaElement> & FormTextAreaFieldProps
>(({ label, hint, placeholder, description, autoSize, className, ...props }, ref) => {
return (
<FormField
control={props.control}
name={props.name}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
render={({ field, fieldState, formState }) => (
<FormItem ref={ref} className={cn(className, "flex flex-col")}>
{label && <FormLabel label={label} hint={hint} />}
<FormControl>
{autoSize ? (
<AutosizeTextarea
disabled={props.disabled}
placeholder={placeholder}
className='resize-y'
{...field}
/>
) : (
<Textarea
disabled={props.disabled}
placeholder={placeholder}
className='resize-y'
{...field}
/>
)}
</FormControl>
{description && <FormDescription>{description}</FormDescription>}
<FormMessage />
</FormItem>
)}
/>
);
});
>(
(
{
name,
label,
hint,
description,
placeholder,
required,
disabled,
autoSize,
className,
...props
},
ref
) => {
const { control } = useFormContext();
return (
<FormField
control={control}
name={name}
rules={{ required }}
disabled={disabled}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
render={({ field, fieldState, formState }) => (
<FormItem ref={ref} className={cn(className, "flex flex-col space-y-3")}>
{label && <FormLabel label={label} hint={hint} required={required} />}
<FormControl className='grow'>
{autoSize ? (
<AutosizeTextarea
{...field}
placeholder={placeholder}
className='resize-y'
{...props}
/>
) : (
<Textarea {...field} placeholder={placeholder} className='resize-y' {...props} />
)}
</FormControl>
{description && <FormDescription>{description}</FormDescription>}
<FormMessage />
</FormItem>
)}
/>
);
}
);

View File

@ -11,7 +11,13 @@ import {
import * as React from "react";
import { createElement } from "react";
import { FieldErrors, FieldPath, FieldValues, UseControllerProps } from "react-hook-form";
import {
FieldErrors,
FieldPath,
FieldValues,
UseControllerProps,
useFormContext,
} from "react-hook-form";
import { FormLabel, FormLabelProps } from "./FormLabel";
import { FormInputProps, FormInputWithIconProps } from "./FormProps";
@ -46,10 +52,11 @@ export const FormTextField = React.forwardRef<
// eslint-disable-next-line @typescript-eslint/no-unused-vars
errors,
name,
control,
type,
} = props;
const { control } = useFormContext();
return (
<FormField
control={control}
@ -59,7 +66,7 @@ export const FormTextField = React.forwardRef<
// eslint-disable-next-line @typescript-eslint/no-unused-vars
render={({ field, fieldState, formState }) => (
<FormItem ref={ref} className={cn(className, "space-y-3")}>
{label && <FormLabel label={label} hint={hint} />}
{label && <FormLabel label={label} hint={hint} required={required} />}
<div className={cn(button ? "flex" : null)}>
<div
className={cn(

View File

@ -1,3 +1,4 @@
export * from "./FormDatePickerField";
export * from "./FormGroup";
export * from "./FormLabel";
export * from "./FormMoneyField";

View File

@ -9,7 +9,7 @@ import { UserButton } from "./components/UserButton";
export const LayoutHeader = () => {
return (
<header className='sticky top-0 z-10 flex items-center h-16 gap-8 px-4 border-b bg-background md:px-6'>
<header className='sticky top-0 z-10 flex items-center h-16 gap-8 px-4 border-b bg-primary md:px-6'>
<nav className='flex-col hidden gap-6 text-lg font-medium md:flex md:flex-row md:items-center md:gap-5 md:text-sm lg:gap-6'>
<Link to='/' className='flex items-center font-semibold'>
<UeckoLogo className='w-24' />
@ -78,7 +78,7 @@ export const LayoutHeader = () => {
<Input
type='search'
placeholder={t("main_menu.search_placeholder")}
className='pl-8 sm:w-[300px] md:w-[200px] lg:w-[350px] xl:w-[550px] 2xl:w-[750px]'
className='pl-8 sm:w-[300px] md:w-[200px] lg:w-[300px] xl:w-[550px] 2xl:w-[750px]'
/>
</div>
</form>

View File

@ -33,7 +33,6 @@ export const UserButton = () => {
},
});
const { data, status } = useGetIdentity();
console.log(data, status);
const openUserMenu = (event: SyntheticEvent) => {
@ -41,57 +40,53 @@ export const UserButton = () => {
setUserMenuOpened(true);
};
/*const closeUserMenu = (event: SyntheticEvent) => {
event.preventDefault();
setUserMenuOpened(false);
};*/
/*if (status !== "success") {
return <></>;
}*/
return (
<DropdownMenu open={userMenuOpened} onOpenChange={setUserMenuOpened}>
<DropdownMenuTrigger asChild>
<>
{status === "success" && (
<div className='grid gap-1 text-right'>
<p className='text-sm font-medium leading-none'>{data?.name}</p>
<p className='text-sm text-muted-foreground'>{data?.email}</p>
</div>
)}
<Button variant='secondary' size='icon' className='rounded-full' onClick={openUserMenu}>
<CircleUserIcon className='w-5 h-5' />
<>
{status === "success" && (
<div className='grid gap-1 text-right'>
<p className='text-xs font-medium leading-none'>{data?.name}</p>
<p className='text-xs text-muted-foreground'>{data?.email}</p>
</div>
)}
<DropdownMenu open={userMenuOpened} onOpenChange={setUserMenuOpened}>
<DropdownMenuTrigger asChild>
<Button
variant='outline'
size='icon'
className='overflow-hidden rounded-full'
onClick={openUserMenu}
>
<CircleUserIcon className='w-5 h-5 accent-current' />
<span className='sr-only'>{t("main_menu.user.user_menu")}</span>
</Button>
</>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-56'>
<DropdownMenuLabel>{t("main_menu.user.my_account")}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<UserIcon className='w-4 h-4 mr-2' />
<span>{t("main_menu.user.profile")}</span>
<DropdownMenuShortcut>P</DropdownMenuShortcut>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-56'>
<DropdownMenuLabel>{t("main_menu.user.my_account")}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem>
<UserIcon className='w-4 h-4 mr-2' />
<span>{t("main_menu.user.profile")}</span>
<DropdownMenuShortcut>P</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<SettingsIcon className='w-4 h-4 mr-2' />
<span>{t("main_menu.user.settings")}</span>
</DropdownMenuItem>
<DropdownMenuItem>
<MessageCircleQuestionIcon className='w-4 h-4 mr-2' />
<span>{t("main_menu.user.support")}</span>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => openLogoutDialog()}>
<LogOutIcon className='w-4 h-4 mr-2' />
<span>{t("main_menu.user.logout")}</span>
<DropdownMenuShortcut>Q</DropdownMenuShortcut>
</DropdownMenuItem>
<DropdownMenuItem>
<SettingsIcon className='w-4 h-4 mr-2' />
<span>{t("main_menu.user.settings")}</span>
</DropdownMenuItem>
<DropdownMenuItem>
<MessageCircleQuestionIcon className='w-4 h-4 mr-2' />
<span>{t("main_menu.user.support")}</span>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => openLogoutDialog()}>
<LogOutIcon className='w-4 h-4 mr-2' />
<span>{t("main_menu.user.logout")}</span>
<DropdownMenuShortcut>Q</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
{LogoutDialog}
</DropdownMenu>
</DropdownMenuContent>
{LogoutDialog}
</DropdownMenu>
</>
);
};

View File

@ -1,4 +1,5 @@
export * from "./ButtonGroup";
export * from "./Buttons";
export * from "./Container";
export * from "./CustomButtons";
export * from "./CustomDialog";

View File

@ -2,51 +2,51 @@
@tailwind components;
@tailwind utilities;
/* https://ui.jln.dev/ Shivering Harpy Brown */
/* https://ui.jln.dev/ Teriyaki */
@layer base {
:root {
--background: 30 65% 100%;
--foreground: 30 75% 0%;
--muted: 30 17% 95%;
--muted-foreground: 30 15% 26%;
--popover: 0 0% 99%;
--popover-foreground: 0 0% 0%;
--card: 0 0% 99%;
--card-foreground: 0 0% 0%;
--border: 30 4% 91%;
--input: 30 4% 91%;
--primary: 30 26% 23%;
--primary-foreground: 30 26% 83%;
--secondary: 30 12% 82%;
--secondary-foreground: 30 12% 22%;
--accent: 30 22% 76%;
--accent-foreground: 30 22% 16%;
--destructive: 18 91% 33%;
--destructive-foreground: 0 0% 100%;
--ring: 30 26% 23%;
--background: 25 31% 100%;
--foreground: 25 67% 4%;
--muted: 25 30% 95%;
--muted-foreground: 25 2% 29%;
--popover: 25 70% 98%;
--popover-foreground: 25 67% 4%;
--card: 25 70% 98%;
--card-foreground: 25 67% 4%;
--border: 220 13% 91%;
--input: 25 31% 75%;
--primary: 25 31% 75%;
--primary-foreground: 25 31% 15%;
--secondary: 25 18% 90%;
--secondary-foreground: 25 18% 30%;
--accent: 25 23% 83%;
--accent-foreground: 25 23% 23%;
--destructive: 13 96% 20%;
--destructive-foreground: 13 96% 80%;
--ring: 25 31% 75%;
--radius: 0.5rem;
}
.dark {
--background: 30 53% 1%;
--foreground: 30 11% 98%;
--muted: 30 17% 5%;
--muted-foreground: 30 15% 74%;
--popover: 30 53% 2%;
--popover-foreground: 30 11% 99%;
--card: 30 53% 2%;
--card-foreground: 30 11% 99%;
--border: 30 4% 14%;
--input: 30 4% 14%;
--primary: 30 26% 23%;
--primary-foreground: 30 26% 83%;
--secondary: 30 7% 15%;
--secondary-foreground: 30 7% 75%;
--accent: 30 16% 24%;
--accent-foreground: 30 16% 84%;
--destructive: 18 91% 59%;
--destructive-foreground: 0 0% 0%;
--ring: 30 26% 23%;
--background: 25 41% 2%;
--foreground: 25 21% 98%;
--muted: 25 30% 5%;
--muted-foreground: 25 2% 71%;
--popover: 25 41% 2%;
--popover-foreground: 25 21% 98%;
--card: 25 41% 2%;
--card-foreground: 25 21% 98%;
--border: 215 27.9% 16.9%;
--input: 215 27.9% 16.9%;
--primary: 25 31% 75%;
--primary-foreground: 25 31% 15%;
--secondary: 25 5% 14%;
--secondary-foreground: 25 5% 74%;
--accent: 25 11% 20%;
--accent-foreground: 25 11% 80%;
--destructive: 13 96% 49%;
--destructive-foreground: 0 0% 100%;
--ring: 25 31% 75%;
}
}

View File

@ -25,7 +25,7 @@ export const createAxiosAuthActions = (
return {
success: true,
data,
redirectTo: "/",
redirectTo: "/home",
};
} catch (error) {
return {
@ -60,6 +60,11 @@ export const createAxiosAuthActions = (
},
getIdentity: async () => {
const errorResult = {
message: "Identification failed",
name: "Invalid profile or identification",
};
try {
const result = await httpClient.request<IIdentity_Response_DTO>({
url: `${apiUrl}/auth/identity`,
@ -71,11 +76,11 @@ export const createAxiosAuthActions = (
if (profile?.id === data?.id) {
secureLocalStorage.setItem("uecko.profile", data);
return data;
return Promise.resolve(data);
}
return undefined;
return Promise.reject(errorResult);
} catch (error) {
return undefined;
return Promise.reject(errorResult);
}
},

View File

@ -59,6 +59,7 @@ const onResponseError = (error: AxiosError): Promise<AxiosError> => {
break;
case 401:
console.error("UnAuthorized");
//return (window.location.href = "/logout");
break;
case 403:
console.error("Forbidden");

View File

@ -14,5 +14,6 @@ export * from "./useUrlId";
export * from "./useAuth";
export * from "./useCustomDialog";
export * from "./useDataTable";
export * from "./useLocalization";
export * from "./usePagination";
export * from "./useTheme";

View File

@ -1,3 +1,5 @@
import { IIdentity_Response_DTO } from "@shared/contexts";
export type SuccessNotificationResponse = {
message: string;
description?: string;
@ -5,7 +7,7 @@ export type SuccessNotificationResponse = {
export type PermissionResponse = unknown;
export type IdentityResponse = unknown;
export type IdentityResponse = IIdentity_Response_DTO;
export type AuthActionCheckResponse = {
authenticated: boolean;
@ -31,7 +33,7 @@ export type AuthActionResponse = {
export interface IAuthActions {
login: (params: any) => Promise<AuthActionResponse>;
logout: (params: any) => Promise<AuthActionResponse>;
check: (params?: any) => Promise<AuthActionCheckResponse>;
check: () => Promise<AuthActionCheckResponse>;
onError?: (error: any) => Promise<AuthActionOnErrorResponse>;
register?: (params: unknown) => Promise<AuthActionResponse>;
forgotPassword?: (params: unknown) => Promise<AuthActionResponse>;

View File

@ -1,9 +1,9 @@
import { PropsWithChildren, createContext } from "react";
import { IAuthActions } from "./AuthActions";
export interface IAuthContextState extends IAuthActions {}
export interface IAuthContextState extends Partial<IAuthActions> {}
export const AuthContext = createContext<IAuthContextState | null>(null);
export const AuthContext = createContext<Partial<IAuthContextState>>({});
export const AuthProvider = ({
children,
@ -27,9 +27,9 @@ export const AuthProvider = ({
}
};
const handleCheck = async (params: unknown) => {
const handleCheck = async () => {
try {
return Promise.resolve(authActions.check?.(params));
return Promise.resolve(authActions.check?.());
} catch (error) {
console.error(error);
return Promise.reject(error);

View File

@ -2,15 +2,15 @@ import { IdentityResponse, useAuth } from "@/lib/hooks";
import { UseQueryOptions, useQuery } from "@tanstack/react-query";
import { useQueryKey } from "../useQueryKey";
export const useGetIdentity = (queryOptions?: UseQueryOptions) => {
export const useGetIdentity = (queryOptions?: UseQueryOptions<IdentityResponse>) => {
const keys = useQueryKey();
const { getIdentity } = useAuth();
return useQuery<IdentityResponse, Error>({
const result = useQuery<IdentityResponse>({
queryKey: keys().auth().action("identity").get(),
queryFn: getIdentity,
...queryOptions,
});
};
export const isAdmin = () => true;
return result;
};

View File

@ -1,14 +1,16 @@
import { useAuth } from "@/lib/hooks";
import { AuthActionCheckResponse, useAuth } from "@/lib/hooks";
import { UseQueryOptions, useQuery } from "@tanstack/react-query";
import { useQueryKey } from "../useQueryKey";
export const useIsLoggedIn = (queryOptions?: UseQueryOptions) => {
export const useIsLoggedIn = (queryOptions?: UseQueryOptions<AuthActionCheckResponse>) => {
const keys = useQueryKey();
const { check } = useAuth();
return useQuery({
const result = useQuery<AuthActionCheckResponse>({
queryKey: keys().auth().action("check").get(),
queryFn: check,
...queryOptions,
});
return result;
};

View File

@ -14,10 +14,10 @@ export const useLogin = (params?: UseMutationOptions<AuthActionResponse, Error,
return useMutation({
mutationKey: keys().auth().action("login").get(),
mutationFn: login,
onSuccess: async (data, variables, context) => {
onSuccess: (data, variables, context) => {
const { success, redirectTo } = data;
if (success && redirectTo) {
navigate(redirectTo);
navigate(redirectTo, { replace: true });
}
if (onSuccess) {
onSuccess(data, variables, context);

View File

@ -0,0 +1,20 @@
import { DefaultError } from "@tanstack/react-query";
export type TDataSourceRecords = {
page: number;
per_page: number;
total_pages: number;
total_items: number;
items: TDataSourceRecord[];
};
export type TDataSourceRecord = {
id?: string | number;
[key: string]: any;
};
export type TDataSourceError = DefaultError;
export type TDataSourceVariables = Record<string, any>;
export type TDataSourceContext = unknown;

View File

@ -1,49 +1,14 @@
import {
QueryFunctionContext,
QueryKey,
UseQueryResult,
keepPreviousData,
useQuery,
} from "@tanstack/react-query";
import { UseQueryOptions, UseQueryResult, keepPreviousData, useQuery } from "@tanstack/react-query";
import {
UseLoadingOvertimeOptionsProps,
UseLoadingOvertimeReturnType,
} from "../useLoadingOvertime/useLoadingOvertime";
import { TDataSourceError, TDataSourceRecord } from "./types";
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export type UseOneQueryOptions<TUseOneQueryData, TUseOneQueryError> = {
queryKey: QueryKey;
queryFn: (context: QueryFunctionContext) => Promise<TUseOneQueryData>;
enabled?: boolean;
autoRefresh?: boolean;
select?: (data: TUseOneQueryData) => TUseOneQueryData;
queryOptions?: Record<string, unknown>;
} & UseLoadingOvertimeOptionsProps;
export type UseOneQueryResult<TUseOneQueryData, TUseOneQueryError> = UseQueryResult<
TUseOneQueryData,
TUseOneQueryError
> & {
isEmpty: boolean;
} & UseLoadingOvertimeReturnType;
export function useOne<TUseOneQueryData, TUseOneQueryError = Error>({
queryKey,
queryFn,
enabled,
select,
queryOptions = {},
}: UseOneQueryOptions<TUseOneQueryData, TUseOneQueryError>): UseQueryResult<
TUseOneQueryData,
TUseOneQueryError
> {
return useQuery<TUseOneQueryData, TUseOneQueryError>({
queryKey,
queryFn,
export function useOne<
TQueryFnData extends TDataSourceRecord = TDataSourceRecord,
TError = TDataSourceError,
TData extends TDataSourceRecord = TQueryFnData
>(options: UseQueryOptions<TQueryFnData, TError, TData>): UseQueryResult<TData, TError> {
return useQuery<TQueryFnData, TError, TData>({
placeholderData: keepPreviousData,
enabled,
select,
...queryOptions,
...options,
});
}

View File

@ -1,43 +1,18 @@
import { UseMutationOptions, useMutation } from "@tanstack/react-query";
import {
DefaultError,
UseMutationOptions,
useMutation,
} from "@tanstack/react-query";
export interface IUseSaveMutationOptions<
TUseSaveMutationData = unknown,
TUseSaveMutationError = DefaultError,
TUseSaveMutationVariables = unknown,
TUseSaveMutationContext = unknown,
> extends UseMutationOptions<
TUseSaveMutationData,
TUseSaveMutationError,
TUseSaveMutationVariables,
TUseSaveMutationContext
> {}
TDataSourceContext,
TDataSourceError,
TDataSourceRecord,
TDataSourceVariables,
} from "./types";
export function useSave<
TUseSaveMutationData = unknown,
TUseSaveMutationError = DefaultError,
TUseSaveMutationVariables = unknown,
TUseSaveMutationContext = unknown,
>(
options: IUseSaveMutationOptions<
TUseSaveMutationData,
TUseSaveMutationError,
TUseSaveMutationVariables,
TUseSaveMutationContext
>,
) {
const { mutationFn, ...params } = options;
return useMutation<
TUseSaveMutationData,
TUseSaveMutationError,
TUseSaveMutationVariables,
TUseSaveMutationContext
>({
mutationFn,
...params,
TData extends TDataSourceRecord = TDataSourceRecord,
TError extends TDataSourceError = TDataSourceError,
TVariables extends TDataSourceVariables = TDataSourceVariables,
TContext extends TDataSourceContext = TDataSourceContext
>(options: UseMutationOptions<TData, TError, TVariables, TContext>) {
return useMutation<TData, TError, TVariables, TContext>({
...options,
});
}

View File

@ -0,0 +1 @@
export * from "./useLocatlization";

View File

@ -0,0 +1,53 @@
/* https://github.com/mayank8aug/use-localization/blob/main/src/index.ts */
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { LocaleToCurrencyTable, rtlLangsList } from "./utils";
type UseLocalizationProps = {
locale: string;
};
export const useLocalization = (props: UseLocalizationProps) => {
const { locale } = props;
const [lang, loc] = locale.split("-");
const { i18n } = useTranslation();
// Obtener el idioma actual
const currentLanguage = i18n.language;
const formatCurrency = useCallback(
(value: number) => {
return new Intl.NumberFormat(locale, {
style: "currency",
currency: LocaleToCurrencyTable[locale],
}).format(value);
},
[locale]
);
const formatNumber = useCallback(
(value: number) => {
return new Intl.NumberFormat(locale).format(value);
},
[locale]
);
const flag = useMemo(
() =>
typeof String.fromCodePoint !== "undefined"
? loc
.toUpperCase()
.replace(/./g, (char) => String.fromCodePoint(char.charCodeAt(0) + 127397))
: "",
[loc]
);
return {
formatCurrency,
formatNumber,
flag,
isRTL: rtlLangsList.includes(lang),
};
};

View File

@ -0,0 +1,13 @@
import { useGetIdentity } from "../useAuth";
export const usePreferredLanguage = () => {
const { data } = useGetIdentity();
if (!data || !data.language) {
return navigator.languages && navigator.languages.length
? navigator.languages[0]
: navigator.language;
}
return data.language;
};

View File

@ -0,0 +1,110 @@
export const LocaleToCurrencyTable: { [key: string]: string } = {
"sq-AL": "ALL", // ALBANIA
"ar-DZ": "DZD", // ALGERIA
"ar-BH": "BHD", // BAHRAIN
"ar-EG": "EGP", // EGYPT
"ar-IQ": "IQD", // IRAQ
"ar-JO": "JOD", // JORDAN
"ar-KW": "KWD", // KUWAIT
"ar-LB": "LBP", // LEBANON
"ar-LY": "LYD", // LIBYA
"ar-MA": "MAD", // MOROCCO
"ar-OM": "OMR", // OMAN
"ar-QA": "QAR", // QATAR
"ar-SA": "SAR", // SAUDI ARABIA
"ar-SD": "SDG", // SUDAN
"ar-SY": "SYP", // SYRIA
"ar-TN": "TND", // TUNISIA
"ar-AE": "AED", // UAE
"ar-YE": "YER", // YEMEN
"be-BY": "BYN", // BELARUS
"bg-BG": "BGN", // BULGARIA
"ca-ES": "EUR", // SPAIN
"zh-CN": "CNY", // CHINA
"zh-HK": "HKD", // HONG KONG
"zh-SG": "SGD", // SINGAPORE
"zh-TW": "TWD", // TAIWAN
"hr-HR": "HRK", // CROATIA
"cs-CZ": "CZK", // CZECH REPUBLIC
"da-DK": "DKK", // DENMARK
"nl-BE": "EUR", // BELIGIUM
"nl-NL": "EUR", // NETHERLANDS
"en-AU": "AUD", // AUSTRALIA
"en-CA": "CAD", // CANADA
"en-IN": "INR", // INDIA
"en-IE": "EUR", // IRELAND
"en-MT": "EUR", // MALTA
"en-NZ": "NZD", // NEW ZEALAND
"en-PH": "PHP", // PHILIPPINES
"en-SG": "SGD", // SINGAPORE
"en-ZA": "ZAR", // SOUTH AFRICA
"en-GB": "GBP", // UNITED KINGDOM
"en-US": "USD", // US
"et-EE": "EUR", // ESTONIA
"fi-FI": "EUR", // FINLAND
"fr-BE": "EUR", // BELGIUM
"fr-CA": "CAD", // CANADA
"fr-FR": "EUR", // FRANCE
"fr-LU": "EUR", // LUXEMBOURG
"fr-CH": "CHF", // SWITZERLAND
"de-AT": "EUR", // AUSTRIA
"de-DE": "EUR", // GERMANY
"de-LU": "EUR", // LUXEMBOURG
"de-CH": "CHF", // SWITZERLAND
"el-CY": "EUR", // CYPRUS
"el-GR": "EUR", // GREECE
"iw-IL": "ILS", // ISRAEL
"hi-IN": "INR", // INDIA
"hu-HU": "HUF", // HUNGARY
"is-IS": "ISK", // ICELAND
"in-ID": "IDR", // INDONESIA
"ga-IE": "EUR", // IRELAND
"it-IT": "EUR", // ITALY
"it-CH": "CHF", // SWITZERLAND
"ja-JP": "JPY", // JAPAN
"ko-KR": "KRW", // SOUTH KOREA
"lv-LV": "EUR", // LATVIA
"lt-LT": "EUR", // LITHUANIA
"mk-MK": "MKD", // MACEDONIA
"ms-MY": "MYR", // MALAYSIA
"mt-MT": "EUR", // MALTA
"no-NO": "NOK", // NORWAY
"pl-PL": "PLN", // POLAND
"pt-BR": "BRL", // BRAZIL
"pt-PT": "EUR", // PORTUGAL
"ro-RO": "RON", // ROMANIA
"ru-RU": "RUB", // RUSSIA
"sr-BA": "BAM", // BOSNIA AND HERZEGOVINA
"sr-ME": "EUR", // MONTENEGRO
"sr-CS": "EURO", // SERBIA AND MONTENEGRO
"sr-RS": "RSD", // SERBIA
"sk-SK": "EUR", // SLOVAKIA
"sl-SI": "EUR", // SLOVENIA
"es-AR": "ARS", // ARGENTINA
"es-BO": "BOB", // BOLIVIA
"es-CL": "CLP", // CHILE
"es-CO": "COP", // COLOMBIA
"es-CR": "CRC", // COSTA RICA
"es-DO": "DOP", // DOMINICAN REPUBLIC
"es-EC": "USD", // ECUADOR
"es-SV": "USD", // EL SALVADOR
"es-GT": "GTQ", // GUATEMALA
"es-HN": "HNL", // HONDURAS
"es-MX": "MXN", // MEXICO
"es-NI": "NIO", // NICARAGUA
"es-PA": "USD", // PANAMA
"es-PY": "PYG", // PARAGUAY
"es-PE": "PEN", // PERU
"es-PR": "USD", // PUERTO RICO
"es-ES": "EUR", // SPAIN
"es-US": "USD", // US
"es-UY": "UYU", // URUGUAY
"es-VE": "VEF", // VENEZUELA
"sv-SE": "SEK", // SWEDEN
"th-TH": "THB", // THAILAND
"tr-TR": "TRY", // TURKEY
"uk-UA": "UAH", // UKRAINE
"vi-VN": "VND", // VIETNAM
};
export const rtlLangsList = ["ar", "fa", "he", "ps", "ur"];

View File

@ -0,0 +1 @@
export * from "./useUploadFile";

View File

@ -0,0 +1,57 @@
import * as React from "react";
import { toast } from "sonner";
import { type OurFileRouter } from "@/app/api/uploadthing/core";
import { getErrorMessage } from "@/lib/handle-error";
import { uploadFiles } from "@/lib/uploadthing";
export interface UploadedFile {}
interface UseUploadFileProps
extends Pick<
UploadFilesOptions<OurFileRouter, keyof OurFileRouter>,
"headers" | "onUploadBegin" | "onUploadProgress" | "skipPolling"
> {
defaultUploadedFiles?: UploadedFile[];
}
export function useUploadFile(
endpoint: keyof OurFileRouter,
{ defaultUploadedFiles = [], ...props }: UseUploadFileProps = {}
) {
const [uploadedFiles, setUploadedFiles] = React.useState<UploadedFile[]>(defaultUploadedFiles);
const [progresses, setProgresses] = React.useState<Record<string, number>>({});
const [isUploading, setIsUploading] = React.useState(false);
async function uploadThings(files: File[]) {
setIsUploading(true);
try {
const res = await uploadFiles(endpoint, {
...props,
files,
onUploadProgress: ({ file, progress }) => {
setProgresses((prev) => {
return {
...prev,
[file]: progress,
};
});
},
});
setUploadedFiles((prev) => (prev ? [...prev, ...res] : res));
} catch (err) {
toast.error(getErrorMessage(err));
} finally {
setProgresses({});
setIsUploading(false);
}
}
return {
uploadedFiles,
progresses,
uploadFiles: uploadThings,
isUploading,
};
}

View File

@ -7,6 +7,8 @@
"save": "Guardar",
"accept": "Aceptar",
"hide": "Ocultar",
"back": "Volver",
"upload": "Cargar",
"sort_asc": "Asc",
"sort_asc_description": "En order ascendente. Click para ordenar descendentemente.",
"sort_desc": "Desc",
@ -21,7 +23,12 @@
"go_to_last_page": "Ir a la última página",
"filter_placeholder": "Escribe aquí para filtrar...",
"reset_filter": "Quitar el filtro",
"error": "Error"
"error": "Error",
"actions": "Acciones",
"open_menu": "Abrir el menú",
"duplicate_rows": "Duplicar",
"duplicate_rows_tooltip": "Duplica las fila(s) seleccionadas(s)",
"pick_date": "Elige una fecha"
},
"main_menu": {
"home": "Inicio",
@ -64,6 +71,81 @@
}
}
},
"quotes": {
"list": {
"title": "Cotizaciones"
},
"status": {
"draft": "Borrador"
},
"create": {
"title": "Nueva cotización",
"buttons": {
"save_quote": "Guardar cotización",
"discard": "Descartar"
},
"tabs": {
"general": "Datos generales",
"items": "Contenido",
"documents": "Documentos",
"history": "Historial"
},
"form_groups": {
"general": {
"title": "Datos generales",
"desc": "Datos generales y cliente al que va la cotización"
},
"status": {
"title": "Estado",
"desc": "Estado de la cotización"
},
"items": {
"title": "Contenido de la cotización",
"desc": "Líneas de detalle de la cotización. Ayúdese del catálogo para rellenar más fácilmente el contenido."
},
"documents": {
"title": "Documentos",
"desc": "Añada adjuntar con su cotización documentos como fotos, planos, croquis, etc."
},
"history": {
"title": "",
"desc": ""
}
},
"form_fields": {
"date": {
"label": "Fecha",
"desc": "Fecha de esta cotización",
"placeholder": ""
},
"reference": {
"label": "Referencia",
"desc": "Referencia para esta cotización",
"placeholder": ""
},
"customer_information": {
"label": "Cliente",
"desc": "Datos del cliente de esta cotización",
"placeholder": "Nombre\nDirección\n..."
},
"payment_method": {
"label": "Forma de pago",
"placeholder": "placeholder",
"desc": "desc"
},
"notes": {
"label": "Notas",
"placeholder": "",
"desc": "desc"
},
"validity": {
"label": "Validez de la cotización",
"placeholder": "",
"desc": "desc"
}
}
}
},
"settings": {
"title": "Ajustes",
"quotes": {
@ -91,7 +173,7 @@
"default_quote_validity": {
"label": "Validez por defecto",
"placeholder": "",
"desc": "desc"
"desc": ""
}
}
}

View File

@ -1,9 +1,9 @@
import i18n from "i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import { initReactI18next } from "react-i18next";
import catResources from "../locales/ca.json";
import enResources from "../locales/en.json";
import esResources from "../locales/es.json";
import catResources from "./ca.json";
import enResources from "./en.json";
import esResources from "./es.json";
i18n
// detect user language
@ -14,8 +14,8 @@ i18n
// init i18next
// for all options read: https://www.i18next.com/overview/configuration-options
.init({
debug: true,
fallbackLng: "en",
debug: false,
fallbackLng: "es",
interpolation: {
escapeValue: false,
},

View File

@ -3,7 +3,7 @@ import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import "./index.css";
import "./lib/i18n";
import "./locales/i18n.ts";
ReactDOM.createRoot(document.getElementById("uecko")!).render(
<React.StrictMode>

View File

@ -1,79 +1,56 @@
import * as React from "react"
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("rounded-md border bg-card text-card-foreground shadow", className)}
{...props}
/>
)
);
Card.displayName = "Card";
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
)
);
CardHeader.displayName = "CardHeader";
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
)
);
CardTitle.displayName = "CardTitle";
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
<p ref={ref} className={cn("text-sm text-muted-foreground", className)} {...props} />
));
CardDescription.displayName = "CardDescription";
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
)
);
CardContent.displayName = "CardContent";
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
)
);
CardFooter.displayName = "CardFooter";
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle };

View File

@ -1,25 +1,25 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
@ -32,11 +32,10 @@ const DropdownMenuSubTrigger = React.forwardRef<
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
<ChevronRight className='w-4 h-4 ml-auto' />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
@ -50,9 +49,8 @@ const DropdownMenuSubContent = React.forwardRef<
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
@ -69,13 +67,13 @@ const DropdownMenuContent = React.forwardRef<
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
));
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
@ -87,8 +85,8 @@ const DropdownMenuItem = React.forwardRef<
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
@ -103,16 +101,15 @@ const DropdownMenuCheckboxItem = React.forwardRef<
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
<Check className='w-4 h-4' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
));
DropdownMenuCheckboxItem.displayName = DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
@ -126,33 +123,29 @@ const DropdownMenuRadioItem = React.forwardRef<
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<span className='absolute left-2 flex h-3.5 w-3.5 items-center justify-center'>
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
<Circle className='w-2 h-2 fill-current' />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
));
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName;
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
inset?: boolean;
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
className={cn("px-2 py-1.5 text-sm font-semibold", inset && "pl-8", className)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
));
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName;
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
@ -163,36 +156,30 @@ const DropdownMenuSeparator = React.forwardRef<
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
));
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
<span className={cn("ml-auto text-xs tracking-widest opacity-60", className)} {...props} />
);
};
DropdownMenuShortcut.displayName = "DropdownMenuShortcut";
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
DropdownMenuTrigger,
};

View File

@ -1,9 +1,11 @@
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
"use client";
import { cn } from "@/lib/utils"
import * as TabsPrimitive from "@radix-ui/react-tabs";
import * as React from "react";
const Tabs = TabsPrimitive.Root
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
@ -12,13 +14,13 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
"inline-flex h-9 items-center justify-center rounded-lg bg-accent p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
));
TabsList.displayName = TabsPrimitive.List.displayName;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
@ -27,13 +29,13 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
@ -47,7 +49,7 @@ const TabsContent = React.forwardRef<
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
export { Tabs, TabsList, TabsTrigger, TabsContent }
export { Tabs, TabsContent, TabsList, TabsTrigger };

View File

@ -77,8 +77,6 @@ export abstract class SequelizeRepository<T> implements IRepository<T> {
queryCriteria,
});
console.log(query);
const args = {
...query,
distinct: true,

View File

@ -1,4 +1,11 @@
import { DataTypes, InferAttributes, InferCreationAttributes, Model, Sequelize } from "sequelize";
import {
CreationOptional,
DataTypes,
InferAttributes,
InferCreationAttributes,
Model,
Sequelize,
} from "sequelize";
export type ProfileCreationAttributes = InferCreationAttributes<Profile_Model>;
@ -15,11 +22,11 @@ export class Profile_Model extends Model<
static associate(connection: Sequelize) {}
declare id: string;
declare contact_information: string;
declare default_payment_method: string;
declare default_notes: string;
declare default_legal_terms: string;
declare default_quote_validity: string;
declare contact_information: CreationOptional<string>;
declare default_payment_method: CreationOptional<string>;
declare default_notes: CreationOptional<string>;
declare default_legal_terms: CreationOptional<string>;
declare default_quote_validity: CreationOptional<string>;
}
export default (sequelize: Sequelize) => {

View File

@ -14,8 +14,14 @@ import { QuoteStatus } from "./QuoteStatus";
export interface IQuoteProps {
status: QuoteStatus;
date: UTCDateValue;
reference: string;
language: Language;
customer: string;
currency: Currency;
paymentMethod: string;
notes: string;
validity: string;
items: ICollection<QuoteItem>;
}
@ -24,6 +30,7 @@ export interface IQuote {
status: QuoteStatus;
date: UTCDateValue;
language: Language;
currency: Currency;
items: ICollection<QuoteItem>;

View File

@ -53,13 +53,9 @@ export class QuoteRepository extends SequelizeRepository<Quote> implements IQuot
}
public async findAll(queryCriteria?: IQueryCriteria): Promise<ICollection<any>> {
const { rows, count } = await this._findAll(
"Quote_Model",
queryCriteria
/*{
include: [], // esto es para quitar las asociaciones al hacer la consulta
}*/
);
const { rows, count } = await this._findAll("Quote_Model", queryCriteria, {
include: [], // esto es para quitar las asociaciones al hacer la consulta
});
return this.mapper.mapArrayAndCountToDomain(rows, count);
}

View File

@ -59,8 +59,6 @@ export class ListDealersController extends ExpressController {
try {
const queryCriteria: IQueryCriteria = QueryCriteriaService.parse(queryParams);
console.log(queryCriteria);
const result: ListDealersResult = await this.useCase.execute({
queryCriteria,
});

View File

@ -59,8 +59,6 @@ export class ListQuotesController extends ExpressController {
try {
const queryCriteria: IQueryCriteria = QueryCriteriaService.parse(queryParams);
console.log(queryCriteria);
const result: ListQuotesResult = await this.useCase.execute({
queryCriteria,
});

View File

@ -1,23 +1,17 @@
import { ListQuotesUseCase } from "@/contexts/sales/application";
import { registerQuoteRepository } from "@/contexts/sales/infrastructure/Quote.repository";
import Express from "express";
import { ISalesContext } from "../../../../Sales.context";
import { ListQuotesController } from "./ListQuotes.controller";
import { ListQuotesPresenter } from "./presenter";
export const listQuotesController = (
req: Express.Request,
res: Express.Response,
next: Express.NextFunction
) => {
const context: ISalesContext = res.locals.context;
export const listQuotesController = (context: ISalesContext) => {
registerQuoteRepository(context);
return new ListQuotesController(
{
useCase: new ListQuotesUseCase(context),
presenter: ListQuotesPresenter,
},
context
).execute(req, res, next);
);
};

View File

@ -1,14 +1,22 @@
import { ICollection, IUpdateQuote_Response_DTO } from "@shared/contexts";
import { ICollection, IListQuotes_Response_DTO, IListResponse_DTO } from "@shared/contexts";
import { Quote, QuoteItem } from "../../../../../../domain";
import { ISalesContext } from "../../../../../Sales.context";
export interface IListQuotesPresenter {
map: (quote: Quote, context: ISalesContext) => IUpdateQuote_Response_DTO;
map: (quote: Quote, context: ISalesContext) => IListQuotes_Response_DTO;
mapArray: (
articles: ICollection<Quote>,
context: ISalesContext,
params: {
page: number;
limit: number;
}
) => IListResponse_DTO<IListQuotes_Response_DTO>;
}
export const ListQuotesPresenter: IListQuotesPresenter = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
map: (quote: Quote, context: ISalesContext): IUpdateQuote_Response_DTO => {
map: (quote: Quote, context: ISalesContext): IListQuotes_Response_DTO => {
return {
id: quote.id.toString(),
status: quote.status.toString(),
@ -25,9 +33,33 @@ export const ListQuotesPresenter: IListQuotesPresenter = {
precision: 2,
currency: "EUR",
},
items: quoteItemPresenter(quote.items, context),
//items: quoteItemPresenter(quote.items, context),
};
},
mapArray: (
quotes: ICollection<Quote>,
context: ISalesContext,
params: {
page: number;
limit: number;
}
): IListResponse_DTO<IListQuotes_Response_DTO> => {
const { page, limit } = params;
const totalCount = quotes.totalCount ?? 0;
const items = quotes.items.map((quote: Quote) => ListQuotesPresenter.map(quote, context));
const result = {
page,
per_page: limit,
total_pages: Math.ceil(totalCount / limit),
total_items: totalCount,
items,
};
return result;
},
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars

View File

@ -5,6 +5,7 @@ import {
InferCreationAttributes,
Model,
NonAttribute,
Op,
Sequelize,
} from "sequelize";
import { Dealer_Model } from "./dealer.model";
@ -36,12 +37,19 @@ export class Quote_Model extends Model<
}
declare id: string;
declare status: string;
declare status: CreationOptional<string>;
declare date: CreationOptional<string>;
declare language_code: string;
declare currency_code: string;
declare subtotal: number;
declare total: number;
declare reference: CreationOptional<string>;
declare lang_code: CreationOptional<string>;
declare customer_information: CreationOptional<string>;
declare currency_code: CreationOptional<string>;
declare payment_method: CreationOptional<string>;
declare notes: CreationOptional<string>;
declare validity: CreationOptional<string>;
declare subtotal: CreationOptional<number>;
declare discount: CreationOptional<number>;
declare total: CreationOptional<number>;
declare items?: NonAttribute<QuoteItem_Model[]>;
declare dealer?: NonAttribute<Dealer_Model>;
@ -65,14 +73,36 @@ export default (sequelize: Sequelize) => {
allowNull: false,
},
language_code: {
reference: {
type: new DataTypes.STRING(),
},
lang_code: {
type: DataTypes.STRING(2),
allowNull: false,
defaultValue: "es",
},
currency_code: {
type: new DataTypes.STRING(),
type: new DataTypes.STRING(3),
allowNull: false,
defaultValue: "EUR",
},
customer_information: {
type: DataTypes.TEXT,
},
payment_method: {
type: DataTypes.TEXT,
},
notes: {
type: DataTypes.TEXT,
},
validity: {
type: DataTypes.TEXT,
},
subtotal: {
@ -80,6 +110,11 @@ export default (sequelize: Sequelize) => {
allowNull: true,
},
discount: {
type: new DataTypes.BIGINT(),
allowNull: true,
},
total: {
type: new DataTypes.BIGINT(),
allowNull: true,
@ -99,8 +134,28 @@ export default (sequelize: Sequelize) => {
indexes: [
{ name: "status_idx", fields: ["status"] },
{ name: "reference_idx", fields: ["reference"] },
{ name: "deleted_at_idx", fields: ["deleted_at"] },
],
whereMergeStrategy: "and", // <- cómo tratar el merge de un scope
scopes: {
quickSearch(value) {
return {
where: {
[Op.or]: {
reference: {
[Op.like]: `%${value}%`,
},
customer_information: {
[Op.like]: `%${value}%`,
},
},
},
};
},
},
}
);

View File

@ -3,10 +3,7 @@ import Joi from "joi";
import { QueryCriteriaService } from "@/contexts/common/application/services";
import { IServerError } from "@/contexts/common/domain/errors";
import { ExpressController } from "@/contexts/common/infrastructure/express";
import {
ListUsersResult,
ListUsersUseCase,
} from "@/contexts/users/application";
import { ListUsersResult, ListUsersUseCase } from "@/contexts/users/application";
import { User } from "@/contexts/users/domain";
import {
ICollection,
@ -29,7 +26,7 @@ export class ListUsersController extends ExpressController {
useCase: ListUsersUseCase;
presenter: IListUsersPresenter;
},
context: IUserContext,
context: IUserContext
) {
super();
@ -60,10 +57,7 @@ export class ListUsersController extends ExpressController {
const queryParams = queryOrError.object;
try {
const queryCriteria: IQueryCriteria =
QueryCriteriaService.parse(queryParams);
console.log(queryCriteria);
const queryCriteria: IQueryCriteria = QueryCriteriaService.parse(queryParams);
const result: ListUsersResult = await this.useCase.execute({
queryCriteria,
@ -79,7 +73,7 @@ export class ListUsersController extends ExpressController {
this.presenter.mapArray(users, this.context, {
page: queryCriteria.pagination.offset,
limit: queryCriteria.pagination.limit,
}),
})
);
} catch (e: unknown) {
return this.fail(e as IServerError);

View File

@ -1,19 +1,20 @@
import { checkisAdmin } from "@/contexts/auth";
import { checkUser } from "@/contexts/auth";
import { listQuotesController } from "@/contexts/sales/infrastructure/express/controllers";
import Express from "express";
export const quoteRoutes: Express.Router = Express.Router({ mergeParams: true });
/*quoteRoutes.get("/", isAdmin, listQuotesController);
quoteRoutes.get("/:quoteId", isUser, getQuoteMiddleware, getQuoteController);
quoteRoutes.get(
"/",
checkUser,
(req: Express.Request, res: Express.Response, next: Express.NextFunction) =>
listQuotesController(res.locals["context"]).execute(req, res, next)
);
/*quoteRoutes.get("/:quoteId", isUser, getQuoteMiddleware, getQuoteController);
quoteRoutes.post("/", isAdmin, createQuoteController);
quoteRoutes.put("/:quoteId", isAdmin, updateQuoteController);
quoteRoutes.delete("/:quoteId", isAdmin, deleteQuoteController);*/
quoteRoutes.get("/", checkisAdmin, (req, res) => {
console.log(req.params);
res.status(200).json();
});
export const QuoteRouter = (appRouter: Express.Router) => {
appRouter.use("/quotes", quoteRoutes);
};

View File

@ -3,10 +3,11 @@ import { IMoney_Response_DTO } from "../../../../common";
export interface IListArticles_Response_DTO {
id: string;
catalog_name: string;
//id_article: string;
//reference: string;
id_article: string;
reference: string;
//family: string;
//subfamily: string;
description: string;
points: number;
retail_price: IMoney_Response_DTO;

View File

@ -1,9 +1,9 @@
import Joi from "joi";
import { Currencies } from "../../../../../utilities/currencies";
import { RuleValidator } from "../../RuleValidator";
import { DomainError, handleDomainError } from "../../errors";
import { INullableValueObjectOptions, NullableValueObject } from "../NullableValueObject";
import { Result } from "../Result";
import { Currencies } from "./currencies";
export interface ICurrency {
symbol: string;

View File

@ -4,9 +4,9 @@ import { Result } from "../Result";
import Joi from "joi";
import { UndefinedOr } from "../../../../../utilities";
import { LANGUAGES_LIST } from "../../../../../utilities/languages_data";
import { DomainError, handleDomainError } from "../../errors";
import { INullableValueObjectOptions, NullableValueObject } from "../NullableValueObject";
import { LANGUAGES_LIST } from "./languages_data";
export interface ILanguage {
code: string;
@ -39,7 +39,7 @@ export class Language extends NullableValueObject<ILanguage> {
public static createFromCode(languageCode: string, options: ILanguageOptions = {}) {
const _options = {
...options,
label: options.label ? options.label : "language_code",
label: options.label ? options.label : "lang_code",
};
const validationResult = Language.validate(languageCode, _options);

View File

@ -5,8 +5,13 @@ export interface ICreateQuote_Request_DTO {
id: string;
status: string;
date: string;
language_code: string;
reference: string;
customer_information: string;
lang_code: string;
currency_code: string;
payment_method: string;
notes: string;
validity: string;
items: ICreateQuoteItem_Request_DTO[];
}
@ -22,8 +27,14 @@ export function ensureCreateQuote_Request_DTOIsValid(quoteDTO: ICreateQuote_Requ
const schema = Joi.object({
id: Joi.string(),
date: Joi.string(),
language: Joi.string(),
currency: Joi.string(),
reference: Joi.string(),
lang_code: Joi.string(),
customer_information: Joi.string(),
currency_code: Joi.string(),
payment_method: Joi.string(),
notes: Joi.string(),
validity: Joi.string(),
items: Joi.array().items(
Joi.object({
description: Joi.string(),

View File

@ -1,11 +1,16 @@
import { IMoney_Response_DTO } from "shared/lib/contexts/common";
import { IMoney_Response_DTO } from "../../../../../common";
export interface IGetQuote_Response_DTO {
id: string;
status: string;
date: string;
language_code: string;
reference: string;
customer_information: string;
lang_code: string;
currency_code: string;
payment_method: string;
notes: string;
validity: string;
subtotal: IMoney_Response_DTO;
total: IMoney_Response_DTO;