feature/IO-3545-Production-Board-List-DND - Checkpoint

This commit is contained in:
Dave
2026-02-03 13:21:32 -05:00
parent db1b701a96
commit 22aae0a7f1
4 changed files with 308 additions and 108 deletions

106
client/package-lock.json generated
View File

@@ -12,6 +12,10 @@
"@amplitude/analytics-browser": "^2.34.0", "@amplitude/analytics-browser": "^2.34.0",
"@ant-design/pro-layout": "^7.22.6", "@ant-design/pro-layout": "^7.22.6",
"@apollo/client": "^4.1.3", "@apollo/client": "^4.1.3",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/is-prop-valid": "^1.4.0", "@emotion/is-prop-valid": "^1.4.0",
"@fingerprintjs/fingerprintjs": "^5.0.1", "@fingerprintjs/fingerprintjs": "^5.0.1",
"@firebase/analytics": "^0.10.19", "@firebase/analytics": "^0.10.19",
@@ -60,7 +64,6 @@
"react-color": "^2.19.3", "react-color": "^2.19.3",
"react-cookie": "^8.0.1", "react-cookie": "^8.0.1",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-drag-listview": "^2.0.0",
"react-grid-gallery": "^1.0.1", "react-grid-gallery": "^1.0.1",
"react-grid-layout": "^2.2.2", "react-grid-layout": "^2.2.2",
"react-i18next": "^16.5.4", "react-i18next": "^16.5.4",
@@ -2494,6 +2497,73 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/modifiers": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-9.0.0.tgz",
"integrity": "sha512-ybiLc66qRGuZoC20wdSSG6pDXFikui/dCNGthxv4Ndy8ylErY0N3KVxY2bgo7AWwIbxDmXDg3ylAFmnrjcbVvw==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dotenvx/dotenvx": { "node_modules/@dotenvx/dotenvx": {
"version": "1.52.0", "version": "1.52.0",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.52.0.tgz", "resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.52.0.tgz",
@@ -8396,16 +8466,6 @@
"@babel/types": "^7.26.0" "@babel/types": "^7.26.0"
} }
}, },
"node_modules/babel-runtime": {
"version": "6.26.0",
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
"integrity": "sha512-ITKNuq2wKlW1fJg9sSW52eepoYgZBggvOAHC0u/CYu/qxQ9EVzThCgR69BnSXLHjy2f7SY5zaQ4yt7H9ZVxY2g==",
"license": "MIT",
"dependencies": {
"core-js": "^2.4.0",
"regenerator-runtime": "^0.11.0"
}
},
"node_modules/bail": { "node_modules/bail": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
@@ -9192,14 +9252,6 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/core-js": {
"version": "2.6.12",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.12.tgz",
"integrity": "sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==",
"deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.",
"hasInstallScript": true,
"license": "MIT"
},
"node_modules/core-js-compat": { "node_modules/core-js-compat": {
"version": "3.47.0", "version": "3.47.0",
"resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.47.0.tgz", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.47.0.tgz",
@@ -15376,16 +15428,6 @@
"react": "^19.2.4" "react": "^19.2.4"
} }
}, },
"node_modules/react-drag-listview": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/react-drag-listview/-/react-drag-listview-2.0.0.tgz",
"integrity": "sha512-7Apx/1Xt4qu+JHHP0rH6aLgZgS7c2MX8ocHVGCi03KfeIWEu0t14MhT3boQKM33l5eJrE/IWfExFTvoYq22fsg==",
"license": "MIT",
"dependencies": {
"babel-runtime": "^6.26.0",
"prop-types": "^15.5.8"
}
},
"node_modules/react-draggable": { "node_modules/react-draggable": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz", "resolved": "https://registry.npmjs.org/react-draggable/-/react-draggable-4.5.0.tgz",
@@ -15972,12 +16014,6 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/regenerator-runtime": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
"integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==",
"license": "MIT"
},
"node_modules/regexp.prototype.flags": { "node_modules/regexp.prototype.flags": {
"version": "1.5.4", "version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",

View File

@@ -11,6 +11,10 @@
"@amplitude/analytics-browser": "^2.34.0", "@amplitude/analytics-browser": "^2.34.0",
"@ant-design/pro-layout": "^7.22.6", "@ant-design/pro-layout": "^7.22.6",
"@apollo/client": "^4.1.3", "@apollo/client": "^4.1.3",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/is-prop-valid": "^1.4.0", "@emotion/is-prop-valid": "^1.4.0",
"@fingerprintjs/fingerprintjs": "^5.0.1", "@fingerprintjs/fingerprintjs": "^5.0.1",
"@firebase/analytics": "^0.10.19", "@firebase/analytics": "^0.10.19",
@@ -59,7 +63,6 @@
"react-color": "^2.19.3", "react-color": "^2.19.3",
"react-cookie": "^8.0.1", "react-cookie": "^8.0.1",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-drag-listview": "^2.0.0",
"react-grid-gallery": "^1.0.1", "react-grid-gallery": "^1.0.1",
"react-grid-layout": "^2.2.2", "react-grid-layout": "^2.2.2",
"react-i18next": "^16.5.4", "react-i18next": "^16.5.4",

View File

@@ -1,15 +1,19 @@
import { SyncOutlined } from "@ant-design/icons"; import { HolderOutlined, SyncOutlined } from "@ant-design/icons";
import { PageHeader } from "@ant-design/pro-layout"; import { PageHeader } from "@ant-design/pro-layout";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { Button, Dropdown, Input, Space, Statistic, Table } from "antd"; import { Button, Dropdown, Input, Space, Statistic, Table } from "antd";
import _ from "lodash"; import _ from "lodash";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import ReactDragListView from "react-drag-listview"; import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors, closestCenter } from "@dnd-kit/core";
import { arrayMove, horizontalListSortingStrategy, SortableContext, useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectTechnician } from "../../redux/tech/tech.selectors"; import { selectTechnician } from "../../redux/tech/tech.selectors";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors"; import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { selectDarkMode } from "../../redux/application/application.selectors.js";
import Prompt from "../../utils/prompt.js"; import Prompt from "../../utils/prompt.js";
import AlertComponent from "../alert/alert.component.jsx"; import AlertComponent from "../alert/alert.component.jsx";
import ProductionListColumnsAdd from "../production-list-columns/production-list-columns.add.component"; import ProductionListColumnsAdd from "../production-list-columns/production-list-columns.add.component";
@@ -23,12 +27,108 @@ import { logImEXEvent } from "../../firebase/firebase.utils.js";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
technician: selectTechnician, technician: selectTechnician,
currentUser: selectCurrentUser currentUser: selectCurrentUser,
isDarkMode: selectDarkMode
}); });
export function ProductionListTable({ loading, data, refetch, bodyshop, technician, currentUser }) { // Draggable header cell component - combines drag and resize
function DraggableHeaderCell(props) {
const { children, columnKey, onResize, width, ...restProps } = props;
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: columnKey,
disabled: !columnKey
});
const style = {
...restProps.style,
transform: CSS.Transform.toString(transform),
transition,
cursor: "move",
opacity: isDragging ? 0.3 : 1
};
// If no columnKey, render as regular header
if (!columnKey) {
return <ResizeableTitle {...props} />;
}
// Combine drag functionality with resize
return (
<ResizeableTitle
{...restProps}
ref={setNodeRef}
style={style}
onResize={onResize}
width={width}
dragAttributes={attributes}
dragListeners={listeners}
>
{children}
</ResizeableTitle>
);
}
// Draggable column title component
// function DraggableColumnTitle({ col, onRemove, t }) {
// const { attributes, listeners, setNodeRef, isDragging } = useSortable({
// id: col.key
// });
//
// const menu = {
// onClick: onRemove,
// items: [
// {
// key: col.key,
// label: t("production.actions.removecolumn")
// }
// ]
// };
//
// const style = {
// display: "flex",
// alignItems: "center",
// width: "100%",
// opacity: isDragging ? 0.4 : 1
// };
//
// return (
// <div ref={setNodeRef} style={style}>
// <span
// {...attributes}
// {...listeners}
// style={{
// cursor: "grab",
// display: "inline-flex",
// alignItems: "center",
// padding: "0 4px",
// marginRight: "4px",
// color: "#999",
// fontSize: "14px",
// flexShrink: 0
// }}
// className="drag-handle"
// title="Drag to reorder column"
// >
// <HolderOutlined />
// </span>
// <Dropdown className="prod-header-dropdown" menu={menu} trigger={["contextMenu"]}>
// <span style={{ flex: 1 }}>{col.title}</span>
// </Dropdown>
// </div>
// );
// }
export function ProductionListTable({ loading, data, refetch, bodyshop, technician, currentUser, isDarkMode }) {
const [searchText, setSearchText] = useState(""); const [searchText, setSearchText] = useState("");
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [activeId, setActiveId] = useState(null);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 1
}
})
);
const { const {
treatments: { Production_List_Status_Colors, Enhanced_Payroll } treatments: { Production_List_Status_Colors, Enhanced_Payroll }
} = useTreatmentsWithConfig({ } = useTreatmentsWithConfig({
@@ -118,17 +218,30 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
logImEXEvent("production_list_sort_filter", { pagination, filters, sorter }); logImEXEvent("production_list_sort_filter", { pagination, filters, sorter });
}; };
const onDragEnd = (fromIndex, toIndex) => { const onDragStart = ({ active }) => {
if (fromIndex === toIndex) return; setActiveId(active.id);
const columnsCopy = [...columns]; };
const [movedItem] = columnsCopy.splice(fromIndex, 1);
columnsCopy.splice(toIndex, 0, movedItem); const onDragEnd = ({ active, over }) => {
if (!_.isEqual(columnsCopy, columns)) { setActiveId(null);
setColumns(columnsCopy); if (!over || active.id === over.id) return;
setHasUnsavedChanges(true);
const oldIndex = columns.findIndex((col) => col.key === active.id);
const newIndex = columns.findIndex((col) => col.key === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
const newColumns = arrayMove(columns, oldIndex, newIndex);
if (!_.isEqual(newColumns, columns)) {
setColumns(newColumns);
setHasUnsavedChanges(true);
}
} }
}; };
const onDragCancel = () => {
setActiveId(null);
};
const removeColumn = (e) => { const removeColumn = (e) => {
const { key } = e; const { key } = e;
const newColumns = columns.filter((i) => i.key !== key); const newColumns = columns.filter((i) => i.key !== key);
@@ -172,10 +285,14 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
} }
] ]
}; };
return ( return (
<Dropdown className="prod-header-dropdown" menu={menu} trigger={["contextMenu"]}> <div style={{ display: "flex", alignItems: "center", width: "100%" }}>
<span>{col.title}</span> <HolderOutlined style={{ marginRight: "8px", color: "#999", cursor: "move" }} />
</Dropdown> <Dropdown className="prod-header-dropdown" menu={menu} trigger={["contextMenu"]}>
<span style={{ flex: 1 }}>{col.title}</span>
</Dropdown>
</div>
); );
}; };
@@ -286,60 +403,99 @@ export function ProductionListTable({ loading, data, refetch, bodyshop, technici
} }
/> />
<ProductionListDetail jobs={dataSource} /> <ProductionListDetail jobs={dataSource} />
<ReactDragListView.DragColumn onDragEnd={onDragEnd} nodeSelector="th" handleSelector=".prod-header-dropdown"> <DndContext
<Table sensors={sensors}
sticky onDragStart={onDragStart}
pagination={false} onDragEnd={onDragEnd}
size="small" onDragCancel={onDragCancel}
{...(Production_List_Status_Colors.treatment === "on" && { collisionDetection={closestCenter}
onRow: (record, index) => { modifiers={[restrictToHorizontalAxis]}
if (!bodyshop.md_ro_statuses.production_colors) return null; >
const color = bodyshop.md_ro_statuses.production_colors.find((x) => x.status === record.status); <SortableContext items={columns.map((col) => col.key)} strategy={horizontalListSortingStrategy}>
if (!color) { <Table
if (index % 2 === 0) sticky
return { pagination={false}
style: { size="small"
backgroundColor: "var(--table-row-even-bg)" {...(Production_List_Status_Colors.treatment === "on" && {
} onRow: (record, index) => {
}; if (!bodyshop.md_ro_statuses.production_colors) return null;
return null; const color = bodyshop.md_ro_statuses.production_colors.find((x) => x.status === record.status);
} if (!color) {
return { if (index % 2 === 0)
className: "rowWithColor", return {
style: { style: {
"--bgColor": color.color backgroundColor: "var(--table-row-even-bg)"
? `rgba(${color.color.r},${color.color.g},${color.color.b},${color.color.a || 1})` }
: "var(--status-row-bg-fallback)" };
return null;
} }
return {
className: "rowWithColor",
style: {
"--bgColor": color.color
? `rgba(${color.color.r},${color.color.g},${color.color.b},${color.color.a || 1})`
: "var(--status-row-bg-fallback)"
}
};
}
})}
components={{
header: {
cell: DraggableHeaderCell
}
}}
columns={columns.map((c, index) => {
return {
...c,
filteredValue: state.filteredInfo[c.key] || null,
sortOrder: state.sortedInfo.columnKey === c.key && state.sortedInfo.order,
title: headerItem(c),
ellipsis: true,
width: c.width ?? 100,
onHeaderCell: (column) => ({
columnKey: column.key,
width: column.width,
onResize: handleResize(index)
})
}; };
} })}
})} rowKey="id"
components={{ loading={loading}
header: { dataSource={dataSource}
cell: ResizeableTitle scroll={{ x: 1000 }}
} onChange={handleTableChange}
}} />
columns={columns.map((c, index) => { </SortableContext>
return { <DragOverlay dropAnimation={null}>
...c, {activeId ? (
filteredValue: state.filteredInfo[c.key] || null, <div
sortOrder: state.sortedInfo.columnKey === c.key && state.sortedInfo.order, style={{
title: headerItem(c), backgroundColor: isDarkMode ? "#141414" : "white",
ellipsis: true, color: isDarkMode ? "white" : "#000",
width: c.width ?? 100, border: `2px solid ${isDarkMode ? "#177ddc" : "#1890ff"}`,
onHeaderCell: (column) => ({ borderRadius: "4px",
width: column.width, padding: "12px 16px",
onResize: handleResize(index) boxShadow: "0 4px 12px rgba(0, 0, 0, 0.25)",
}) cursor: "grabbing",
}; display: "flex",
})} alignItems: "center",
rowKey="id" fontWeight: 500,
loading={loading} minWidth: "120px"
dataSource={dataSource} }}
scroll={{ x: 1000 }} >
onChange={handleTableChange} <HolderOutlined style={{ marginRight: "8px", color: isDarkMode ? "white" : "#000", fontSize: "16px" }} />
/> <span>
</ReactDragListView.DragColumn> {(() => {
const col = columns.find((c) => c.key === activeId);
const title = typeof col?.title === 'string' ? col.title :
(col?.dataIndex || col?.key || "Column");
return title;
})()}
</span>
</div>
) : null}
</DragOverlay>
</DndContext>
</div> </div>
); );
} }

View File

@@ -1,10 +1,11 @@
import { forwardRef } from "react";
import { Resizable } from "react-resizable"; import { Resizable } from "react-resizable";
export default function ResizableComponent(props) { const ResizableComponent = forwardRef((props, ref) => {
const { onResize, width, ...restProps } = props; const { onResize, width, dragAttributes, dragListeners, ...restProps } = props;
if (!width) { if (!width) {
return <th {...restProps} />; return <th ref={ref} {...restProps} {...(dragAttributes || {})} {...(dragListeners || {})} />;
} }
return ( return (
@@ -22,7 +23,11 @@ export default function ResizableComponent(props) {
/> />
} }
> >
<th {...restProps} /> <th ref={ref} {...restProps} {...(dragAttributes || {})} {...(dragListeners || {})} />
</Resizable> </Resizable>
); );
} });
ResizableComponent.displayName = "ResizableComponent";
export default ResizableComponent;