diff --git a/.vscode/launch.json b/.vscode/launch.json index 0b0a325..5f4c45c 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -13,7 +13,10 @@ { "name": "Launch Chrome localhost", "type": "pwa-chrome", - "port": 9222 + "request": "launch", + "reAttach": true, + "url": "http://localhost:5173", + "webRoot": "${workspaceFolder}/client" }, { diff --git a/client/index.html b/client/index.html index a51a4e4..83c10b6 100644 --- a/client/index.html +++ b/client/index.html @@ -1,5 +1,5 @@ - + diff --git a/client/src/Routes.tsx b/client/src/Routes.tsx index 4a4d276..0944408 100644 --- a/client/src/Routes.tsx +++ b/client/src/Routes.tsx @@ -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: ( - + + + ), + children: [ + { + index: true, + element: , + }, + { + path: "add", + element: , + }, + ], }, { 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 ; diff --git a/client/src/app/catalog/components/useCatalogTableColumns.tsx b/client/src/app/catalog/components/useCatalogTableColumns.tsx index d6f9f4f..4c70c6c 100644 --- a/client/src/app/catalog/components/useCatalogTableColumns.tsx +++ b/client/src/app/catalog/components/useCatalogTableColumns.tsx @@ -5,8 +5,8 @@ import { DataTablaRowActionFunction, DataTableRowActions } from "@/components"; import { Badge } from "@/ui"; import { useMemo } from "react"; -export const useCustomerInvoiceDataTableColumns = ( - actions: DataTablaRowActionFunction +export const useCatalogTableColumns = ( + actions: DataTablaRowActionFunction ): ColumnDef[] => { const customerColumns: ColumnDef[] = useMemo( () => [ diff --git a/client/src/app/quotes/QuotesContext.tsx b/client/src/app/quotes/QuotesContext.tsx new file mode 100644 index 0000000..2f4cb16 --- /dev/null +++ b/client/src/app/quotes/QuotesContext.tsx @@ -0,0 +1,21 @@ +import { usePagination } from "@/lib/hooks"; +import { PropsWithChildren, createContext } from "react"; + +export interface IQuotesContextState {} + +export const QuotesContext = createContext(null); + +export const QuotesProvider = ({ children }: PropsWithChildren) => { + const [pagination, setPagination] = usePagination(); + + return ( + + {children} + + ); +}; diff --git a/client/src/app/quotes/components/AddNewRowButton.tsx b/client/src/app/quotes/components/AddNewRowButton.tsx new file mode 100644 index 0000000..a314d35 --- /dev/null +++ b/client/src/app/quotes/components/AddNewRowButton.tsx @@ -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 => ( + +); + +AddNewRowButton.displayName = "AddNewRowButton"; diff --git a/client/src/app/quotes/components/QuotesDataTable.tsx b/client/src/app/quotes/components/QuotesDataTable.tsx new file mode 100644 index 0000000..865d095 --- /dev/null +++ b/client/src/app/quotes/components/QuotesDataTable.tsx @@ -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[]>( + () => [ + { + 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: () =>
{t("catalog.list.columns.points")}
, + cell: ({ renderValue }: { renderValue: () => any }) => ( +
{renderValue()}
+ ), + enableResizing: false, + size: 20, + }, + { + id: "retail_price" as const, + accessorKey: "retail_price", + header: () =>
{t("catalog.list.columns.retail_price")}
, + cell: ({ row }: { row: Row }) => { + const price = MoneyValue.create(row.original.retail_price).object; + return
{price.toFormat()}
; + }, + enableResizing: false, + size: 20, + }, + ], + [] + ); + + const { table } = useDataTable({ + data: data?.items ?? [], + columns: columns, + pageCount: data?.total_pages ?? -1, + }); + + if (isError) { + return ; + } + + if (isPending) { + return ( + + + + + + ); + } + + if (data?.total_items === 0 && !isFiltered) { + return ( + navigate("add", { relative: "path" })} + /> + ); + } + + return ( + <> + + + + + ); +}; diff --git a/client/src/app/quotes/components/SortableDataTable.tsx b/client/src/app/quotes/components/SortableDataTable.tsx new file mode 100644 index 0000000..337e2d1 --- /dev/null +++ b/client/src/app/quotes/components/SortableDataTable.tsx @@ -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 { + 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[]; + data: Record<"id", string>[]; + actions: Omit, "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> = { + 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 ( + 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({}); + const [activeId, setActiveId] = useState(); + + const [columnVisibility, setColumnVisibility] = useState({}); + 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[]) { + if (!activeId) { + return items; + } + + return items.filter((idOrRow) => { + const id = typeof idOrRow === "string" ? idOrRow : idOrRow.id; + return id === activeId || !table.getSelectedRowModel().rowsById[id]; + }); + } + + return ( + + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder ? null : ( + + )} + + ); + })} + + ))} + + + + {filterItems(table.getRowModel().rows).map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + + + + + + + + + +
+ + {createPortal( + + {activeId && ( +
+ {table.getSelectedRowModel().rows.length ? ( + + {table.getSelectedRowModel().rows.length} + + ) : null} +
+ + + {table.getRowModel().rows.map( + (row) => + row.id === activeId && ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ) + )} + +
+
+ + {table.getSelectedRowModel().rows.length > 1 && ( +
+ + + {table.getRowModel().rows.map( + (row) => + row.id === activeId && ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ) + )} + +
+
+ )} + + {table.getSelectedRowModel().rows.length > 2 && ( +
+ + + {table.getRowModel().rows.map( + (row) => + row.id === activeId && ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ) + )} + +
+
+ )} + + {table.getSelectedRowModel().rows.length > 3 && ( +
+ + + {table.getRowModel().rows.map( + (row) => + row.id === activeId && ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ) + )} + +
+
+ )} +
+ )} +
, + document.body + )} +
+ ); +} diff --git a/client/src/app/quotes/components/SortableDataTableToolbar.tsx b/client/src/app/quotes/components/SortableDataTableToolbar.tsx new file mode 100644 index 0000000..5ab72fe --- /dev/null +++ b/client/src/app/quotes/components/SortableDataTableToolbar.tsx @@ -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 }) => { + const selectedRowsCount = table.getSelectedRowModel().rows.length; + + if (selectedRowsCount) { + return ( +
+
+ + + + + + {t("common.duplicate_rows_tooltip")} + + + + + + Elimina las fila(s) seleccionada(s) + + + + + + + Quita la selección + +
+
+ ); + } + + return ( +
+
+ + + + + Añadir fila + + + + + + + + + + + +
+
Snooze until
+
+ + + + +
+
+
+ +
+
+
+ Snooze +
+
+
+ + + + + Reply + + + + + + Reply all + + + + + + Forward + +
+ + + + + + + {table.getAllColumns().map((column) => { + return ( + column.toggleVisibility(!!value)} + > + {column.id} + + ); + })} + + +
+ ); +}; diff --git a/client/src/app/quotes/components/SortableTableRow.tsx b/client/src/app/quotes/components/SortableTableRow.tsx new file mode 100644 index 0000000..ec9e47f --- /dev/null +++ b/client/src/app/quotes/components/SortableTableRow.tsx @@ -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; + listeners: DraggableSyntheticListeners; + ref(node: HTMLElement | null): void; +} +export const SortableTableRowContext = createContext({ + attributes: {}, + listeners: undefined, + ref() {}, +}); + +function animateLayoutChanges(args) { + if (args.isSorting || args.wasDragging) { + return defaultAnimateLayoutChanges(args); + } + + return true; +} + +export function SortableTableRow({ + id, + children, +}: PropsWithChildren) { + 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 ( + + + {children} + + + ); +} diff --git a/client/src/app/quotes/components/editors/QuoteDetailsCardEditor.tsx b/client/src/app/quotes/components/editors/QuoteDetailsCardEditor.tsx new file mode 100644 index 0000000..6e97af0 --- /dev/null +++ b/client/src/app/quotes/components/editors/QuoteDetailsCardEditor.tsx @@ -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: () => ( + + ), + 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 ( + + ); + }, + }, + { + id: "quantity" as const, + accessorKey: "quantity", + header: "quantity", + size: 60, + cell: ({ row: { index }, column: { id } }) => { + return ( + + ); + }, + }, + { + id: "unit_measure" as const, + accessorKey: "unit_measure", + header: "unit_measure", + size: 60, + cell: ({ row: { index }, column: { id } }) => { + return ; + }, + }, + { + id: "unit_price" as const, + accessorKey: "unit_price", + header: "unit_price", + cell: ({ row: { index }, column: { id } }) => { + return ; + }, + } /* + { + 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 ( + + null} size='sm' /> + + + } + > +
+ +
+
+ ); +}; diff --git a/client/src/app/quotes/components/editors/QuoteDocumentsCardEditor.tsx b/client/src/app/quotes/components/editors/QuoteDocumentsCardEditor.tsx new file mode 100644 index 0000000..e448319 --- /dev/null +++ b/client/src/app/quotes/components/editors/QuoteDocumentsCardEditor.tsx @@ -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 ( +
+ + ( +
+ + Images + + + +
+ )} + /> + +
+
+ ); + + return ( +
+ +
+ Product image +
+ + + +
+
+
+
+ ); +}; diff --git a/client/src/app/quotes/components/editors/QuoteGeneralCardEditor.tsx b/client/src/app/quotes/components/editors/QuoteGeneralCardEditor.tsx new file mode 100644 index 0000000..4bbef3a --- /dev/null +++ b/client/src/app/quotes/components/editors/QuoteGeneralCardEditor.tsx @@ -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 ( +
+ +
+ + + +
+
+ + + + + +
+
+ +
+
+ + +
+ + +
+
+
+ ); +}; diff --git a/client/src/app/quotes/components/editors/index.ts b/client/src/app/quotes/components/editors/index.ts new file mode 100644 index 0000000..d8b9177 --- /dev/null +++ b/client/src/app/quotes/components/editors/index.ts @@ -0,0 +1,3 @@ +export * from "./QuoteDetailsCardEditor"; +export * from "./QuoteDocumentsCardEditor"; +export * from "./QuoteGeneralCardEditor"; diff --git a/client/src/app/quotes/components/index.ts b/client/src/app/quotes/components/index.ts new file mode 100644 index 0000000..ccded8d --- /dev/null +++ b/client/src/app/quotes/components/index.ts @@ -0,0 +1 @@ +export * from "./QuotesDataTable"; diff --git a/client/src/app/quotes/components/useQuoteDataTableColumns.tsx b/client/src/app/quotes/components/useQuoteDataTableColumns.tsx new file mode 100644 index 0000000..f6786e7 --- /dev/null +++ b/client/src/app/quotes/components/useQuoteDataTableColumns.tsx @@ -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 +): ColumnDef[] => { + const customerColumns: ColumnDef[] = useMemo( + () => [ + /*{ + id: "complete_name", + header: "Nombre", + accessorFn: (row) => ( +
+
+ + + + {acronym(`${row.first_name} ${row.last_name}`)} + + +
+

{`${row.first_name} ${row.last_name}`}

+

{row.job_title}

+
+
+
+ ), + enableSorting: true, + sortingFn: "alphanumeric", + enableHiding: false, + + cell: ({ renderValue }) => ( + + <>{renderValue()} + + ), + },*/ + { + id: "state", + accessorKey: "state", + header: "Estado", + cell: ({ renderValue }) => ( + + <>{renderValue()} + + ), + }, + { + id: "client", + accessorKey: "client", + header: "Cliente", + enableSorting: false, + sortingFn: "alphanumeric", + }, + { + id: "actions", + header: "Acciones", + cell: ({ row }) => { + return ; + }, + }, + ], + [actions] + ); + return customerColumns; +}; diff --git a/client/src/app/quotes/create.tsx b/client/src/app/quotes/create.tsx new file mode 100644 index 0000000..1ccc405 --- /dev/null +++ b/client/src/app/quotes/create.tsx @@ -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({ + mode: "onBlur", + values: data, + defaultValues: { + date: "", + reference: "", + customer_information: "", + lang_code: "", + currency_code: "", + payment_method: "", + notes: "", + validity: "", + items: [], + }, + }); + + const onSubmit: SubmitHandler = async (data) => { + try { + setLoading(true); + data.currency_code = "EUR"; + data.lang_code = String(userIdentity?.language); + mutate(data); + } finally { + setLoading(false); + } + }; + + return ( +
+ +
+
+ +

+ {t("quotes.create.title")} +

+ + {t("quotes.status.draft")} + +
+ + + {t("quotes.create.buttons.save_quote")} + +
+
+ + + {t("quotes.create.tabs.general")} + {t("quotes.create.tabs.items")} + {t("quotes.create.tabs.documents")} + {t("quotes.create.tabs.history")} + + + + + + + + +
+ + +
+
+
+ + ); +}; diff --git a/client/src/app/quotes/hooks/index.ts b/client/src/app/quotes/hooks/index.ts new file mode 100644 index 0000000..d736367 --- /dev/null +++ b/client/src/app/quotes/hooks/index.ts @@ -0,0 +1,3 @@ +export * from "./useDetailColumns"; +export * from "./useQuotes"; +export * from "./useQuotesList"; diff --git a/client/src/app/quotes/hooks/useDetailColumns.tsx b/client/src/app/quotes/hooks/useDetailColumns.tsx new file mode 100644 index 0000000..3024a83 --- /dev/null +++ b/client/src/app/quotes/hooks/useDetailColumns.tsx @@ -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( + rows: Row[], + currentID: number, + selectedID: number, +): Row[] { + const rangeStart = selectedID > currentID ? currentID : selectedID; + const rangeEnd = rangeStart === currentID ? selectedID : currentID; + return rows.slice(rangeStart, rangeEnd + 1); +}*/ + +export function useDetailColumns( + columns: ColumnDef[], + options: { + enableDragHandleColumn?: boolean; + enableSelectionColumn?: boolean; + enableActionsColumn?: boolean; + rowActionFn?: DataTablaRowActionFunction; + } = {} +): ColumnDef[] { + const { + enableDragHandleColumn = false, + enableSelectionColumn = false, + enableActionsColumn = false, + rowActionFn = undefined, + } = options; + + // const lastSelectedId = ""; + + return useMemo(() => { + if (enableSelectionColumn) { + columns.unshift({ + id: "select", + header: ({ table }) => ( + 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; table: Table }) => ( + { + 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: () => ( + + ), + cell: ({ row }: { row: Row }) => , + size: 16, + enableSorting: false, + enableHiding: false, + }); + } + + if (enableActionsColumn) { + columns.push({ + id: "row_actions", + header: () => ( + + ), + cell: (props) => { + return ; + }, + size: 16, + enableSorting: false, + enableHiding: false, + }); + } + + return columns; + }, []); +} diff --git a/client/src/app/quotes/hooks/useQuotes.tsx b/client/src/app/quotes/hooks/useQuotes.tsx new file mode 100644 index 0000000..fae761f --- /dev/null +++ b/client/src/app/quotes/hooks/useQuotes.tsx @@ -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; +}; + +export const useQuotes = (params?: UseQuotesGetParamsType) => { + const dataSource = useDataSource(); + const keys = useQueryKey(); + + return { + useQuery: useOne({ + queryKey: keys().data().resource("quotes").action("one").id("").params().get(), + queryFn: () => + dataSource.getOne({ + resource: "quotes", + id: "", + }), + ...params, + }), + useMutation: useSave({ + 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, + }); + }, + }), + }; +}; diff --git a/client/src/app/quotes/hooks/useQuotesList.tsx b/client/src/app/quotes/hooks/useQuotesList.tsx new file mode 100644 index 0000000..17513a6 --- /dev/null +++ b/client/src/app/quotes/hooks/useQuotesList.tsx @@ -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; +}; + +export type UseQuotesListResponse = UseListQueryResult< + IListResponse_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, + }); +}; diff --git a/client/src/app/quotes/index.ts b/client/src/app/quotes/index.ts index 491ccf0..d1c12f2 100644 --- a/client/src/app/quotes/index.ts +++ b/client/src/app/quotes/index.ts @@ -1 +1,2 @@ +export * from "./create"; export * from "./list"; diff --git a/client/src/app/quotes/layout.tsx b/client/src/app/quotes/layout.tsx new file mode 100644 index 0000000..99848c6 --- /dev/null +++ b/client/src/app/quotes/layout.tsx @@ -0,0 +1,14 @@ +import { Layout, LayoutContent, LayoutHeader } from "@/components"; +import { PropsWithChildren } from "react"; +import { QuotesProvider } from "./QuotesContext"; + +export const QuotesLayout = ({ children }: PropsWithChildren) => { + return ( + + + + {children} + + + ); +}; diff --git a/client/src/app/quotes/list.tsx b/client/src/app/quotes/list.tsx index 71d2f9b..6166871 100644 --- a/client/src/app/quotes/list.tsx +++ b/client/src/app/quotes/list.tsx @@ -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 ( - - - -
-

Quotes

-
-
-
- ); -}; +export const QuotesList = () => ( + +
+

+ +

+
+ + +
+); diff --git a/client/src/app/quotes/useQuotesContext.tsx b/client/src/app/quotes/useQuotesContext.tsx new file mode 100644 index 0000000..ce9d92e --- /dev/null +++ b/client/src/app/quotes/useQuotesContext.tsx @@ -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; +}; diff --git a/client/src/components/Buttons/CancelButton.tsx b/client/src/components/Buttons/CancelButton.tsx new file mode 100644 index 0000000..3b35171 --- /dev/null +++ b/client/src/components/Buttons/CancelButton.tsx @@ -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 ( + + ); +}; + +CancelButton.displayName = "CancelButton"; diff --git a/client/src/components/Buttons/CustomButton.tsx b/client/src/components/Buttons/CustomButton.tsx new file mode 100644 index 0000000..5d129ce --- /dev/null +++ b/client/src/components/Buttons/CustomButton.tsx @@ -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 { + icon: LucideIcon; // Propiedad para proporcionar el icono personalizado + label?: string; +} + +const CustomButton = React.forwardRef( + ({ className, label, size, icon: Icon, children, ...props }, ref) => ( + + ) +); + +CustomButton.displayName = "CustomButton"; + +export { CustomButton, customButtonVariants }; diff --git a/client/src/components/Buttons/SubmitButton.tsx b/client/src/components/Buttons/SubmitButton.tsx new file mode 100644 index 0000000..224a3df --- /dev/null +++ b/client/src/components/Buttons/SubmitButton.tsx @@ -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) => ( + +); + +SubmitButton.displayName = "SubmitButton"; diff --git a/client/src/components/Buttons/index.ts b/client/src/components/Buttons/index.ts new file mode 100644 index 0000000..0146343 --- /dev/null +++ b/client/src/components/Buttons/index.ts @@ -0,0 +1,2 @@ +export * from "./CancelButton"; +export * from "./SubmitButton"; diff --git a/client/src/components/DataTable/DataTableRowActions.tsx b/client/src/components/DataTable/DataTableRowActions.tsx index 9da8b86..308225b 100644 --- a/client/src/components/DataTable/DataTableRowActions.tsx +++ b/client/src/components/DataTable/DataTableRowActions.tsx @@ -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 = CellContext; +type DataTableRowActionContext = CellContext & { + row: Row; +}; export type DataTablaRowActionFunction = ( - props: DataTableRowActionContext, + props: DataTableRowActionContext ) => DataTableRowActionDefinition[]; export type DataTableRowActionDefinition = { label: string | "-"; shortcut?: string; - onClick?: ( - props: DataTableRowActionContext, - e: React.BaseSyntheticEvent, - ) => void; + onClick?: (props: DataTableRowActionContext, e: React.BaseSyntheticEvent) => void; }; export type DataTableRowActionsProps = { - props: DataTableRowActionContext; actions?: DataTablaRowActionFunction; + row?: DataTableRowActionContext; }; -export function DataTableRowActions({ +export function DataTableRowActions({ actions, ...props }: DataTableRowActionsProps) { @@ -41,18 +41,18 @@ export function DataTableRowActions({ - - Acciones + + {t("common.actions")} {actions && actions(props).map((action, index) => action.label === "-" ? ( @@ -60,14 +60,12 @@ export function DataTableRowActions({ ) : ( - action.onClick ? action.onClick(props, event) : null - } + onClick={(event) => (action.onClick ? action.onClick(props, event) : null)} > {action.label} {action.shortcut} - ), + ) )} diff --git a/client/src/components/EmptyState/SimpleEmptyState.tsx b/client/src/components/EmptyState/SimpleEmptyState.tsx index 8b3b440..5636974 100644 --- a/client/src/components/EmptyState/SimpleEmptyState.tsx +++ b/client/src/components/EmptyState/SimpleEmptyState.tsx @@ -9,29 +9,29 @@ export const SimpleEmptyState = ({ actions = undefined, }) => { return ( -
+
-

{title}

-

{subtitle}

+

{title}

+

{subtitle}

-
+
{actions && <>{actions}} {!actions && ( - diff --git a/client/src/components/Forms/FormDatePickerField.tsx b/client/src/components/Forms/FormDatePickerField.tsx new file mode 100644 index 0000000..a6dd9cc --- /dev/null +++ b/client/src/components/Forms/FormDatePickerField.tsx @@ -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 = FieldPath +> = InputProps & + FormInputProps & + Partial & + UseControllerProps & {}; + +/*const loadDateFnsLocale = async (locale: Locale) => { + return await import(`date-fns/locale/${locale.code}/index.js`); +};*/ + +export const FormDatePickerField = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & 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 ( + ( + + {label && } + + + + + + + + + { + 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 + /> + + + {description && {description}} + + + )} + /> + ); +}); diff --git a/client/src/components/Forms/FormLabel.tsx b/client/src/components/Forms/FormLabel.tsx index c637db1..fc5ae52 100644 --- a/client/src/components/Forms/FormLabel.tsx +++ b/client/src/components/Forms/FormLabel.tsx @@ -15,10 +15,11 @@ export const FormLabel = React.forwardRef< Pick >(({ label, hint, required, ...props }, ref) => { const _hint = hint ? hint : required ? "obligatorio" : undefined; + const _hintClassName = required ? "text-destructive" : ""; return ( - - {label} - {_hint && {_hint}} + + {label} + {_hint && {_hint}} ); }); diff --git a/client/src/components/Forms/FormTextAreaField.tsx b/client/src/components/Forms/FormTextAreaField.tsx index 48085d5..e2532b2 100644 --- a/client/src/components/Forms/FormTextAreaField.tsx +++ b/client/src/components/Forms/FormTextAreaField.tsx @@ -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 & FormTextAreaFieldProps ->(({ label, hint, placeholder, description, autoSize, className, ...props }, ref) => { - return ( - ( - - {label && } - - {autoSize ? ( - - ) : ( -