From 2d6f01b63add5bb1d76636bfed4b0d317f7bada8 Mon Sep 17 00:00:00 2001 From: Civolilah Date: Wed, 16 Apr 2025 17:53:36 +0200 Subject: [PATCH 1/5] Products table redesign --- src/components/forms/Combobox.tsx | 6 ++- .../common/components/ProductsTable.tsx | 53 +++++++++++++++---- .../common/hooks/useResolveInputField.tsx | 4 -- 3 files changed, 47 insertions(+), 16 deletions(-) diff --git a/src/components/forms/Combobox.tsx b/src/components/forms/Combobox.tsx index 0eb7cacd5e..4a32079b6d 100644 --- a/src/components/forms/Combobox.tsx +++ b/src/components/forms/Combobox.tsx @@ -342,7 +342,11 @@ export function Combobox({

) : null} -
+
+ + {columns.map((column, index) => ( - + {resolveTranslation(column)} ))} @@ -127,10 +142,35 @@ export function ProductsTable(props: Props) { tabIndex={index + 1} {...provided.draggableProps} > + +
e.currentTarget.focus()} + > + +
+ + {columns.map((column, columnIndex, { length }) => ( {length - 1 !== columnIndex && (
- {columnIndex === 0 ? ( - - ) : null} - {resolveInputField( column, getLineItemIndex(lineItem) diff --git a/src/pages/invoices/common/hooks/useResolveInputField.tsx b/src/pages/invoices/common/hooks/useResolveInputField.tsx index 03d95e3cac..85f162ff4f 100644 --- a/src/pages/invoices/common/hooks/useResolveInputField.tsx +++ b/src/pages/invoices/common/hooks/useResolveInputField.tsx @@ -1,5 +1,3 @@ - - /** * Invoice Ninja (https://invoiceninja.com). * @@ -112,7 +110,6 @@ export function useResolveInputField(props: Props) { return filteredItems.some((lineItem) => isLineItemEmpty(lineItem)); }; - const cleanLineItemsList = useCallback( (lineItems: InvoiceItem[]) => { let typeId = InvoiceItemType.Product; @@ -350,7 +347,6 @@ export function useResolveInputField(props: Props) { onChange={(event: ChangeEvent) => onChange(property, event.target.value, index) } - style={{ marginTop: '4px' }} textareaRows={preferences.auto_expand_product_table_notes ? 1 : 3} /> ); From a2a608db231447beec8648fdf392c28e9972863e Mon Sep 17 00:00:00 2001 From: Civolilah Date: Thu, 17 Apr 2025 18:33:56 +0200 Subject: [PATCH 2/5] Redesigned line item product table --- src/components/forms/InputField.tsx | 1 + .../common/components/ProductsTable.tsx | 122 +++++++++++------- 2 files changed, 74 insertions(+), 49 deletions(-) diff --git a/src/components/forms/InputField.tsx b/src/components/forms/InputField.tsx index ad9e4e59a6..ef84edda64 100644 --- a/src/components/forms/InputField.tsx +++ b/src/components/forms/InputField.tsx @@ -101,6 +101,7 @@ export function InputField(props: Props) { border: props.border !== false, 'border-[#09090B26] focus:border-black': !reactSettings.dark_mode, 'border-[#1f2e41] focus:border-white': reactSettings.dark_mode, + block: props.element === 'textarea', } )} placeholder={props.placeholder || ''} diff --git a/src/pages/invoices/common/components/ProductsTable.tsx b/src/pages/invoices/common/components/ProductsTable.tsx index 14b890bb66..222a624fe7 100644 --- a/src/pages/invoices/common/components/ProductsTable.tsx +++ b/src/pages/invoices/common/components/ProductsTable.tsx @@ -9,7 +9,7 @@ */ import { Table, Tbody, Td, Th, Thead, Tr } from '$app/components/tables'; -import { Plus, Trash2 } from 'react-feather'; +import { Plus } from 'react-feather'; import { useTranslation } from 'react-i18next'; import { isLineItemEmpty, @@ -28,6 +28,8 @@ import classNames from 'classnames'; import { useColorScheme } from '$app/common/colors'; import { useThemeColorScheme } from '$app/pages/settings/user/components/StatusColorTheme'; import { GridDotsVertical } from '$app/components/icons/GridDotsVertical'; +import { CircleXMark } from '$app/components/icons/CircleXMark'; +import styled from 'styled-components'; export type ProductTableResource = Invoice | RecurringInvoice | PurchaseOrder; export type RelationType = 'client_id' | 'vendor_id'; @@ -52,6 +54,14 @@ interface Props { shouldCreateInitialLineItem?: boolean; } +const AddLineItemButton = styled.div` + background-color: ${({ theme }) => theme.backgroundColor}; + + &:hover { + background-color: ${({ theme }) => theme.hoverBackgroundColor}; + } +`; + export function ProductsTable(props: Props) { const [t] = useTranslation(); const colors = useColorScheme(); @@ -124,6 +134,8 @@ export function ProductsTable(props: Props) { {resolveTranslation(column)} ))} + + @@ -172,46 +184,43 @@ export function ProductsTable(props: Props) { })} style={{ borderColor: colors.$20 }} > - {length - 1 !== columnIndex && ( -
- {resolveInputField( - column, - getLineItemIndex(lineItem) - )} -
- )} - - {length - 1 === columnIndex && ( -
- {resolveInputField( - column, - getLineItemIndex(lineItem) - )} - - {resource && ( - - )} -
- )} +
+ {resolveInputField( + column, + getLineItemIndex(lineItem) + )} +
))} + + +
+ {resource && ( + + )} +
+ )} @@ -219,19 +228,34 @@ export function ProductsTable(props: Props) { {provided.placeholder} - - - + From f4796b8d8a03ae893465b6cdd11c2c8573fe5c29 Mon Sep 17 00:00:00 2001 From: Civolilah Date: Thu, 17 Apr 2025 18:45:29 +0200 Subject: [PATCH 3/5] Cleanup --- .../common/hooks/useResolveInputField.tsx | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/pages/invoices/common/hooks/useResolveInputField.tsx b/src/pages/invoices/common/hooks/useResolveInputField.tsx index 85f162ff4f..8930726653 100644 --- a/src/pages/invoices/common/hooks/useResolveInputField.tsx +++ b/src/pages/invoices/common/hooks/useResolveInputField.tsx @@ -381,14 +381,18 @@ export function useResolveInputField(props: Props) { } if ('gross_line_total' === property) { - return formatMoney( - (resource?.line_items[index][property] ?? 0) as number + return ( + + {formatMoney((resource?.line_items[index][property] ?? 0) as number)} + ); } if ('tax_amount' === property) { - return formatMoney( - (resource?.line_items[index][property] ?? 0) as number + return ( + + {formatMoney((resource?.line_items[index][property] ?? 0) as number)} + ); } @@ -397,7 +401,11 @@ export function useResolveInputField(props: Props) { } if (['line_total'].includes(property)) { - return formatMoney(resource?.line_items[index][property] as number); + return ( + + {formatMoney(resource?.line_items[index][property] as number)} + + ); } if (['product1', 'product2', 'product3', 'product4'].includes(property)) { From f941cb51bebfe1189d85d56a86168104fd61836f Mon Sep 17 00:00:00 2001 From: Civolilah Date: Sat, 19 Apr 2025 18:31:43 +0200 Subject: [PATCH 4/5] Adjusted select field customization --- src/components/forms/SelectField.tsx | 120 ++++++++++++++++++--------- 1 file changed, 81 insertions(+), 39 deletions(-) diff --git a/src/components/forms/SelectField.tsx b/src/components/forms/SelectField.tsx index 655db2a704..320fe20170 100644 --- a/src/components/forms/SelectField.tsx +++ b/src/components/forms/SelectField.tsx @@ -15,7 +15,12 @@ import CommonProps from '../../common/interfaces/common-props.interface'; import { useColorScheme } from '$app/common/colors'; import React, { CSSProperties, ReactNode, isValidElement } from 'react'; import { SelectOption } from '../datatables/Actions'; -import Select, { StylesConfig } from 'react-select'; +import Select, { + components, + ControlProps, + DropdownIndicatorProps, + StylesConfig, +} from 'react-select'; import { ChevronDown } from '../icons/ChevronDown'; import { merge } from 'lodash'; @@ -76,8 +81,12 @@ export function SelectField(props: SelectProps) { } ); - const selectedEntry = $entries?.find((entry) => entry.value === value); - const defaultEntry = $entries?.find((entry) => entry.value === defaultValue); + const selectedEntry = $entries?.find((entry) => entry.value === value) as + | SelectOption + | undefined; + const defaultEntry = $entries?.find( + (entry) => entry.value === defaultValue + ) as SelectOption | undefined; const customStyles: StylesConfig = { input: (styles) => { @@ -99,14 +108,26 @@ export function SelectField(props: SelectProps) { zIndex: 50, }); }, - control: (base, { isDisabled }) => { + control: (base, { isDisabled, isFocused }) => { return merge(base, { + minHeight: '2rem', + height: '2.3rem', + paddingLeft: '4px', + paddingRight: '4px', borderRadius: '0.375rem', backgroundColor: colors.$1, color: colors.$3, - borderColor: colors.$5, + borderColor: isFocused ? colors.$3 : colors.$24, cursor: isDisabled ? 'not-allowed' : 'pointer', pointerEvents: isDisabled ? 'auto' : 'unset', + boxShadow: 'none', + outline: 'none', + '&:focus': { + borderColor: colors.$24, + }, + '&:hover': { + borderColor: isFocused ? colors.$3 : colors.$24, + }, ...controlStyle, }); }, @@ -122,6 +143,12 @@ export function SelectField(props: SelectProps) { minHeight: '1.875rem', }); }, + valueContainer: (base) => { + return merge(base, { + paddingLeft: '0.1rem', + }); + }, + ...(props.withoutSeparator && { indicatorSeparator: () => { return { @@ -131,6 +158,48 @@ export function SelectField(props: SelectProps) { }), }; + const CustomControl = ({ + children, + ...props + }: ControlProps) => { + return ( + +
+ {controlIcon} + {children} +
+
+ ); + }; + + const CustomDropdownIndicator = ( + props: DropdownIndicatorProps + ) => { + return ( + +
+ +
+
+ ); + }; + return (
{props.label && ( @@ -174,7 +243,11 @@ export function SelectField(props: SelectProps) { // @ts-ignore options={$entries} defaultValue={defaultEntry} - value={clearAfterSelection ? { label: '', value: '' } : selectedEntry} + value={ + clearAfterSelection + ? ({ label: '', value: '' } as SelectOption) + : selectedEntry + } onChange={(v) => { if (v === null) { return onValueChange?.((blankOptionValue as string) ?? ''); @@ -196,39 +269,8 @@ export function SelectField(props: SelectProps) { blurInputOnSelect data-cy={cypressRef} components={{ - Control: ({ children, innerProps, isFocused }) => ( -
- {controlIcon} - {children} -
- ), - - DropdownIndicator: () => ( -
- -
- ), + Control: CustomControl, + DropdownIndicator: CustomDropdownIndicator, }} /> )} From a9832af7ee31499beb6664580c74bc77041587cd Mon Sep 17 00:00:00 2001 From: Civolilah Date: Wed, 23 Apr 2025 19:22:47 +0200 Subject: [PATCH 5/5] Create new ux for products table columns --- .../common/components/ProductsTable.tsx | 382 +++++++++++------- .../common/helpers/resolve-column-width.ts | 26 +- 2 files changed, 260 insertions(+), 148 deletions(-) diff --git a/src/pages/invoices/common/components/ProductsTable.tsx b/src/pages/invoices/common/components/ProductsTable.tsx index 222a624fe7..288e314a96 100644 --- a/src/pages/invoices/common/components/ProductsTable.tsx +++ b/src/pages/invoices/common/components/ProductsTable.tsx @@ -62,6 +62,29 @@ const AddLineItemButton = styled.div` } `; +// Add a container with horizontal scroll +const TableContainer = styled.div` + overflow-x: auto; + width: 100%; +`; + +// Create styled components for sticky columns +const StickyTh = styled(Th)` + position: sticky !important; + right: ${({ theme }) => theme.right}px; + z-index: 10; + background-color: ${({ theme }) => theme.bgColor} !important; + box-shadow: -2px 0 5px -2px rgba(0, 0, 0, 0.1); +`; + +const StickyTd = styled(Td)` + position: sticky !important; + right: ${({ theme }) => theme.right}px; + z-index: 5; + background-color: ${({ theme }) => theme.bgColor} !important; + box-shadow: -2px 0 5px -2px rgba(0, 0, 0, 0.1); +`; + export function ProductsTable(props: Props) { const [t] = useTranslation(); const colors = useColorScheme(); @@ -97,6 +120,13 @@ export function ProductsTable(props: Props) { return resource.line_items.indexOf(lineItem); }; + // Calculate positions for the sticky columns + // We need the last two columns to be sticky + const deleteColumnWidth = 48; // Width of delete button column (in pixels) + + // We'll make the last column and the second-to-last column sticky + const lastColumnIndex = columns.length - 1; + // This portion of the code pertains to the automatic creation of line items. // Currently, we do not support this functionality, and we will comment it out until we begin providing support for it. @@ -113,155 +143,225 @@ export function ProductsTable(props: Props) { }, [resource.client_id, resource.vendor_id]); */ return ( - - - - - {columns.map((column, index) => ( - - ))} - - - - - - {(provided) => ( - - {items.map((lineItem, index) => ( - +
- {resolveTranslation(column)} -
+ + + + {columns.map((column, index) => { + if (index === lastColumnIndex) { + return ( + - {(provided) => ( - - + ); + } + })} + + + + + + {(provided) => ( + + {items.map((lineItem, index) => ( + + {(provided) => ( + -
e.currentTarget.focus()} +
+
e.currentTarget.focus()} + > + +
+ - {columns.map((column, columnIndex, { length }) => ( - + ); + } + })} + + -
- {resolveInputField( - column, - getLineItemIndex(lineItem) +
+ {resource && ( + )}
- - ))} - -
- - )} - - ))} - - {provided.placeholder} - - - )} - onClick={(event) => { - event.stopPropagation(); - - !isAnyLineItemEmpty() && props.onCreateItemClick(); - }} - theme={{ - backgroundColor: colors.$1, - hoverBackgroundColor: colors.$20, - }} - > -
- -
- - - {props.type === 'product' ? t('add_item') : t('add_line')} - - - - - - )} - - -
+ ); + } else { + return ( + + {resolveTranslation(column)} +
- - - { + // For the last two columns, use sticky cells + if (columnIndex === lastColumnIndex) { + return ( + +
+ {resolveInputField( + column, + getLineItemIndex(lineItem) + )} +
+
+ ); + } else { + return ( +
+
+ {resolveInputField( + column, + getLineItemIndex(lineItem) + )} +
+
-
- {resource && ( - - )} -
-
- +
+ + ))} + + {provided.placeholder} + + + + { + event.stopPropagation(); + + !isAnyLineItemEmpty() && props.onCreateItemClick(); + }} + theme={{ + backgroundColor: colors.$1, + hoverBackgroundColor: colors.$20, + }} + > +
+ +
+ + + {props.type === 'product' + ? t('add_item') + : t('add_line')} + +
+ + + + )} + + + + ); } diff --git a/src/pages/invoices/common/helpers/resolve-column-width.ts b/src/pages/invoices/common/helpers/resolve-column-width.ts index b6e9cddc9d..cbf9cb33ca 100644 --- a/src/pages/invoices/common/helpers/resolve-column-width.ts +++ b/src/pages/invoices/common/helpers/resolve-column-width.ts @@ -14,13 +14,25 @@ export function resolveColumnWidth(column: string) { const property = resolveProperty(column); const mappings: Record = { - product_key: '15%', - notes: '30%', - cost: '10%', - quantity: '10%', - line_total: '5%', - discount: '10%', - tax_rate1: '12%', + product_key: '13rem', + notes: '30rem', + cost: '13rem', + quantity: '13rem', + line_total: '8rem', + discount: '13rem', + tax_rate1: '13rem', + tax_rate2: '13rem', + tax_rate3: '13rem', + tax_amount: '13rem', + gross_line_total: '8rem', + product1: '13rem', + product2: '13rem', + product3: '13rem', + product4: '13rem', + task1: '13rem', + task2: '13rem', + task3: '13rem', + task4: '13rem', }; return mappings[property] || '';