Merge branch 'master-AIO' into feature/IO-3515-ocr-bill-posting

This commit is contained in:
Patrick Fic
2026-02-18 10:08:25 -08:00
208 changed files with 8558 additions and 2872 deletions

View File

@@ -13,4 +13,5 @@
.env.development.local
.env.test.local
.env.production.local
.env.localstack.docker
bodyshop_translations.babel

View File

@@ -0,0 +1,593 @@
# React 19 & Ant Design 6 Upgrade - Deprecation Fixes Report
## Overview
This document outlines all deprecations fixed during the upgrade from React 18 to React 19 and Ant Design 5 to Ant Design 6 in the branch `feature/IO-3499-React-19` compared to `origin/master-AIO`.
---
## 1. Core Dependency Updates
### React & React DOM
- **Upgraded from:** React ^18.3.1 → React ^19.2.4
- **Upgraded from:** React DOM ^18.3.1 → React DOM ^19.2.4
- **Impact:** Enabled React 19 compiler optimizations and new concurrent features
### Ant Design
- **Upgraded from:** Ant Design ^5.28.1 → ^6.2.2
- **Upgraded from:** @ant-design/icons ^5.6.1 → ^6.1.0
- **Impact:** Access to Ant Design 6 improvements and API changes
### Apollo GraphQL
- **@apollo/client:** ^3.13.9 → ^4.1.3
- **apollo-link-logger:** ^2.0.1 → ^3.0.0
- **graphql-ws:** ^6.0.7 (added for WebSocket subscriptions)
- **Impact:** Major version upgrade with breaking changes to import paths and API
### React Ecosystem Libraries
- **react-router-dom:** ^6.30.0 → ^7.13.0
- **react-i18next:** ^15.7.3 → ^16.5.4
- **react-grid-layout:** 1.3.4 → ^2.2.2
- **@testing-library/react:** ^16.3.1 → ^16.3.2
- **styled-components:** ^6.2.0 → ^6.3.8
### Build Tools
- **Vite:** ^7.3.1 (maintained, peer dependencies updated)
- **vite-plugin-babel:** ^1.3.2 → ^1.4.1
- **vite-plugin-node-polyfills:** ^0.24.0 → ^0.25.0
- **vitest:** ^3.2.4 → ^4.0.18
### Monitoring & Analytics
- **@sentry/react:** ^9.43.0 → ^10.38.0
- **@sentry/cli:** ^2.58.2 → ^3.1.0
- **@sentry/vite-plugin:** ^4.6.1 → ^4.8.0
- **logrocket:** ^9.0.2 → ^12.0.0
- **posthog-js:** ^1.315.1 → ^1.336.4
- **@amplitude/analytics-browser:** ^2.33.1 → ^2.34.0
### Other Key Dependencies
- **axios:** ^1.13.2 → ^1.13.4
- **env-cmd:** ^10.1.0 → ^11.0.0
- **i18next:** ^25.7.4 → ^25.8.0
- **libphonenumber-js:** ^1.12.33 → ^1.12.36
- **lightningcss:** ^1.30.2 → ^1.31.1
- **@fingerprintjs/fingerprintjs:** ^4.6.1 → ^5.0.1
- **@firebase/app:** ^0.14.6 → ^0.14.7
- **@firebase/firestore:** ^4.9.3 → ^4.10.0
### Infrastructure
- **Node.js:** 22.x → 24.x (Dockerfile updated)
---
## 2. React 19 Compiler Optimizations
### Manual Memoization Removed
React 19's new compiler automatically optimizes components, making manual memoization unnecessary and potentially counterproductive.
#### 2.1 `useMemo` Hook Removals
**Example - Job Watchers:**
```javascript
// BEFORE
const jobWatchers = useMemo(() => (watcherData?.job_watchers ? [...watcherData.job_watchers] : []), [watcherData]);
// AFTER
// Do NOT clone arrays; keep referential stability for React Compiler and to reduce rerenders.
const jobWatchers = watcherData?.job_watchers ?? EMPTY_ARRAY;
```
**Benefits:**
- Eliminates unnecessary array cloning
- Maintains referential stability for React Compiler
- Reduces re-renders
- Cleaner, more readable code
**Files Affected:**
- Multiple kanban components
- Production board components
- Job management components
#### 2.2 `useCallback` Hook Removals
**Example - Card Lookup Function:**
```javascript
// BEFORE
const getCardByID = useCallback((data, cardId) => {
for (const lane of data.lanes) {
for (const card of lane.cards) {
// ... logic
}
}
}, [/* dependencies */]);
// AFTER
const getCardByID = (data, cardId) => {
for (const lane of data.lanes) {
for (const card of lane.cards) {
// ... logic
}
}
};
```
**Benefits:**
- React 19 compiler automatically optimizes function references
- Reduced complexity in component code
- No need to manage dependency arrays
**Files Affected:**
- production-board-kanban.component.jsx
- production-board-kanban.container.jsx
- Multiple board controller components
#### 2.3 `React.memo()` Wrapper Removals
**Example - EllipsesToolTip Component:**
```javascript
// BEFORE
const EllipsesToolTip = memo(({ title, children, kiosk }) => {
if (kiosk || !title) {
return <div className="ellipses no-select">{children}</div>;
}
return (
<Tooltip title={title}>
<div className="ellipses no-select">{children}</div>
</Tooltip>
);
});
EllipsesToolTip.displayName = "EllipsesToolTip";
// AFTER
function EllipsesToolTip({ title, children, kiosk }) {
if (kiosk || !title) {
return <div className="ellipses no-select">{children}</div>;
}
return (
<Tooltip title={title}>
<div className="ellipses no-select">{children}</div>
</Tooltip>
);
}
```
**Benefits:**
- Compiler handles optimization automatically
- No need for manual displayName assignment
- Standard function syntax is cleaner
**Files Affected:**
- production-board-kanban-card.component.jsx
- EllipsesToolTip components
- Various utility components
---
## 3. State Management Optimizations
### Deep Cloning Elimination
React 19's compiler efficiently handles change detection, eliminating the need for manual deep cloning.
**Example - Board Lanes State Update:**
```javascript
// BEFORE
setBoardLanes((prevBoardLanes) => {
const deepClonedData = cloneDeep(newBoardData);
if (!isEqual(prevBoardLanes, deepClonedData)) {
return deepClonedData;
}
return prevBoardLanes;
});
// AFTER
setBoardLanes(newBoardData);
```
**Benefits:**
- Removed lodash dependencies (`cloneDeep`, `isEqual`) from components
- Reduced memory overhead
- Faster state updates
- React 19's compiler handles change detection efficiently
---
## 4. Import Cleanup
### React Import Simplifications
**Example - Removed Unnecessary Hook Imports:**
```javascript
// BEFORE
import { useMemo, useState, useEffect, useCallback } from "react";
// AFTER
import { useState, useEffect } from "react";
```
Multiple files had their React imports streamlined by removing `useMemo`, `useCallback`, and `memo` imports that are no longer needed.
---
## 5. Apollo Client 4.x Migration
### Import Path Changes
Apollo Client 4.x requires React-specific imports to come from `@apollo/client/react` instead of the main package.
**Example - Hook Imports:**
```javascript
// BEFORE (Apollo Client 3.x)
import { useQuery, useMutation, useLazyQuery } from "@apollo/client";
import { ApolloProvider } from "@apollo/client";
import { useApolloClient } from "@apollo/client";
// AFTER (Apollo Client 4.x)
import { useQuery, useMutation, useLazyQuery } from "@apollo/client/react";
import { ApolloProvider } from "@apollo/client/react";
import { useApolloClient } from "@apollo/client/react";
```
**Benefits:**
- Better tree-shaking for non-React Apollo Client usage
- Clearer separation between core and React-specific functionality
- Reduced bundle size for React-only applications
**Files Affected:**
- All components using Apollo hooks (50+ files)
- Main app provider component
- GraphQL container components
### `useLazyQuery` API Changes
The return value destructuring pattern for `useLazyQuery` changed in Apollo Client 4.x.
**Example - Query Function Extraction:**
```javascript
// BEFORE (Apollo Client 3.x)
const [, { data, refetch, queryLoading }] = useLazyQuery(QUERY_RO_AND_OWNER_BY_JOB_PKS, {
variables: { jobids: [context.jobid] },
skip: !context?.jobid
});
// AFTER (Apollo Client 4.x)
const [loadRoAndOwnerByJobPks, { data, loading: queryLoading, error: queryError, refetch, called }] = useLazyQuery(
QUERY_RO_AND_OWNER_BY_JOB_PKS
);
// Call the query function explicitly when needed
useEffect(() => {
if (context?.jobid) {
loadRoAndOwnerByJobPks({ variables: { jobids: [context.jobid] } });
}
}, [context?.jobid, loadRoAndOwnerByJobPks]);
```
**Key Changes:**
- **Query function must be destructured**: Previously ignored with `,` now must be named
- **Options moved to function call**: `variables` and other options passed when calling the query function
- **`loading` renamed**: More consistent with `useQuery` hook naming
- **`called` property added**: Track if the query has been executed at least once
- **No more `skip` option**: Logic moved to conditional query execution
**Benefits:**
- More explicit control over when queries execute
- Better alignment with `useQuery` API patterns
- Clearer code showing query execution timing
**Files Affected:**
- card-payment-modal.component.jsx
- bill-form.container.jsx
- Multiple job and payment components
---
## 6. forwardRef Pattern Migration
React 19 simplifies ref handling by allowing `ref` to be passed as a regular prop, eliminating the need for `forwardRef` in most cases.
### forwardRef Wrapper Removal
**Example - Component Signature Change:**
```javascript
// BEFORE
import { forwardRef } from "react";
const BillLineSearchSelect = ({ options, disabled, allowRemoved, ...restProps }, ref) => {
const { t } = useTranslation();
return (
<Select
ref={ref}
options={generateOptions(options, allowRemoved, t)}
disabled={disabled}
{...restProps}
/>
);
};
export default forwardRef(BillLineSearchSelect);
// AFTER
const BillLineSearchSelect = ({ options, disabled, allowRemoved, ref, ...restProps }) => {
const { t } = useTranslation();
return (
<Select
ref={ref}
options={generateOptions(options, allowRemoved, t)}
disabled={disabled}
{...restProps}
/>
);
};
export default BillLineSearchSelect;
```
**Key Changes:**
- **`ref` as regular prop**: Moved from second parameter to first parameter as a regular prop
- **No `forwardRef` import needed**: Removed from React imports
- **No `forwardRef` wrapper**: Export component directly
- **Same ref behavior**: Works identically from parent component perspective
**Benefits:**
- Simpler component API (single parameter instead of two)
- Reduced boilerplate code
- Better TypeScript inference
- More intuitive for developers
**Components Migrated:**
- BillLineSearchSelect
- ContractStatusComponent
- CourtesyCarFuelComponent
- CourtesyCarReadinessComponent
- CourtesyCarStatusComponent
- EmployeeTeamSearchSelect
- FormInputNumberCalculator
- FormItemCurrency
- FormItemEmail
- 10+ additional form components
---
## 7. React.lazy Import Cleanup
React 19 makes `React.lazy` usage more seamless, and in some cases lazy imports were removed where they were no longer beneficial.
**Example - Lazy Import Removal:**
```javascript
// BEFORE
import { lazy, Suspense, useEffect, useRef, useState } from "react";
const LazyComponent = lazy(() => import('./HeavyComponent'));
// AFTER
import { Suspense, useEffect, useRef, useState } from "react";
// Lazy loading handled differently or component loaded directly
```
**Context:**
- Some components had lazy imports removed where the loading behavior wasn't needed
- `Suspense` boundaries maintained for actual lazy-loaded components
- React 19 improves Suspense integration
**Files Affected:**
- Multiple route components
- Dashboard components
- Heavy data visualization components
---
## 8. StrictMode Integration
React 19's StrictMode was explicitly added to help catch potential issues during development.
**Addition:**
```javascript
import { StrictMode } from "react";
root.render(
<StrictMode>
<App />
</StrictMode>
);
```
**Benefits:**
- Detects unexpected side effects
- Warns about deprecated APIs
- Validates React 19 best practices
- Double-invokes effects in development to catch issues
**Impact:**
- Helps ensure components work correctly with React 19 compiler
- Catches potential issues with state management
- Comment added: "This handles React StrictMode double-mounting"
---
## 9. React 19 New Hooks (Added Documentation)
The upgrade includes documentation for React 19's new concurrent hooks:
### `useFormStatus`
Track form submission state for better UX during async operations.
### `useOptimistic`
Implement optimistic UI updates that rollback on failure.
### `useActionState`
Manage server actions with pending states and error handling.
---
## 10. ESLint Configuration Updates
### React Compiler Plugin Added
**Addition to eslint.config.js:**
```javascript
plugins: {
"react-compiler": pluginReactCompiler
},
rules: {
"react-compiler/react-compiler": "error"
}
```
**Purpose:**
- Enforces React 19 compiler best practices
- Warns about patterns that prevent compiler optimizations
- Ensures code is compatible with automatic optimizations
---
## 11. Testing Library Updates
### @testing-library/react
- **Upgraded:** ^16.3.1 → ^16.3.2
- **Impact:** React 19 compatibility maintained
- Tests continue to work with updated React APIs
---
## 12. Peer Dependencies Updates
Multiple packages updated their peer dependency requirements to support React 19:
**Examples:**
```json
// BEFORE
"peerDependencies": {
"react": ">=16.9.0",
"react-dom": ">=16.9.0"
}
// AFTER
"peerDependencies": {
"react": ">=18.0.0",
"react-dom": ">=18.0.0"
}
```
**Affected Packages:**
- Multiple internal and external dependencies
- Ensures ecosystem compatibility with React 19
---
## 13. Ant Design 6 Changes
### Icon Package Update
- @ant-design/icons upgraded from ^5.6.1 to ^6.1.0
- Icon imports remain compatible (no breaking changes in usage patterns)
### Component API Compatibility
- Existing Ant Design component usage remains largely compatible
- Form.Item, Button, Modal, Table, and other components work with existing code
- No major API breaking changes required in application code
---
## 14. Validation & Quality Assurance
Based on the optimization summary included in the changes:
### Deprecations Verified as Fixed ✓
- **propTypes:** None found (already removed or using TypeScript)
- **defaultProps:** None found (using default parameters instead)
- **ReactDOM.render:** Already using createRoot
- **componentWillMount/Receive/Update:** No legacy lifecycle methods found
- **String refs:** Migrated to ref objects and useRef hooks
### Performance Improvements
- Cleaner, more readable code
- Reduced bundle size (removed unnecessary memoization wrappers)
- Better performance through compiler-optimized memoization
- Fewer function closures and re-creations
- Reduced memory overhead from eliminated deep cloning
---
## Summary Statistics
### Dependencies Updated
- **Core:** 3 major updates (React, Ant Design, Apollo Client)
- **GraphQL:** 2 packages (Apollo Client 3→4, apollo-link-logger 2→3)
- **Ecosystem:** 10+ related libraries (router, i18next, grid layout, etc.)
- **Build Tools:** 3 plugins/tools (Vite plugins, vitest)
- **Monitoring:** 6 packages (Sentry, LogRocket, PostHog, Amplitude)
- **Infrastructure:** Node.js 22 → 24
### Code Patterns Modernized
- **useMemo removals:** 15+ instances across multiple files
- **useCallback removals:** 10+ instances
- **memo() wrapper removals:** 5+ components
- **Deep clone eliminations:** Multiple state management simplifications
- **Import cleanups:** Dozens of simplified import statements
- **Apollo import migrations:** 50+ files updated to `/react` imports
- **forwardRef removals:** 15+ components migrated to direct ref props
- **useLazyQuery updates:** Multiple query patterns updated for Apollo 4.x API
- **lazy import cleanups:** Several unnecessary lazy imports removed
- **StrictMode integration:** Added to development builds
### Files Impacted
- **Production board kanban components:** Compiler optimization removals
- **Trello-board controllers and components:** Memoization removals
- **Job management components:** State management simplifications
- **All GraphQL components:** Apollo Client 4.x import migrations (50+ files)
- **Form components:** forwardRef pattern migrations (15+ components)
- **Payment components:** useLazyQuery API updates
- **Various utility components:** Import cleanups
- **Build configuration files:** ESLint React compiler plugin
- **Docker infrastructure:** Node.js 22→24 upgrade
- **App root:** StrictMode integration
- **Package manifests:** 30+ dependency upgrades
---
## Recommendations for Future Development
1. **Avoid Manual Memoization:** Let React 19 compiler handle optimization automatically
2. **Use ESLint React Compiler Plugin:** Catch patterns that prevent optimizations
3. **Maintain Referential Stability:** Use constant empty arrays/objects instead of creating new ones
4. **Leverage New React 19 Hooks:** Use `useOptimistic`, `useFormStatus`, and `useActionState` for better UX
5. **Monitor Compiler Warnings:** Address any compiler optimization warnings during development
6. **Apollo Client 4.x Imports:** Always import React hooks from `@apollo/client/react`
7. **Ref as Props:** Use `ref` as a regular prop instead of `forwardRef` wrapper
8. **useLazyQuery Pattern:** Extract query function and call explicitly rather than using `skip` option
9. **StrictMode Aware:** Ensure components handle double-mounting in development properly
10. **Keep Dependencies Updated:** Monitor for peer dependency compatibility as ecosystem evolves
---
## Conclusion
This comprehensive upgrade successfully modernizes the codebase across multiple dimensions:
### Major Achievements
1. **React 19 Migration:** Leveraged new compiler optimizations by removing manual memoization
2. **Apollo Client 4.x:** Updated all GraphQL operations to new import patterns and APIs
3. **Ant Design 6:** Maintained UI consistency while gaining access to latest features
4. **forwardRef Elimination:** Simplified 15+ components by using refs as regular props
5. **Dependency Modernization:** Updated 30+ packages including monitoring, build tools, and ecosystem libraries
6. **Infrastructure Upgrade:** Node.js 24.x support for latest runtime features
### Code Quality Improvements
- **Cleaner code:** Removed unnecessary wrappers and boilerplate
- **Better performance:** Compiler-optimized rendering without manual hints
- **Reduced bundle size:** Removed lodash cloning, unnecessary lazy imports, and redundant memoization
- **Improved maintainability:** Simpler patterns that are easier to understand and modify
- **Enhanced DX:** ESLint integration catches optimization blockers during development
### Migration Completeness
✅ All React 18→19 deprecations addressed
✅ All Apollo Client 3→4 breaking changes handled
✅ All Ant Design 5→6 updates applied
✅ All monitoring libraries updated to latest versions
✅ StrictMode integration for development safety
✅ Comprehensive testing library compatibility maintained
**No breaking changes to application functionality** - The upgrade maintains backward compatibility in behavior while providing forward-looking improvements in implementation.

File diff suppressed because it is too large Load Diff

View File

@@ -18,4 +18,3 @@ VITE_PUBLIC_POSTHOG_KEY=phc_xtLmBIu0rjWwExY73Oj5DTH1bGbwq1G1Y8jnlTceien
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com
VITE_APP_AMP_KEY=6228a598e57cd66875cfd41604f1f891
VITE_ENABLE_COMPILER_IN_DEV=1

View File

@@ -20,4 +20,3 @@ VITE_PUBLIC_POSTHOG_KEY=phc_xtLmBIu0rjWwExY73Oj5DTH1bGbwq1G1Y8jnlTceien
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com
VITE_APP_AMP_KEY=46b1193a867d4e3131ae4c3a64a3fc78
VITE_ENABLE_COMPILER_IN_DEV=1

3
client/.gitignore vendored
View File

@@ -13,3 +13,6 @@ playwright/.cache/
# Sentry Config File
.sentryclirc
/dev-dist
# Local environment overrides (not version controlled)
.env.development.local.overrides

1021
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -8,9 +8,13 @@
"private": true,
"proxy": "http://localhost:4000",
"dependencies": {
"@amplitude/analytics-browser": "^2.33.4",
"@amplitude/analytics-browser": "^2.34.0",
"@ant-design/pro-layout": "^7.22.6",
"@apollo/client": "^4.1.1",
"@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",
"@fingerprintjs/fingerprintjs": "^5.0.1",
"@firebase/analytics": "^0.10.19",
@@ -21,14 +25,14 @@
"@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.11.2",
"@sentry/cli": "^3.1.0",
"@sentry/react": "^10.35.0",
"@sentry/vite-plugin": "^4.7.0",
"@sentry/react": "^10.38.0",
"@sentry/vite-plugin": "^4.8.0",
"@splitsoftware/splitio-react": "^2.6.1",
"@tanem/react-nprogress": "^5.0.56",
"antd": "^6.2.1",
"@tanem/react-nprogress": "^5.0.58",
"antd": "^6.2.2",
"apollo-link-logger": "^3.0.0",
"autosize": "^6.0.1",
"axios": "^1.13.2",
"axios": "^1.13.4",
"classnames": "^2.5.1",
"css-box-model": "^1.2.1",
"dayjs": "^1.11.19",
@@ -38,31 +42,30 @@
"env-cmd": "^11.0.0",
"exifr": "^7.1.3",
"graphql": "^16.12.0",
"graphql-ws": "^6.0.6",
"graphql-ws": "^6.0.7",
"i18next": "^25.8.0",
"i18next-browser-languagedetector": "^8.2.0",
"immutability-helper": "^3.1.1",
"libphonenumber-js": "^1.12.34",
"lightningcss": "^1.31.0",
"logrocket": "^11.0.0",
"libphonenumber-js": "^1.12.36",
"lightningcss": "^1.31.1",
"logrocket": "^12.0.0",
"markerjs2": "^2.32.7",
"memoize-one": "^6.0.0",
"normalize-url": "^8.1.1",
"object-hash": "^3.0.0",
"phone": "^3.1.69",
"posthog-js": "^1.335.0",
"phone": "^3.1.70",
"posthog-js": "^1.336.4",
"prop-types": "^15.8.1",
"query-string": "^9.3.1",
"raf-schd": "^4.0.3",
"react": "^19.2.3",
"react": "^19.2.4",
"react-big-calendar": "^1.19.4",
"react-color": "^2.19.3",
"react-cookie": "^8.0.1",
"react-dom": "^19.2.3",
"react-drag-listview": "^2.0.0",
"react-dom": "^19.2.4",
"react-grid-gallery": "^1.0.1",
"react-grid-layout": "^2.2.2",
"react-i18next": "^16.5.3",
"react-i18next": "^16.5.4",
"react-icons": "^5.5.0",
"react-image-lightbox": "^5.1.4",
"react-markdown": "^10.1.0",
@@ -71,10 +74,10 @@
"react-product-fruits": "^2.2.62",
"react-redux": "^9.2.0",
"react-resizable": "^3.1.3",
"react-router-dom": "^7.12.0",
"react-router-dom": "^7.13.0",
"react-sticky": "^6.0.3",
"react-virtuoso": "^4.18.1",
"recharts": "^3.6.0",
"recharts": "^3.7.0",
"redux": "^5.0.1",
"redux-actions": "^3.0.3",
"redux-persist": "^6.0.0",
@@ -82,7 +85,7 @@
"redux-state-sync": "^3.1.4",
"reselect": "^5.1.1",
"rxjs": "^7.8.2",
"sass": "^1.97.2",
"sass": "^1.97.3",
"socket.io-client": "^4.8.3",
"styled-components": "^6.3.8",
"vite-plugin-ejs": "^1.7.0",
@@ -92,15 +95,17 @@
"postinstall": "echo 'when updating react-big-calendar, remember to check to localizer in the calendar wrapper'",
"analyze": "source-map-explorer 'build/static/js/*.js'",
"start": "vite",
"build": "dotenvx run --env-file=.env.development.imex -- vite build",
"start:imex": "dotenvx run --env-file=.env.development.imex -- vite",
"start:rome": "dotenvx run --env-file=.env.development.rome -- vite",
"preview:imex": "dotenvx run --env-file=.env.development.imex -- vite preview",
"preview:rome": "dotenvx run --env-file=.env.development.rome -- vite preview",
"build:test:imex": "env-cmd -f .env.test.imex -- npm run build",
"build:test:rome": "env-cmd -f .env.test.rome -- npm run build",
"build:production:imex": "env-cmd -f .env.production.imex -- npm run build",
"build:production:rome": "env-cmd -f .env.production.rome -- npm run build",
"build": "vite build",
"build:dev:imex": "dotenvx run --env-file=.env.development.imex --env-file=.env.development.local.overrides -- vite build",
"build:dev:rome": "dotenvx run --env-file=.env.development.rome --env-file=.env.development.local.overrides -- vite build",
"build:test:imex": "dotenvx run --env-file=.env.test.imex -- vite build",
"build:test:rome": "dotenvx run --env-file=.env.test.rome -- vite build",
"build:production:imex": "env-cmd -f .env.production.imex vite build",
"build:production:rome": "env-cmd -f .env.production.rome vite build",
"start:imex": "dotenvx run --env-file=.env.development.imex --env-file=.env.development.local.overrides -- vite",
"start:rome": "dotenvx run --env-file=.env.development.rome --env-file=.env.development.local.overrides -- vite",
"preview:imex": "dotenvx run --env-file=.env.development.imex --env-file=.env.development.local.overrides -- vite preview",
"preview:rome": "dotenvx run --env-file=.env.development.rome --env-file=.env.development.local.overrides -- vite preview",
"madge": "madge --image ./madge-graph.svg --extensions js,jsx,ts,tsx --circular .",
"eulaize": "node src/utils/eulaize.js",
"test:unit": "vitest run",
@@ -151,7 +156,7 @@
"eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "^19.1.0-rc.2",
"globals": "^17.1.0",
"globals": "^17.2.0",
"jsdom": "^27.4.0",
"memfs": "^4.56.10",
"os-browserify": "^0.3.0",

View File

@@ -6,8 +6,7 @@ import enLocale from "antd/es/locale/en_US";
import { useEffect, useMemo } from "react";
import { CookiesProvider } from "react-cookie";
import { useTranslation } from "react-i18next";
import { connect, useSelector } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useDispatch, useSelector } from "react-redux";
import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component";
import { setDarkMode } from "../redux/application/application.actions";
import { selectDarkMode } from "../redux/application/application.selectors";
@@ -28,93 +27,102 @@ const config = {
function SplitClientProvider({ children }) {
const imexshopid = useSelector((state) => state.user.imexshopid);
const splitClient = useSplitClient({ key: imexshopid || "anon" });
useEffect(() => {
if (splitClient && imexshopid) {
if (import.meta.env.DEV && splitClient && imexshopid) {
console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`);
}
}, [splitClient, imexshopid]);
return children;
}
const mapStateToProps = createStructuredSelector({
currentUser: selectCurrentUser
});
const mapDispatchToProps = (dispatch) => ({
setDarkMode: (isDarkMode) => dispatch(setDarkMode(isDarkMode)),
signOutStart: () => dispatch(signOutStart())
});
function AppContainer({ currentUser, setDarkMode, signOutStart }) {
function AppContainer() {
const { t } = useTranslation();
const dispatch = useDispatch();
const currentUser = useSelector(selectCurrentUser);
const isDarkMode = useSelector(selectDarkMode);
const theme = useMemo(() => getTheme(isDarkMode), [isDarkMode]);
const antdInput = useMemo(() => ({ autoComplete: "new-password" }), []);
const antdForm = useMemo(
() => ({
validateMessages: {
required: t("general.validation.required", { label: "${label}" })
}
}),
[t]
);
// Global seamless logout listener with redirect to /signin
useEffect(() => {
const handleSeamlessLogout = (event) => {
if (event.data?.type !== "seamlessLogoutRequest") return;
const requestOrigin = event.origin;
// Only accept messages from the parent window
if (event.source !== window.parent) return;
const targetOrigin = event.origin || "*";
if (currentUser?.authorized !== true) {
window.parent.postMessage(
{ type: "seamlessLogoutResponse", status: "already_logged_out" },
requestOrigin || "*"
);
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "already_logged_out" }, targetOrigin);
return;
}
signOutStart();
window.parent.postMessage({ type: "seamlessLogoutResponse", status: "logged_out" }, requestOrigin || "*");
dispatch(signOutStart());
window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "logged_out" }, targetOrigin);
};
window.addEventListener("message", handleSeamlessLogout);
return () => {
window.removeEventListener("message", handleSeamlessLogout);
};
}, [signOutStart, currentUser]);
}, [dispatch, currentUser?.authorized]);
// Update data-theme attribute
// Update data-theme attribute (no cleanup to avoid transient style churn)
useEffect(() => {
document.documentElement.setAttribute("data-theme", isDarkMode ? "dark" : "light");
return () => document.documentElement.removeAttribute("data-theme");
document.documentElement.dataset.theme = isDarkMode ? "dark" : "light";
}, [isDarkMode]);
// Sync darkMode with localStorage
useEffect(() => {
if (currentUser?.uid) {
const savedMode = localStorage.getItem(`dark-mode-${currentUser.uid}`);
if (savedMode !== null) {
setDarkMode(JSON.parse(savedMode));
} else {
setDarkMode(false);
const uid = currentUser?.uid;
if (!uid) {
dispatch(setDarkMode(false));
return;
}
} else {
setDarkMode(false);
const key = `dark-mode-${uid}`;
const raw = localStorage.getItem(key);
if (raw == null) {
dispatch(setDarkMode(false));
return;
}
}, [currentUser?.uid, setDarkMode]);
try {
dispatch(setDarkMode(Boolean(JSON.parse(raw))));
} catch {
dispatch(setDarkMode(false));
}
}, [currentUser?.uid, dispatch]);
// Persist darkMode
useEffect(() => {
if (currentUser?.uid) {
localStorage.setItem(`dark-mode-${currentUser.uid}`, JSON.stringify(isDarkMode));
}
const uid = currentUser?.uid;
if (!uid) return;
localStorage.setItem(`dark-mode-${uid}`, JSON.stringify(isDarkMode));
}, [isDarkMode, currentUser?.uid]);
return (
<CookiesProvider>
<ApolloProvider client={client}>
<ConfigProvider
input={{ autoComplete: "new-password" }}
locale={enLocale}
theme={theme}
form={{
validateMessages: {
required: t("general.validation.required", { label: "${label}" })
}
}}
>
<ConfigProvider input={antdInput} locale={enLocale} theme={theme} form={antdForm}>
<GlobalLoadingBar />
<SplitFactoryProvider config={config}>
<SplitClientProvider>
@@ -127,4 +135,4 @@ function AppContainer({ currentUser, setDarkMode, signOutStart }) {
);
}
export default Sentry.withProfiler(connect(mapStateToProps, mapDispatchToProps)(AppContainer));
export default Sentry.withProfiler(AppContainer);

View File

@@ -100,14 +100,7 @@ export function App({
if (currentUser.authorized && bodyshop) {
client.setAttribute("imexshopid", bodyshop.imexshopid);
if (
client.getTreatment("LogRocket_Tracking") === "on" ||
window.location.hostname ===
InstanceRenderMgr({
imex: "beta.imex.online",
rome: "beta.romeonline.io"
})
) {
if (client.getTreatment("LogRocket_Tracking") === "on") {
console.log("LR Start");
LogRocket.init(
InstanceRenderMgr({

View File

@@ -446,3 +446,32 @@
//.rbc-time-header-gutter {
// padding: 0;
//}
/* globally allow shrink inside table cells */
.prod-list-table .ant-table-cell,
.prod-list-table .ant-table-cell > * {
min-width: 0;
}
/* common AntD offenders */
.prod-list-table > .ant-table-cell .ant-space,
.ant-table-cell .ant-space-item {
min-width: 0;
}
/* Keep your custom header content on the left, push AntD sorter to the far right */
.prod-list-table .ant-table-column-sorters {
display: flex !important;
align-items: center;
width: 100%;
}
.prod-list-table .ant-table-column-title {
flex: 1 1 auto;
min-width: 0; /* allows ellipsis to work */
}
.prod-list-table .ant-table-column-sorter {
margin-left: auto;
flex: 0 0 auto;
}

View File

@@ -169,14 +169,20 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, bills, ref
refetch={refetch}
/>
{bodyshop.accountingconfig && bodyshop.accountingconfig.qbo && <QboAuthorizeComponent />}
<Input value={state.search} onChange={handleSearch} placeholder={t("general.labels.search")} allowClear />
<Input
value={state.search}
onChange={handleSearch}
placeholder={t("general.labels.search")}
allowClear
enterButton
/>
</Space>
}
>
<Table
loading={loading}
dataSource={dataSource}
pagination={{ placement: "top", pageSize: exportPageLimit }}
pagination={{ placement: "top", pageSize: exportPageLimit, showSizeChanger: false }}
columns={columns}
rowKey="id"
onChange={handleTableChange}

View File

@@ -182,14 +182,20 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, payments,
refetch={refetch}
/>
{bodyshop.accountingconfig && bodyshop.accountingconfig.qbo && <QboAuthorizeComponent />}
<Input value={state.search} onChange={handleSearch} placeholder={t("general.labels.search")} allowClear />
<Input
value={state.search}
onChange={handleSearch}
placeholder={t("general.labels.search")}
allowClear
enterButton
/>
</Space>
}
>
<Table
loading={loading}
dataSource={dataSource}
pagination={{ placement: "top", pageSize: exportPageLimit }}
pagination={{ placement: "top", pageSize: exportPageLimit, showSizeChanger: false }}
columns={columns}
rowKey="id"
onChange={handleTableChange}

View File

@@ -204,6 +204,7 @@ export function AccountingReceivablesTableComponent({ bodyshop, loading, jobs, r
onChange={handleSearch}
placeholder={t("general.labels.search")}
allowClear
enterButton
/>
</Space>
}
@@ -211,7 +212,7 @@ export function AccountingReceivablesTableComponent({ bodyshop, loading, jobs, r
<Table
loading={loading}
dataSource={dataSource}
pagination={{ placement: "top", pageSize: exportPageLimit }}
pagination={{ placement: "top", pageSize: exportPageLimit, showSizeChanger: false }}
columns={columns}
rowKey="id"
onChange={handleTableChange}

View File

@@ -52,6 +52,9 @@ export default function BillCmdReturnsTableComponent({ form, returnLoading, retu
{fields.map((field, index) => (
<tr key={field.key}>
<td>
<Form.Item hidden key={`${index}id`} name={[field.name, "id"]}>
<ReadOnlyFormItemComponent />
</Form.Item>
<Form.Item
// label={t("joblines.fields.line_desc")}
key={`${index}line_desc`}

View File

@@ -77,13 +77,7 @@ export function BillDeleteButton({ bill, jobid, callback, insertAuditTrail }) {
return (
<RbacWrapper action="bills:delete" noauth={<></>}>
<Popconfirm disabled={bill.exported} onConfirm={handleDelete} title={t("bills.labels.deleteconfirm")}>
<Button
disabled={bill.exported}
// onClick={handleDelete}
loading={loading}
>
<DeleteFilled />
</Button>
<Button icon={<DeleteFilled />} disabled={bill.exported} loading={loading} />
</Popconfirm>
</RbacWrapper>
);

View File

@@ -48,7 +48,7 @@ export function BillDetailEditReturn({ setPartsOrderContext, data, disabled }) {
// db_price: i.actual_price,
act_price: i.actual_price,
cost: i.actual_cost,
quantity: i.quantity,
part_qty: i.quantity,
joblineid: i.joblineid,
oem_partno: i.jobline && i.jobline.oem_partno,
part_type: i.jobline && i.jobline.part_type
@@ -104,6 +104,10 @@ export function BillDetailEditReturn({ setPartsOrderContext, data, disabled }) {
{fields.map((field, index) => (
<tr key={field.key}>
<td>
{/* Hidden field to preserve the id */}
<Form.Item name={[field.name, "id"]} hidden>
<input type="hidden" />
</Form.Item>
<Form.Item
// label={t("joblines.fields.selected")}
key={`${index}selected`}

View File

@@ -32,6 +32,7 @@ export function BillFormItemsExtendedFormItem({
if (!value)
return (
<Button
icon={<PlusCircleFilled />}
onClick={() => {
const values = form.getFieldsValue("billlineskeys");
@@ -53,9 +54,7 @@ export function BillFormItemsExtendedFormItem({
}
});
}}
>
<PlusCircleFilled />
</Button>
/>
);
return (
@@ -196,6 +195,7 @@ export function BillFormItemsExtendedFormItem({
</Form.Item>
<Button
icon={<MinusCircleFilled />}
onClick={() => {
const values = form.getFieldsValue("billlineskeys");
@@ -207,9 +207,7 @@ export function BillFormItemsExtendedFormItem({
}
});
}}
>
<MinusCircleFilled />
</Button>
/>
</Space>
);
}

View File

@@ -373,9 +373,11 @@ export function BillFormComponent({
"local_tax_rate"
]);
let totals;
if (!!values.total && !!values.billlines && values.billlines.length > 0)
if (!!values.total && !!values.billlines && values.billlines.length > 0) {
totals = CalculateBillTotal(values);
if (totals)
}
if (totals) {
return (
// TODO: Align is not correct
// eslint-disable-next-line react/no-unknown-property
@@ -414,7 +416,7 @@ export function BillFormComponent({
<Statistic
title={t("bills.labels.discrepancy")}
styles={{
value: {
content: {
color: totals.discrepancy.getAmount() === 0 ? "green" : "red"
}
}}
@@ -427,6 +429,7 @@ export function BillFormComponent({
) : null}
</div>
);
}
return null;
}}
</Form.Item>

View File

@@ -1,6 +1,7 @@
import { DeleteFilled, DollarCircleFilled } from "@ant-design/icons";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { Button, Checkbox, Form, Input, InputNumber, Select, Space, Switch, Table, Tooltip } from "antd";
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -32,14 +33,14 @@ export function BillEnterModalLinesComponent({
}) {
const { t } = useTranslation();
const { setFieldsValue, getFieldsValue, getFieldValue } = form;
const firstFieldRefs = useRef({});
// Keep input row heights consistent with the rest of the table controls.
const CONTROL_HEIGHT = 32;
const normalizeDiscount = (d) => {
const n = Number(d);
if (!Number.isFinite(n) || n <= 0) return 0;
return n > 1 ? n / 100 : n; // supports 15 or 0.15
return n > 1 ? n / 100 : n;
};
const round2 = (v) => Math.round((v + Number.EPSILON) * 100) / 100;
@@ -79,7 +80,6 @@ export function BillEnterModalLinesComponent({
return NaN;
};
// safe per-field setter (supports AntD 6+ setFieldValue, falls back to setFieldsValue)
const setLineField = (index, field, value) => {
if (typeof form.setFieldValue === "function") {
form.setFieldValue(["billlines", index, field], value);
@@ -92,6 +92,7 @@ export function BillEnterModalLinesComponent({
});
};
// Only fill actual_cost when the user forward-tabs out of Retail (actual_price)
const autofillActualCost = (index) => {
Promise.resolve().then(() => {
const retailRaw = form.getFieldValue(["billlines", index, "actual_price"]);
@@ -115,7 +116,6 @@ export function BillEnterModalLinesComponent({
};
const getIndicatorShellStyles = (statusColor) => {
// bring back the “colored shell” feel around the $ indicator while keeping row height stable
if (isDarkMode) {
if (statusColor === "green")
return { borderColor: "rgba(82, 196, 26, 0.75)", background: "rgba(82, 196, 26, 0.10)" };
@@ -145,7 +145,7 @@ export function BillEnterModalLinesComponent({
editable: true,
minWidth: "10rem",
formItemProps: (field) => ({
key: `${field.index}joblinename`,
key: `${field.name}joblinename`,
name: [field.name, "joblineid"],
label: t("billlines.fields.jobline"),
rules: [{ required: true }]
@@ -157,6 +157,9 @@ export function BillEnterModalLinesComponent({
),
formInput: (record, index) => (
<BillLineSearchSelect
ref={(el) => {
firstFieldRefs.current[index] = el;
}}
disabled={disabled}
options={lineData}
style={{
@@ -167,10 +170,9 @@ export function BillEnterModalLinesComponent({
}}
allowRemoved={form.getFieldValue("is_credit_memo") || false}
onSelect={(value, opt) => {
const d = normalizeDiscount(discount);
const retail = Number(opt.cost);
const computedActual = Number.isFinite(retail) ? round2(retail * (1 - d)) : null;
// IMPORTANT:
// Do NOT autofill actual_cost here. It should only fill when the user forward-tabs
// from Retail (actual_price) -> Actual Cost (actual_cost).
setFieldsValue({
billlines: (getFieldValue("billlines") || []).map((item, idx) => {
if (idx !== index) return item;
@@ -181,7 +183,7 @@ export function BillEnterModalLinesComponent({
quantity: opt.part_qty || 1,
actual_price: opt.cost,
original_actual_price: opt.cost,
actual_cost: isBlank(item.actual_cost) ? computedActual : item.actual_cost,
// actual_cost intentionally untouched here
cost_center: opt.part_type
? bodyshopHasDmsKey(bodyshop)
? opt.part_type !== "PAE"
@@ -203,12 +205,12 @@ export function BillEnterModalLinesComponent({
editable: true,
minWidth: "10rem",
formItemProps: (field) => ({
key: `${field.index}line_desc`,
key: `${field.name}line_desc`,
name: [field.name, "line_desc"],
label: t("billlines.fields.line_desc"),
rules: [{ required: true }]
}),
formInput: () => <Input.TextArea disabled={disabled} autoSize />
formInput: () => <Input.TextArea disabled={disabled} autoSize tabIndex={0} />
},
{
title: t("billlines.fields.confidence"),
@@ -228,17 +230,19 @@ export function BillEnterModalLinesComponent({
editable: true,
width: "4rem",
formItemProps: (field) => ({
key: `${field.index}quantity`,
key: `${field.name}quantity`,
name: [field.name, "quantity"],
label: t("billlines.fields.quantity"),
rules: [
{ required: true },
({ getFieldValue: gf }) => ({
validator(rule, value) {
if (value && gf("billlines")[field.fieldKey]?.inventories?.length > value) {
validator(_, value) {
const invLen = gf(["billlines", field.name, "inventories"])?.length ?? 0;
if (value && invLen > value) {
return Promise.reject(
t("bills.validation.inventoryquantity", {
number: gf("billlines")[field.fieldKey]?.inventories?.length
number: invLen
})
);
}
@@ -247,7 +251,7 @@ export function BillEnterModalLinesComponent({
})
]
}),
formInput: () => <InputNumber precision={0} min={1} disabled={disabled} />
formInput: () => <InputNumber precision={0} min={1} disabled={disabled} tabIndex={0} />
},
{
title: t("billlines.fields.actual_price"),
@@ -255,7 +259,7 @@ export function BillEnterModalLinesComponent({
width: "8rem",
editable: true,
formItemProps: (field) => ({
key: `${field.index}actual_price`,
key: `${field.name}actual_price`,
name: [field.name, "actual_price"],
label: t("billlines.fields.actual_price"),
rules: [{ required: true }]
@@ -264,9 +268,10 @@ export function BillEnterModalLinesComponent({
<CurrencyInput
min={0}
disabled={disabled}
onBlur={() => autofillActualCost(index)}
tabIndex={0}
// NOTE: Autofill should only happen on forward Tab out of Retail
onKeyDown={(e) => {
if (e.key === "Tab") autofillActualCost(index);
if (e.key === "Tab" && !e.shiftKey) autofillActualCost(index);
}}
/>
),
@@ -307,7 +312,7 @@ export function BillEnterModalLinesComponent({
width: "10rem",
skipFormItem: true,
formItemProps: (field) => ({
key: `${field.index}actual_cost`,
key: `${field.name}actual_cost`,
name: [field.name, "actual_cost"],
label: t("billlines.fields.actual_cost"),
rules: [{ required: true }]
@@ -341,6 +346,7 @@ export function BillEnterModalLinesComponent({
min={0}
disabled={disabled}
controls={false}
tabIndex={0}
style={{ width: "100%", height: CONTROL_HEIGHT }}
onFocus={() => autofillActualCost(index)}
/>
@@ -398,14 +404,14 @@ export function BillEnterModalLinesComponent({
dataIndex: "cost_center",
editable: true,
formItemProps: (field) => ({
key: `${field.index}cost_center`,
key: `${field.name}cost_center`,
name: [field.name, "cost_center"],
label: t("billlines.fields.cost_center"),
valuePropName: "value",
rules: [{ required: true }]
}),
formInput: () => (
<Select showSearch style={{ minWidth: "3rem" }} disabled={disabled}>
<Select showSearch style={{ minWidth: "3rem" }} disabled={disabled} tabIndex={0}>
{bodyshopHasDmsKey(bodyshop)
? CiecaSelect(true, false)
: responsibilityCenters.costs.map((item) => <Select.Option key={item.name}>{item.name}</Select.Option>)}
@@ -421,11 +427,11 @@ export function BillEnterModalLinesComponent({
editable: true,
label: t("billlines.fields.location"),
formItemProps: (field) => ({
key: `${field.index}location`,
key: `${field.name}location`,
name: [field.name, "location"]
}),
formInput: () => (
<Select disabled={disabled}>
<Select disabled={disabled} tabIndex={0}>
{bodyshop.md_parts_locations.map((loc, idx) => (
<Select.Option key={idx} value={loc}>
{loc}
@@ -442,10 +448,10 @@ export function BillEnterModalLinesComponent({
width: "40px",
formItemProps: (field) => ({
valuePropName: "checked",
key: `${field.index}deductedfromlbr`,
key: `${field.name}deductedfromlbr`,
name: [field.name, "deductedfromlbr"]
}),
formInput: () => <Switch disabled={disabled} />,
formInput: () => <Switch disabled={disabled} tabIndex={0} />,
additional: (record, index) => (
<Form.Item shouldUpdate noStyle style={{ display: "inline-block" }}>
{() => {
@@ -528,11 +534,15 @@ export function BillEnterModalLinesComponent({
editable: true,
width: "40px",
formItemProps: (field) => ({
key: `${field.index}fedtax`,
key: `${field.name}fedtax`,
valuePropName: "checked",
name: [field.name, "applicable_taxes", "federal"]
name: [field.name, "applicable_taxes", "federal"],
initialValue: InstanceRenderManager({
imex: true,
rome: false
})
}),
formInput: () => <Switch disabled={disabled} />
formInput: () => <Switch disabled={disabled} tabIndex={0} />
}
]
}),
@@ -543,11 +553,11 @@ export function BillEnterModalLinesComponent({
editable: true,
width: "40px",
formItemProps: (field) => ({
key: `${field.index}statetax`,
key: `${field.name}statetax`,
valuePropName: "checked",
name: [field.name, "applicable_taxes", "state"]
}),
formInput: () => <Switch disabled={disabled} />
formInput: () => <Switch disabled={disabled} tabIndex={0} />
},
...InstanceRenderManager({
@@ -559,11 +569,11 @@ export function BillEnterModalLinesComponent({
editable: true,
width: "40px",
formItemProps: (field) => ({
key: `${field.index}localtax`,
key: `${field.name}localtax`,
valuePropName: "checked",
name: [field.name, "applicable_taxes", "local"]
}),
formInput: () => <Switch disabled={disabled} />
formInput: () => <Switch disabled={disabled} tabIndex={0} />
}
]
}),
@@ -573,24 +583,29 @@ export function BillEnterModalLinesComponent({
dataIndex: "actions",
render: (text, record) => (
<Form.Item shouldUpdate noStyle>
{() => (
{() => {
const currentLine = getFieldValue(["billlines", record.name]);
const invLen = currentLine?.inventories?.length ?? 0;
return (
<Space wrap>
<Button
disabled={disabled || getFieldValue("billlines")[record.fieldKey]?.inventories?.length > 0}
icon={<DeleteFilled />}
disabled={disabled || invLen > 0}
onClick={() => remove(record.name)}
>
<DeleteFilled />
</Button>
tabIndex={0}
/>
{Simple_Inventory.treatment === "on" && (
<BilllineAddInventory
disabled={!billEdit || form.isFieldsTouched() || form.getFieldValue("is_credit_memo")}
billline={getFieldValue("billlines")[record.fieldKey]}
billline={currentLine}
jobid={getFieldValue("jobid")}
/>
)}
</Space>
)}
);
}}
</Form.Item>
)
}
@@ -638,8 +653,9 @@ export function BillEnterModalLinesComponent({
size="small"
bordered
dataSource={fields}
rowKey="key"
columns={mergedColumns(remove)}
scroll={hasRows ? { x: "max-content" } : undefined} // <-- no scrollbar when empty
scroll={hasRows ? { x: "max-content" } : undefined}
pagination={false}
rowClassName="editable-row"
/>
@@ -649,12 +665,19 @@ export function BillEnterModalLinesComponent({
<Button
disabled={disabled}
onClick={() => {
const newIndex = fields.length;
add(
InstanceRenderManager({
imex: { applicable_taxes: { federal: true } },
rome: { applicable_taxes: { federal: false } }
})
);
setTimeout(() => {
const firstField = firstFieldRefs.current[newIndex];
if (firstField?.focus) {
firstField.focus();
}
}, 100);
}}
style={{ width: "100%" }}
>
@@ -694,11 +717,7 @@ const EditableCell = ({
const control = skipFormItem ? (
(formInput && formInput(record, record.name, propsFinal)) || children
) : (
<Form.Item
labelCol={{ span: 0 }}
{...propsFinal}
style={{ marginBottom: 0 }} // <-- important: remove default Form.Item margin
>
<Form.Item labelCol={{ span: 0 }} {...propsFinal} style={{ marginBottom: 0 }}>
{(formInput && formInput(record, record.name, propsFinal)) || children}
</Form.Item>
);
@@ -715,10 +734,7 @@ const EditableCell = ({
const { style: tdStyle, ...tdRest } = restProps;
const td = (
<td
{...tdRest}
style={{ ...tdStyle, verticalAlign: "middle" }} // optional but helps consistency
>
<td {...tdRest} style={{ ...tdStyle, verticalAlign: "middle" }}>
{cellInner}
</td>
);

View File

@@ -17,22 +17,35 @@ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
currentUser: selectCurrentUser
});
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
const mapDispatchToProps = () => ({});
export default connect(mapStateToProps, mapDispatchToProps)(BilllineAddInventory);
export function BilllineAddInventory({ currentUser, bodyshop, billline, disabled, jobid }) {
const [loading, setLoading] = useState(false);
const { billid } = queryString.parse(useLocation().search);
const qs = queryString.parse(useLocation().search);
const billid = qs?.billid != null ? String(qs.billid) : null;
const [insertInventoryLine] = useMutation(INSERT_INVENTORY_AND_CREDIT);
const notification = useNotification();
const inventoryCount = billline?.inventories?.length ?? 0;
const quantity = billline?.quantity ?? 0;
const addToInventory = async () => {
if (loading) return;
// Defensive: row identity can transiently desync during remove/add reindexing.
if (!billline) {
notification.error({
title: t("inventory.errors.inserting", { error: "Bill line is missing (please try again)." })
});
return;
}
setLoading(true);
//Check to make sure there are no existing items already in the inventory.
try {
const taxes = billline?.applicable_taxes ?? {};
const cm = {
vendorid: bodyshop.inhousevendorid,
invoice_number: "ih",
@@ -53,9 +66,9 @@ export function BilllineAddInventory({ currentUser, bodyshop, billline, disabled
cost_center: billline.cost_center,
deductedfromlbr: billline.deductedfromlbr,
applicable_taxes: {
local: billline.applicable_taxes.local,
state: billline.applicable_taxes.state,
federal: billline.applicable_taxes.federal
local: taxes.local,
state: taxes.state,
federal: taxes.federal
}
}
]
@@ -65,8 +78,7 @@ export function BilllineAddInventory({ currentUser, bodyshop, billline, disabled
const insertResult = await insertInventoryLine({
variables: {
joblineId: billline.joblineid === "noline" ? billline.id : billline.joblineid, //This will return null as there will be no jobline that has the id of the bill line.
//Unfortunately, we can't send null as the GQL syntax validation fails.
joblineId: billline.joblineid === "noline" ? billline.id : billline.joblineid,
joblineStatus: bodyshop.md_order_statuses.default_returned,
inv: {
shopid: bodyshop.id,
@@ -76,7 +88,7 @@ export function BilllineAddInventory({ currentUser, bodyshop, billline, disabled
quantity: billline.quantity,
line_desc: billline.line_desc
},
cm: { ...cm, billlines: { data: cm.billlines } }, //Fix structure for apollo insert.
cm: { ...cm, billlines: { data: cm.billlines } },
pol: {
returnfrombill: billid,
vendorid: bodyshop.inhousevendorid,
@@ -85,7 +97,6 @@ export function BilllineAddInventory({ currentUser, bodyshop, billline, disabled
data: [
{
line_desc: billline.line_desc,
act_price: billline.actual_price,
cost: billline.actual_cost,
quantity: billline.quantity,
@@ -106,7 +117,7 @@ export function BilllineAddInventory({ currentUser, bodyshop, billline, disabled
refetchQueries: ["QUERY_BILL_BY_PK"]
});
if (!insertResult.errors) {
if (!insertResult?.errors?.length) {
notification.success({
title: t("inventory.successes.inserted")
});
@@ -117,19 +128,26 @@ export function BilllineAddInventory({ currentUser, bodyshop, billline, disabled
})
});
}
} catch (err) {
notification.error({
title: t("inventory.errors.inserting", {
error: err?.message || String(err)
})
});
} finally {
setLoading(false);
}
};
return (
<Tooltip title={t("inventory.actions.addtoinventory")}>
<Button
icon={<FileAddFilled />}
loading={loading}
disabled={disabled || billline?.inventories?.length >= billline.quantity}
disabled={disabled || inventoryCount >= quantity}
onClick={addToInventory}
>
<FileAddFilled />
{billline?.inventories?.length > 0 && <div>({billline?.inventories?.length} in inv)</div>}
{inventoryCount > 0 && <div>({inventoryCount} in inv)</div>}
</Button>
</Tooltip>
);

View File

@@ -84,15 +84,14 @@ export function BillsListTableComponent({
}
});
}}
>
<FaTasks />
</Button>
icon={<FaTasks />}
/>
<BillDeleteButton bill={record} jobid={job.id} />
<BillDetailEditReturnComponent
data={{ bills_by_pk: { ...record, jobid: job.id, job: job } }}
disabled={record.is_credit_memo || record.vendorid === bodyshop.inhousevendorid || jobRO}
/>
{record.isinhouse && (
<PrintWrapperComponent
templateObject={{
@@ -190,9 +189,7 @@ export function BillsListTableComponent({
title={t("bills.labels.bills")}
extra={
<Space wrap>
<Button onClick={() => refetch()}>
<SyncOutlined />
</Button>
<Button onClick={() => refetch()} icon={<SyncOutlined />} />
{job && job.converted ? (
<>
<Button
@@ -235,6 +232,7 @@ export function BillsListTableComponent({
e.preventDefault();
setSearchText(e.target.value);
}}
enterButton
/>
</Space>
}

View File

@@ -41,9 +41,7 @@ export default function CABCpvrtCalculator({ disabled, form }) {
return (
<Popover destroyOnHidden content={popContent} open={visibility} disabled={disabled}>
<Button disabled={disabled} onClick={() => setVisibility(true)}>
<CalculatorFilled />
</Button>
<Button disabled={disabled} onClick={() => setVisibility(true)} icon={<CalculatorFilled />} />
</Popover>
);
}

View File

@@ -57,7 +57,6 @@ const CardPaymentModalComponent = ({
QUERY_RO_AND_OWNER_BY_JOB_PKS,
{
fetchPolicy: "network-only",
notifyOnNetworkStatusChange: true
}
);

View File

@@ -12,11 +12,16 @@ export function ChatAffixContainer({ bodyshop, chatVisible, currentUser }) {
const client = useApolloClient();
const { socket } = useSocket();
// 1) FCM subscription (independent of socket handler registration)
useEffect(() => {
if (!bodyshop?.messagingservicesid) return;
const messagingServicesId = bodyshop?.messagingservicesid;
const bodyshopId = bodyshop?.id;
const imexshopid = bodyshop?.imexshopid;
async function subscribeToTopicForFCMNotification() {
const messagingEnabled = Boolean(messagingServicesId);
useEffect(() => {
if (!messagingEnabled) return;
(async () => {
try {
await requestForToken();
await axios.post("/notifications/subscribe", {
@@ -24,23 +29,19 @@ export function ChatAffixContainer({ bodyshop, chatVisible, currentUser }) {
vapidKey: import.meta.env.VITE_APP_FIREBASE_PUBLIC_VAPID_KEY
}),
type: "messaging",
imexshopid: bodyshop.imexshopid
imexshopid
});
} catch (error) {
console.log("Error attempting to subscribe to messaging topic: ", error);
}
}
})();
}, [messagingEnabled, imexshopid]);
subscribeToTopicForFCMNotification();
}, [bodyshop?.messagingservicesid, bodyshop?.imexshopid]);
// 2) Register socket handlers as soon as socket is connected (regardless of chatVisible)
useEffect(() => {
if (!socket) return;
if (!bodyshop?.messagingservicesid) return;
if (!bodyshop?.id) return;
if (!messagingEnabled) return;
if (!bodyshopId) return;
// If socket isn't connected yet, ensure no stale handlers remain.
if (!socket.connected) {
unregisterMessagingHandlers({ socket });
return;
@@ -56,16 +57,14 @@ export function ChatAffixContainer({ bodyshop, chatVisible, currentUser }) {
bodyshop
});
return () => {
unregisterMessagingHandlers({ socket });
};
}, [socket, socket?.connected, bodyshop?.id, bodyshop?.messagingservicesid, client, currentUser?.email]);
return () => unregisterMessagingHandlers({ socket });
}, [socket, messagingEnabled, bodyshopId, client, currentUser?.email, bodyshop]);
if (!bodyshop?.messagingservicesid) return <></>;
if (!messagingEnabled) return null;
return (
<div className={`chat-affix ${chatVisible ? "chat-affix-open" : ""}`}>
{bodyshop?.messagingservicesid ? <ChatPopupComponent /> : null}
{messagingEnabled ? <ChatPopupComponent /> : null}
</div>
);
}

View File

@@ -128,7 +128,7 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
onClick={() => setSelectedConversation(item.id)}
className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`}
>
<Card style={getCardStyle()} variant={true} size="small" extra={cardExtra} title={cardTitle}>
<Card style={getCardStyle()} variant="outlined" size="small" extra={cardExtra} title={cardTitle}>
<div style={{ display: "inline-block", width: "70%", textAlign: "left" }}>{cardContentLeft}</div>
<div style={{ display: "inline-block", width: "30%", textAlign: "right" }}>{cardContentRight}</div>
</Card>

View File

@@ -1,22 +1,23 @@
import { Button } from "antd";
import parsePhoneNumber from "libphonenumber-js";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
import PhoneNumberFormatter from "../../utils/PhoneFormatter";
import { createStructuredSelector } from "reselect";
import { openChatByPhone } from "../../redux/messaging/messaging.actions";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { searchingForConversation } from "../../redux/messaging/messaging.selectors";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import PhoneNumberFormatter from "../../utils/PhoneFormatter";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
searchingForConversation: searchingForConversation
searchingForConversation
});
const mapDispatchToProps = (dispatch) => ({
openChatByPhone: (phone) => dispatch(openChatByPhone(phone))
openChatByPhone: (payload) => dispatch(openChatByPhone(payload))
});
export function ChatOpenButton({ bodyshop, searchingForConversation, phone, type, jobid, openChatByPhone }) {
@@ -24,31 +25,59 @@ export function ChatOpenButton({ bodyshop, searchingForConversation, phone, type
const { socket } = useSocket();
const notification = useNotification();
if (!phone) return <></>;
if (!phone) return null;
if (!bodyshop.messagingservicesid) {
return <PhoneNumberFormatter type={type}>{phone}</PhoneNumberFormatter>;
const messagingEnabled = Boolean(bodyshop?.messagingservicesid);
const parsed = useMemo(() => {
if (!messagingEnabled) return null;
try {
return parsePhoneNumber(phone, "CA") || null;
} catch {
return null;
}
}, [messagingEnabled, phone]);
return (
<a
href="# "
onClick={(e) => {
const isValid = Boolean(parsed?.isValid?.() && parsed.isValid());
const clickable = messagingEnabled && !searchingForConversation && isValid;
const onClick = useCallback(
(e) => {
e.preventDefault();
e.stopPropagation();
if (searchingForConversation) return; // Prevent finding the same thing twice.
if (!messagingEnabled) return;
if (searchingForConversation) return;
const p = parsePhoneNumber(phone, "CA");
if (p && p.isValid()) {
openChatByPhone({ phone_num: p.formatInternational(), jobid, socket });
} else {
if (!isValid) {
notification.error({ title: t("messaging.error.invalidphone") });
return;
}
}}
openChatByPhone({
phone_num: parsed.formatInternational(),
jobid,
socket
});
},
[messagingEnabled, searchingForConversation, isValid, parsed, jobid, socket, openChatByPhone, notification, t]
);
const content = <PhoneNumberFormatter type={type}>{phone}</PhoneNumberFormatter>;
// If not clickable, render plain formatted text (no link styling)
if (!clickable) return content;
// Clickable: render as a link-styled button (best for a “command”)
return (
<Button
type="link"
onClick={onClick}
className="chat-open-button-link"
aria-label={t("messaging.actions.openchat") || "Open chat"}
>
<PhoneNumberFormatter type={type}>{phone}</PhoneNumberFormatter>
</a>
{content}
</Button>
);
}

View File

@@ -47,7 +47,6 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
const [getConversations, { loading, data, refetch, called }] = useLazyQuery(CONVERSATION_LIST_QUERY, {
fetchPolicy: "network-only",
nextFetchPolicy: "network-only",
notifyOnNetworkStatusChange: true,
...(pollInterval > 0 ? { pollInterval } : {})
});
@@ -108,9 +107,12 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
hasLoadedConversationsOnceRef.current = true;
getConversations({ variables: { offset: 0 } }).catch((err) => {
// Ignore abort errors (they're expected when component unmounts)
if (err?.name !== "AbortError") {
console.error(`Error fetching conversations: ${err?.message || ""}`, err);
}
});
}, [getConversations]);
}, []);
const handleManualRefresh = async () => {
try {

View File

@@ -99,6 +99,7 @@ export default function ContractsCarsComponent({ loading, data, selectedCarId, h
placeholder={t("general.labels.search")}
value={state.search}
onChange={(e) => setState({ ...state, search: e.target.value })}
enterButton
/>
}
>

View File

@@ -1,5 +1,5 @@
import { WarningFilled } from "@ant-design/icons";
import { Form, Input, InputNumber, Space } from "antd";
import { Card, Form, Input, InputNumber, Space } from "antd";
import { useTranslation } from "react-i18next";
import { DateFormatter } from "../../utils/DateFormatter";
import dayjs from "../../utils/day";
@@ -19,9 +19,9 @@ import ContractFormJobPrefill from "./contract-form-job-prefill.component";
export default function ContractFormComponent({ form, create = false, selectedJobState, selectedCar }) {
const { t } = useTranslation();
return (
<>
<Card>
{!create && <FormFieldsChanged form={form} />}
<LayoutFormRow>
<LayoutFormRow noDivider={true}>
{!create && (
<Form.Item
label={t("contracts.fields.status")}
@@ -310,6 +310,6 @@ export default function ContractFormComponent({ form, create = false, selectedJo
<InputNumber precision={2} />
</Form.Item>
</LayoutFormRow>
</>
</Card>
);
}

View File

@@ -123,6 +123,7 @@ export default function ContractsJobsComponent({ loading, data, selectedJob, han
placeholder={t("general.labels.search")}
value={state.search}
onChange={(e) => setState({ ...state, search: e.target.value })}
enterButton
/>
}
>

View File

@@ -156,15 +156,15 @@ export function ContractsList({ bodyshop, loading, contracts, refetch, total, se
</>
)}
<Button onClick={() => setContractFinderContext()}>{t("contracts.actions.find")}</Button>
<Button onClick={() => refetch()}>
<SyncOutlined />
</Button>
<Button onClick={() => refetch()} icon={<SyncOutlined />} />
<Input.Search
placeholder={search.searh || t("general.labels.search")}
onSearch={(value) => {
const updatedSearch = { ...search, search: value };
history({ search: queryString.stringify(updatedSearch) });
}}
enterButton
/>
</Space>
}

View File

@@ -1,6 +1,6 @@
import { WarningFilled } from "@ant-design/icons";
import { useApolloClient } from "@apollo/client/react";
import { Button, Form, Input, InputNumber, Space } from "antd";
import { Button, Card, Form, Input, InputNumber, Space } from "antd";
import { PageHeader } from "@ant-design/pro-layout";
import dayjs from "../../utils/day";
import { useTranslation } from "react-i18next";
@@ -19,7 +19,7 @@ export default function CourtesyCarCreateFormComponent({ form, saveLoading, newC
const client = useApolloClient();
return (
<div>
<Card>
<PageHeader
title={t("menus.header.courtesycars")}
extra={
@@ -314,6 +314,6 @@ export default function CourtesyCarCreateFormComponent({ form, saveLoading, newC
<CurrencyInput />
</Form.Item>
</LayoutFormRow>
</div>
</Card>
);
}

View File

@@ -255,9 +255,8 @@ export default function CourtesyCarsList({ loading, courtesycars, refetch }) {
title={t("menus.header.courtesycars")}
extra={
<Space wrap>
<Button onClick={() => refetch()}>
<SyncOutlined />
</Button>
<Button onClick={() => refetch()} icon={<SyncOutlined />} />
<Dropdown trigger="click" menu={menu}>
<Button>{t("general.labels.print")}</Button>
</Dropdown>

View File

@@ -85,13 +85,7 @@ export default function CsiResponseListPaginated({ refetch, loading, responses,
};
return (
<Card
extra={
<Button onClick={() => refetch()}>
<SyncOutlined />
</Button>
}
>
<Card extra={<Button onClick={() => refetch()} icon={<SyncOutlined />} />}>
<Table
loading={loading}
pagination={{ placement: "top", pageSize: pageLimit, current: parseInt(state.page || 1), total: total }}

View File

@@ -96,6 +96,7 @@ export default function DashboardMonthlyJobCosting({ data, ...cardProps }) {
e.preventDefault();
setSearchText(e.target.value);
}}
enterButton
/>
</Space>
}

View File

@@ -36,7 +36,7 @@ export function DashboardTotalProductionHours({ bodyshop, data, ...cardProps })
<Statistic
title={t("dashboard.labels.prodhrs")}
value={hours.total.toFixed(1)}
styles={{ value: { color: aboveTargetHours ? "green" : "red" } }}
styles={{ content: { color: aboveTargetHours ? "green" : "red" } }}
/>
</Space>
</Card>

View File

@@ -196,9 +196,7 @@ export function DashboardGridComponent({ currentUser }) {
<PageHeader
extra={
<Space>
<Button onClick={() => refetch()}>
<SyncOutlined />
</Button>
<Button onClick={() => refetch()} icon={<SyncOutlined />} />
<Dropdown menu={menu} trigger={["click"]}>
<Button>{t("dashboard.actions.addcomponent")}</Button>
</Dropdown>

View File

@@ -1,6 +1,6 @@
import { SyncOutlined } from "@ant-design/icons";
import { Button, Card, Form, Input, Table } from "antd";
import { useEffect, useState, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -111,9 +111,8 @@ export function DmsAllocationsSummaryAp({ socket, bodyshop, billids, title }) {
onClick={() => {
socket.emit("pbs-calculate-allocations-ap", billids, (ack) => setAllocationsSummary(ack));
}}
>
<SyncOutlined />
</Button>
icon={<SyncOutlined />}
/>
}
>
<Table

View File

@@ -1,6 +1,6 @@
import { Alert, Button, Card, Table, Typography } from "antd";
import { SyncOutlined } from "@ant-design/icons";
import { useCallback, useEffect, useState, useRef } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -110,11 +110,7 @@ export function DmsAllocationsSummary({ mode, socket, bodyshop, jobId, title, on
return (
<Card
title={title}
extra={
<Button onClick={fetchAllocations} aria-label={t("general.actions.refresh")}>
<SyncOutlined />
</Button>
}
extra={<Button onClick={fetchAllocations} aria-label={t("general.actions.refresh")} icon={<SyncOutlined />} />}
>
{bodyshop.pbs_configuration?.disablebillwip && (
<Alert type="warning" title={t("jobs.labels.dms.disablebillwip")} />

View File

@@ -1,6 +1,6 @@
import { Alert, Button, Card, Table, Tabs, Typography } from "antd";
import { SyncOutlined } from "@ant-design/icons";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
@@ -329,11 +329,7 @@ export function RrAllocationsSummary({ socket, bodyshop, jobId, title, onAllocat
return (
<Card
title={title}
extra={
<Button onClick={fetchAllocations} aria-label={t("general.actions.refresh")}>
<SyncOutlined />
</Button>
}
extra={<Button onClick={fetchAllocations} aria-label={t("general.actions.refresh")} icon={<SyncOutlined />} />}
>
{bodyshop.pbs_configuration?.disablebillwip && (
<Alert type="warning" title={t("jobs.labels.dms.disablebillwip")} />

View File

@@ -49,12 +49,15 @@ export function DmsCdkVehicles({ form, job }) {
open={open}
onCancel={() => setOpen(false)}
onOk={() => {
if (selectedModel) {
form.setFieldsValue({
dms_make: selectedModel.makecode,
dms_model: selectedModel.modelcode
});
setOpen(false);
}
}}
okButtonProps={{ disabled: !selectedModel }}
>
{error && <AlertComponent title={error.message} type="error" />}
<Table
@@ -62,6 +65,7 @@ export function DmsCdkVehicles({ form, job }) {
<Input.Search
onSearch={(val) => callSearch({ variables: { search: val } })}
placeholder={t("general.labels.search")}
enterButton
/>
)}
columns={columns}

View File

@@ -23,13 +23,13 @@ export default connect(mapStateToProps, mapDispatchToProps)(DmsCustomerSelector)
* @constructor
*/
export function DmsCustomerSelector(props) {
const { bodyshop, jobid, socket, rrOptions = {} } = props;
const { bodyshop, jobid, job, socket, rrOptions = {} } = props;
// Centralized "mode" (provider + transport)
const mode = props.mode;
// Stable base props for children
const base = useMemo(() => ({ bodyshop, jobid, socket }), [bodyshop, jobid, socket]);
const base = useMemo(() => ({ bodyshop, jobid, job, socket }), [bodyshop, jobid, job, socket]);
switch (mode) {
case DMS_MAP.reynolds: {

View File

@@ -53,13 +53,13 @@ export default function FortellisCustomerSelector({ bodyshop, jobid, socket }) {
render: (_t, r) => <Checkbox disabled checked={r.vinOwner} />
},
{
title: t("jobs.fields.dms.name1"),
title: t("jobs.fields.dms.first_name"),
dataIndex: ["customerName", "firstName"],
key: "firstName",
sorter: (a, b) => alphaSort(a.customerName?.firstName, b.customerName?.firstName)
},
{
title: t("jobs.fields.dms.name1"),
title: t("jobs.fields.dms.last_name"),
dataIndex: ["customerName", "lastName"],
key: "lastName",
sorter: (a, b) => alphaSort(a.customerName?.lastName, b.customerName?.lastName)

View File

@@ -1,4 +1,4 @@
import { Alert, Button, Checkbox, Col, message, Space, Table } from "antd";
import { Alert, Button, Checkbox, message, Modal, Space, Table } from "antd";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { alphaSort } from "../../utils/sorters";
@@ -47,6 +47,7 @@ const rrAddressToString = (addr) => {
export default function RRCustomerSelector({
jobid,
socket,
job,
rrOpenRoLimit = false,
onRrOpenRoFinished,
rrValidationPending = false,
@@ -59,15 +60,26 @@ export default function RRCustomerSelector({
const [refreshing, setRefreshing] = useState(false);
// Show dialog automatically when validation is pending
// BUT: skip this for early RO flow (job already has dms_id)
useEffect(() => {
if (rrValidationPending) setOpen(true);
}, [rrValidationPending]);
if (rrValidationPending && !job?.dms_id) {
setOpen(true);
}
}, [rrValidationPending, job?.dms_id]);
// Listen for RR customer selection list
useEffect(() => {
if (!socket) return;
const handleRrSelectCustomer = (list) => {
const normalized = normalizeRrList(list);
// If list is empty, it means early RO exists and customer selection should be skipped
// Don't open the modal in this case
if (normalized.length === 0) {
setRefreshing(false);
return;
}
setOpen(true);
setCustomerList(normalized);
const firstOwner = normalized.find((r) => r.vinOwner)?.custNo;
@@ -127,6 +139,10 @@ export default function RRCustomerSelector({
});
};
const handleClose = () => {
setOpen(false);
};
const refreshRrSearch = () => {
setRefreshing(true);
const to = setTimeout(() => setRefreshing(false), 12000);
@@ -141,8 +157,6 @@ export default function RRCustomerSelector({
socket.emit("rr-export-job", { jobId: jobid });
};
if (!open) return null;
const columns = [
{ title: t("jobs.fields.dms.id"), dataIndex: "custNo", key: "custNo" },
{
@@ -169,8 +183,45 @@ export default function RRCustomerSelector({
return !rrOwnerSet.has(String(record.custNo));
};
// For early RO flow: show validation banner even when modal is closed
if (!open) {
if (rrValidationPending && job?.dms_id) {
return (
<Col span={24}>
<div style={{ marginBottom: 16 }}>
<Alert
type="info"
showIcon
title="Complete Validation in Reynolds"
description={
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<div>
We created the Repair Order. Please validate the totals and taxes in the DMS system. When done,
click <strong>Finished</strong> to finalize and mark this export as complete.
</div>
<div>
<Space>
<Button type="primary" onClick={onValidationFinished}>
Finished
</Button>
</Space>
</div>
</div>
}
/>
</div>
);
}
return null;
}
return (
<Modal
open={open}
onCancel={handleClose}
footer={null}
width={800}
title={t("dms.selectCustomer")}
>
<Table
title={() => (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
@@ -196,8 +247,8 @@ export default function RRCustomerSelector({
/>
)}
{/* Validation step banner */}
{rrValidationPending && (
{/* Validation step banner - only show for NON-early RO flow (legacy) */}
{rrValidationPending && !job?.dms_id && (
<Alert
type="info"
showIcon
@@ -262,6 +313,6 @@ export default function RRCustomerSelector({
getCheckboxProps: (record) => ({ disabled: rrDisableRow(record) })
}}
/>
</Col>
</Modal>
);
}

View File

@@ -69,7 +69,7 @@ export function DmsLogEvents({
return {
key: idx,
color: logLevelColor(level),
children: (
content: (
<Space orientation="vertical" size={4} style={{ display: "flex" }}>
{/* Row 1: summary + inline "Details" toggle */}
<Space wrap align="start">
@@ -113,7 +113,7 @@ export function DmsLogEvents({
[logs, openSet, colorizeJson, isDarkMode, showDetails]
);
return <Timeline pending reverse items={items} />;
return <Timeline reverse items={items} />;
}
/**

View File

@@ -272,7 +272,7 @@ export default function CdkLikePostForm({ bodyshop, socket, job, logsRef, mode,
name={[field.name, "name"]}
rules={[{ required: true }]}
>
<Select style={{ minWidth: "15rem" }} onSelect={(value) => handlePayerSelect(value, index)}>
<Select style={{ width: "100%" }} onSelect={(value) => handlePayerSelect(value, index)}>
{bodyshop.cdk_configuration?.payers?.map((payer) => (
<Select.Option key={payer.name}>{payer.name}</Select.Option>
))}
@@ -404,7 +404,7 @@ export default function CdkLikePostForm({ bodyshop, socket, job, logsRef, mode,
<Typography.Title>=</Typography.Title>
<Statistic
title={t("jobs.labels.dms.notallocated")}
styles={{ value: { color: discrep.getAmount() === 0 ? "green" : "red" } }}
styles={{ content: { color: discrep.getAmount() === 0 ? "green" : "red" } }}
value={discrep.toFormat()}
/>
<Button disabled={disablePost} htmlType="submit">

View File

@@ -208,8 +208,18 @@ export default function RRPostForm({
});
};
// Check if early RO was created (job has all early RO fields)
const hasEarlyRO = !!(job?.dms_id && job?.dms_customer_id && job?.dms_advisor_id);
return (
<Card title={t("jobs.labels.dms.postingform")}>
{hasEarlyRO && (
<Typography.Paragraph type="success" strong style={{ marginBottom: 16 }}>
{t("jobs.labels.dms.earlyro.created")} {job.dms_id}
<br />
<Typography.Text type="secondary">{t("jobs.labels.dms.earlyro.willupdate")}</Typography.Text>
</Typography.Paragraph>
)}
<Form
form={form}
layout="vertical"
@@ -218,7 +228,8 @@ export default function RRPostForm({
initialValues={initialValues}
>
<Row gutter={[16, 12]} align="bottom">
{/* Advisor + inline Refresh */}
{/* Advisor + inline Refresh - Only show if no early RO */}
{!hasEarlyRO && (
<Col xs={24} sm={24} md={12} lg={8}>
<Form.Item label={t("jobs.fields.dms.advisor")} required>
<Space.Compact block>
@@ -254,8 +265,10 @@ export default function RRPostForm({
</Space.Compact>
</Form.Item>
</Col>
)}
{/* RR OpCode (prefix / base / suffix) */}
{/* RR OpCode (prefix / base / suffix) - Only show if no early RO */}
{!hasEarlyRO && (
<Col xs={24} sm={12} md={12} lg={8}>
<Form.Item
required
@@ -285,11 +298,7 @@ export default function RRPostForm({
placeholder={t("jobs.fields.dms.rr_opcode_prefix", "Prefix")}
/>
</Form.Item>
<Form.Item
name="opBase"
noStyle
rules={[{ required: true, message: t("general.validation.required") }]}
>
<Form.Item name="opBase" noStyle rules={[{ required: true }]}>
<Input
allowClear
maxLength={10}
@@ -308,6 +317,7 @@ export default function RRPostForm({
</Space.Compact>
</Form.Item>
</Col>
)}
<Col xs={12} sm={8} md={6} lg={4}>
<Form.Item name="kmin" label={t("jobs.fields.kmin")} initialValue={job?.kmin} rules={[{ required: true }]}>
@@ -355,13 +365,14 @@ export default function RRPostForm({
{/* Validation */}
<Form.Item shouldUpdate>
{() => {
const advisorOk = !!form.getFieldValue("advisorNo");
// When early RO exists, advisor is already set, so we don't need to validate it
const advisorOk = hasEarlyRO ? true : !!form.getFieldValue("advisorNo");
return (
<Space size="large" wrap align="center">
<Statistic title={t("jobs.labels.subtotal")} value={totals.totalSale.toFormat()} />
<Typography.Title>=</Typography.Title>
<Button disabled={!advisorOk} htmlType="submit">
{t("jobs.actions.dms.post")}
<Button disabled={!advisorOk} htmlType="submit" type={hasEarlyRO ? "default" : "primary"}>
{hasEarlyRO ? t("jobs.actions.dms.update_ro") : t("jobs.actions.dms.post")}
</Button>
</Space>
);

View File

@@ -0,0 +1,367 @@
import { ReloadOutlined } from "@ant-design/icons";
import { Alert, Button, Form, Input, InputNumber, Modal, Radio, Select, Space, Table, Typography } from "antd";
import { useEffect, useMemo, useState } from "react";
// Simple customer selector table
function CustomerSelectorTable({ customers, onSelect, isSubmitting }) {
const [selectedCustNo, setSelectedCustNo] = useState(null);
const columns = [
{
title: "Select",
key: "select",
width: 80,
render: (_, record) => (
<Radio checked={selectedCustNo === record.custNo} onChange={() => setSelectedCustNo(record.custNo)} />
)
},
{ title: "Customer ID", dataIndex: "custNo", key: "custNo" },
{ title: "Name", dataIndex: "name", key: "name" },
{
title: "VIN Owner",
key: "vinOwner",
render: (_, record) => (record.vinOwner || record.isVehicleOwner ? "Yes" : "No")
}
];
return (
<div>
<Table columns={columns} dataSource={customers} rowKey="custNo" pagination={false} size="small" />
<div style={{ marginTop: 16, display: "flex", gap: 8 }}>
<Button
type="primary"
onClick={() => onSelect(selectedCustNo, false)}
disabled={!selectedCustNo || isSubmitting}
loading={isSubmitting}
>
Use Selected Customer
</Button>
<Button onClick={() => onSelect(null, true)} disabled={isSubmitting} loading={isSubmitting}>
Create New Customer
</Button>
</div>
</div>
);
}
/**
* RR Early RO Creation Form
* Used from convert button or admin page to create minimal RO before full export
* @param bodyshop
* @param socket
* @param job
* @param onSuccess - callback when RO is created successfully
* @param onCancel - callback to close modal
* @param showCancelButton - whether to show cancel button
* @returns {JSX.Element}
* @constructor
*/
export default function RREarlyROForm({ bodyshop, socket, job, onSuccess, onCancel, showCancelButton = true }) {
const [form] = Form.useForm();
// Advisors
const [advisors, setAdvisors] = useState([]);
const [advLoading, setAdvLoading] = useState(false);
// Customer selection
const [customerCandidates, setCustomerCandidates] = useState([]);
const [showCustomerSelector, setShowCustomerSelector] = useState(false);
// Loading and success states
const [isSubmitting, setIsSubmitting] = useState(false);
const [earlyRoCreated, setEarlyRoCreated] = useState(!!job?.dms_id);
const [createdRoNumber, setCreatedRoNumber] = useState(job?.dms_id || null);
// Derive default OpCode parts from bodyshop config (matching dms.container.jsx logic)
const initialValues = useMemo(() => {
const cfg = bodyshop?.rr_configuration || {};
const defaults =
cfg.opCodeDefault ||
cfg.op_code_default ||
cfg.op_codes?.default ||
cfg.defaults?.opCode ||
cfg.defaults ||
cfg.default ||
{};
const prefix = defaults.prefix ?? defaults.opCodePrefix ?? "";
const base = defaults.base ?? defaults.opCodeBase ?? "";
const suffix = defaults.suffix ?? defaults.opCodeSuffix ?? "";
return {
kmin: job?.kmin || 0,
opPrefix: prefix,
opBase: base,
opSuffix: suffix
};
}, [bodyshop, job]);
const getAdvisorNumber = (a) => a?.advisorId;
const getAdvisorLabel = (a) => `${a?.firstName || ""} ${a?.lastName || ""}`.trim();
const fetchRrAdvisors = (refresh = false) => {
if (!socket) return;
setAdvLoading(true);
const onResult = (payload) => {
try {
const list = payload?.result ?? payload ?? [];
setAdvisors(Array.isArray(list) ? list : []);
} finally {
setAdvLoading(false);
socket.off("rr-get-advisors:result", onResult);
}
};
socket.once("rr-get-advisors:result", onResult);
socket.emit("rr-get-advisors", { departmentType: "B", refresh }, (ack) => {
if (ack?.ok) {
const list = ack.result ?? [];
setAdvisors(Array.isArray(list) ? list : []);
} else if (ack) {
console.error("Error fetching RR Advisors:", ack.error);
}
setAdvLoading(false);
socket.off("rr-get-advisors:result", onResult);
});
};
useEffect(() => {
fetchRrAdvisors(false);
}, [bodyshop?.id, socket]);
const handleStartEarlyRO = async (values) => {
if (!socket) {
console.error("Socket not available");
return;
}
setIsSubmitting(true);
const txEnvelope = {
advisorNo: values.advisorNo,
story: values.story || "",
kmin: values.kmin || job?.kmin || 0,
opPrefix: values.opPrefix || "",
opBase: values.opBase || "",
opSuffix: values.opSuffix || ""
};
// Emit the early RO creation request
socket.emit("rr-create-early-ro", {
jobId: job.id,
txEnvelope
});
// Wait for customer selection
const customerListener = (candidates) => {
console.log("Received rr-select-customer event with candidates:", candidates);
setCustomerCandidates(candidates || []);
setShowCustomerSelector(true);
setIsSubmitting(false);
socket.off("rr-select-customer", customerListener);
};
socket.once("rr-select-customer", customerListener);
// Handle failures
const failureListener = (payload) => {
if (payload?.jobId === job.id) {
console.error("Early RO creation failed:", payload.error);
alert(`Failed to create early RO: ${payload.error}`);
setIsSubmitting(false);
setShowCustomerSelector(false);
socket.off("export-failed", failureListener);
socket.off("rr-select-customer", customerListener);
}
};
socket.once("export-failed", failureListener);
};
const handleCustomerSelected = (custNo, createNew = false) => {
if (!socket) return;
console.log("handleCustomerSelected called:", { custNo, createNew, custNoType: typeof custNo });
setIsSubmitting(true);
setShowCustomerSelector(false);
const payload = {
jobId: job.id,
custNo: createNew ? null : custNo,
create: createNew
};
console.log("Emitting rr-early-customer-selected:", payload);
// Emit customer selection
socket.emit("rr-early-customer-selected", payload, (ack) => {
console.log("Received ack from rr-early-customer-selected:", ack);
setIsSubmitting(false);
if (ack?.ok) {
const roNumber = ack.dmsRoNo || ack.outsdRoNo;
setEarlyRoCreated(true);
setCreatedRoNumber(roNumber);
onSuccess?.({ roNumber, ...ack });
} else {
alert(`Failed to create early RO: ${ack?.error || "Unknown error"}`);
}
});
// Also listen for socket events
const successListener = (payload) => {
if (payload?.jobId === job.id) {
const roNumber = payload.dmsRoNo || payload.outsdRoNo;
console.log("Early RO created:", roNumber);
socket.off("rr-early-ro-created", successListener);
socket.off("export-failed", failureListener);
}
};
const failureListener = (payload) => {
if (payload?.jobId === job.id) {
console.error("Early RO creation failed:", payload.error);
setIsSubmitting(false);
setEarlyRoCreated(false);
socket.off("rr-early-ro-created", successListener);
socket.off("export-failed", failureListener);
}
};
socket.once("rr-early-ro-created", successListener);
socket.once("export-failed", failureListener);
};
// If early RO already created, show success message
if (earlyRoCreated) {
return (
<Alert
title="Early Reynolds RO Created"
description={`RO Number: ${createdRoNumber || "N/A"} - You can now convert the job.`}
type="success"
showIcon
style={{ marginBottom: 16 }}
/>
);
}
// If showing customer selector, render modal
if (showCustomerSelector) {
return (
<>
<Typography.Title level={5}>Create Early Reynolds RO</Typography.Title>
<Typography.Paragraph type="secondary">Waiting for customer selection...</Typography.Paragraph>
<Modal
title="Select Customer for Early RO"
open={true}
width={800}
footer={null}
onCancel={() => {
setShowCustomerSelector(false);
setIsSubmitting(false);
}}
>
<CustomerSelectorTable
customers={customerCandidates}
onSelect={handleCustomerSelected}
isSubmitting={isSubmitting}
/>
</Modal>
</>
);
}
// Handle manual submit (since we can't nest forms)
const handleManualSubmit = async () => {
try {
const values = await form.validateFields();
handleStartEarlyRO(values);
} catch (error) {
console.error("Validation failed:", error);
}
};
// Show the form
return (
<div style={{ marginTop: 16 }}>
<Typography.Title level={5}>Create Early Reynolds RO</Typography.Title>
<Typography.Paragraph type="secondary" style={{ fontSize: "12px" }}>
Complete this section to create a minimal RO in Reynolds before converting the job.
</Typography.Paragraph>
<Form form={form} layout="vertical" component={false} initialValues={initialValues}>
<Form.Item name="advisorNo" label="Advisor" rules={[{ required: true, message: "Please select an advisor" }]}>
<Select
showSearch={{
optionFilterProp: "children",
filterOption: (input, option) => (option?.children?.toLowerCase() ?? "").includes(input.toLowerCase())
}}
loading={advLoading}
placeholder="Select advisor..."
popupRender={(menu) => (
<>
{menu}
<Button
type="link"
icon={<ReloadOutlined />}
onClick={() => fetchRrAdvisors(true)}
style={{ width: "100%", textAlign: "left" }}
>
Refresh Advisors
</Button>
</>
)}
>
{advisors.map((adv) => (
<Select.Option key={getAdvisorNumber(adv)} value={getAdvisorNumber(adv)}>
{getAdvisorLabel(adv)}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
name="kmin"
label="Mileage In"
rules={[
{ required: true, message: "Please enter initial mileage" },
{ type: "number", min: 1, message: "Mileage must be greater than 0" }
]}
>
<InputNumber min={1} style={{ width: "100%" }} />
</Form.Item>
{/* RR OpCode (prefix / base / suffix) */}
<Form.Item required label="RR OpCode">
<Space.Compact block>
<Form.Item name="opPrefix" noStyle>
<Input allowClear maxLength={4} style={{ width: "30%" }} placeholder="Prefix" />
</Form.Item>
<Form.Item name="opBase" noStyle rules={[{ required: true, message: "Base Required" }]}>
<Input allowClear maxLength={10} style={{ width: "40%" }} placeholder="Base" />
</Form.Item>
<Form.Item name="opSuffix" noStyle>
<Input allowClear maxLength={4} style={{ width: "30%" }} placeholder="Suffix" />
</Form.Item>
</Space.Compact>
</Form.Item>
<Form.Item name="story" label="Comments / Story (Optional)">
<Input.TextArea rows={2} maxLength={240} showCount placeholder="Enter comments or story..." />
</Form.Item>
<div style={{ marginTop: 16 }}>
<Space>
<Button type="primary" onClick={handleManualSubmit} loading={isSubmitting} disabled={advLoading}>
Create Early RO
</Button>
{showCancelButton && <Button onClick={onCancel}>Cancel</Button>}
</Space>
</div>
</Form>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { Modal } from "antd";
import RREarlyROForm from "./rr-early-ro-form";
/**
* Modal wrapper for RR Early RO Creation Form
* @param open - boolean to control modal visibility
* @param onClose - callback when modal is closed
* @param onSuccess - callback when RO is created successfully
* @param bodyshop - bodyshop object
* @param socket - socket.io connection
* @param job - job object
* @returns {JSX.Element}
* @constructor
*/
export default function RREarlyROModal({ open, onClose, onSuccess, bodyshop, socket, job }) {
const handleSuccess = (result) => {
onSuccess?.(result);
onClose?.();
};
return (
<Modal
open={open}
onCancel={onClose}
footer={null}
width={700}
destroyOnHidden
title="Create Reynolds Repair Order"
>
<RREarlyROForm bodyshop={bodyshop} socket={socket} job={job} onSuccess={handleSuccess} onCancel={onClose} />
</Modal>
);
}

View File

@@ -14,8 +14,11 @@ export default function GlobalSearch() {
const [callSearch, { loading, error, data }] = useLazyQuery(GLOBAL_SEARCH_QUERY);
const navigate = useNavigate();
const executeSearch = (v) => {
if (v && v.variables.search && v.variables.search !== "" && v.variables.search.length >= 3) callSearch(v);
const executeSearch = (variables) => {
if (variables?.search !== "" && variables?.search?.length >= 3)
callSearch({
variables
});
};
const debouncedExecuteSearch = _.debounce(executeSearch, 750);
@@ -157,7 +160,9 @@ export default function GlobalSearch() {
return (
<AutoComplete
options={options}
onSearch={handleSearch}
showSearch={{
onSearch: handleSearch
}}
defaultActiveFirstOption
onKeyDown={(e) => {
if (e.key !== "Enter") return;

View File

@@ -53,9 +53,7 @@ export default function InventoryLineDelete({ inventoryline, disabled, refetch }
onConfirm={handleDelete}
title={t("inventory.labels.deleteconfirm")}
>
<Button disabled={disabled || inventoryline.consumedbybillid} loading={loading}>
<DeleteFilled />
</Button>
<Button disabled={disabled || inventoryline.consumedbybillid} loading={loading} icon={<DeleteFilled />} />
</Popconfirm>
</RbacWrapper>
);

View File

@@ -110,9 +110,9 @@ export function JobsList({ refetch, loading, jobs, total, setInventoryUpsertCont
}
});
}}
>
<EditFilled />
</Button>
icon={<EditFilled />}
/>
<InventoryLineDelete inventoryline={record} refetch={refetch} />
</Space>
)
@@ -155,9 +155,9 @@ export function JobsList({ refetch, loading, jobs, total, setInventoryUpsertCont
context: {}
});
}}
>
<FileAddFilled />
</Button>
icon={<FileAddFilled />}
/>
<Button
onClick={() => {
const updatedSearch = { ...search };
@@ -172,9 +172,8 @@ export function JobsList({ refetch, loading, jobs, total, setInventoryUpsertCont
{search.showall ? t("inventory.labels.showavailable") : t("inventory.labels.showall")}
</Button>
<Button onClick={() => refetch()}>
<SyncOutlined />
</Button>
<Button onClick={() => refetch()} icon={<SyncOutlined />} />
<Input.Search
placeholder={search.search || t("general.labels.search")}
onSearch={(value) => {

View File

@@ -97,7 +97,7 @@ export function Jobd3RdPartyModal({ bodyshop, jobId, job, technician }) {
return (
<>
<Button onClick={showModal}>{t("printcenter.jobs.3rdpartypayer")}</Button>
<Modal open={isModalVisible} onOk={handleOk} onCancel={handleCancel}>
<Modal open={isModalVisible} onOk={handleOk} onCancel={handleCancel} getContainer={() => document.body}>
<Form onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}>
<Form.Item label={t("bills.fields.vendor")} name="vendorid">
<VendorSearchSelect options={VendorAutoCompleteData?.vendors} onSelect={handleVendorSelect} />

View File

@@ -61,9 +61,7 @@ export function ScheduleEventNote({ event }) {
) : (
<Input.TextArea rows={3} value={note} onChange={(e) => setNote(e.target.value)} style={{ maxWidth: "8vw" }} />
)}
<Button onClick={toggleEdit} loading={loading}>
{editing ? <SaveFilled /> : <EditFilled />}
</Button>
<Button onClick={toggleEdit} loading={loading} icon={editing ? <SaveFilled /> : <EditFilled />} />
</Space>
</DataLabel>
);

View File

@@ -159,9 +159,8 @@ export function JobAuditTrail({ bodyshop, jobId }) {
onClick={() => {
refetch();
}}
>
<SyncOutlined />
</Button>
icon={<SyncOutlined />}
/>
}
>
<Table loading={loading} columns={columns} rowKey="id" dataSource={data ? data.audit_trail : []} />

View File

@@ -61,9 +61,12 @@ export default function JobIntakeTemplateList({ templates }) {
renderItem={(template) => (
<List.Item
actions={[
<Button key="checkListTemplateButton" loading={loading} onClick={() => renderTemplate(template)}>
<PrinterFilled />
</Button>
<Button
key="checkListTemplateButton"
loading={loading}
onClick={() => renderTemplate(template)}
icon={<PrinterFilled />}
/>
]}
>
<List.Item.Meta

View File

@@ -76,6 +76,7 @@ export default function JobCostingPartsTable({ data, summaryData }) {
e.preventDefault();
setSearchText(e.target.value);
}}
enterButton
/>
</Space>
);

View File

@@ -50,7 +50,8 @@ export function JobCreateIOU({ bodyshop, currentUser, job, selectedJobLines, tec
config: {
status: bodyshop.md_ro_statuses.default_open,
bodyshopid: bodyshop.id,
useremail: currentUser.email
useremail: currentUser.email,
timezone: bodyshop.timezone
},
currentUser
});

View File

@@ -395,9 +395,8 @@ export function JobLinesComponent({
context: { ...record, jobid: job.id }
});
}}
>
<EditFilled />
</Button>
icon={<EditFilled />}
/>
)}
<Button
title={t("tasks.buttons.create")}
@@ -409,9 +408,9 @@ export function JobLinesComponent({
}
});
}}
>
<FaTasks />
</Button>
icon={<FaTasks />}
/>
{(record.manual_line || jobIsPrivate) && !technician && (
<Button
disabled={jobRO}
@@ -431,9 +430,8 @@ export function JobLinesComponent({
await axios.post("/job/totalsssu", { id: job.id });
if (refetch) refetch();
}}
>
<DeleteFilled />
</Button>
icon={<DeleteFilled />}
/>
)}
</Space>
)
@@ -542,9 +540,7 @@ export function JobLinesComponent({
title={t("jobs.labels.estimatelines")}
extra={
<Space wrap>
<Button onClick={() => refetch()}>
<SyncOutlined />
</Button>
<Button onClick={() => refetch()} icon={<SyncOutlined />} />
{/* Bulk Update Location */}
<Button
@@ -609,8 +605,8 @@ export function JobLinesComponent({
setSelectedLines([]);
}}
icon={<HomeOutlined />}
>
<HomeOutlined />
{t("parts.actions.orderinhouse")}
{selectedLines.length > 0 && ` (${selectedLines.length})`}
</Button>
@@ -641,6 +637,7 @@ export function JobLinesComponent({
{!isPartsEntry && (
<Button
icon={<FilterFilled />}
id="job-lines-filter-parts-only-button"
onClick={() => {
setState((state) => ({
@@ -652,7 +649,7 @@ export function JobLinesComponent({
}));
}}
>
<FilterFilled /> {t("jobs.actions.filterpartsonly")}
{t("jobs.actions.filterpartsonly")}
</Button>
)}
@@ -685,6 +682,7 @@ export function JobLinesComponent({
e.preventDefault();
setSearchText(e.target.value);
}}
enterButton
/>
</Space>
}

View File

@@ -187,9 +187,8 @@ export function JobLineConvertToLabor({
loading={loading}
onClick={handleClick}
{...otherBtnProps}
>
<ClockCircleOutlined />
</Button>
icon={<ClockCircleOutlined />}
/>
</Tooltip>
</Popover>
)}

View File

@@ -1,29 +1,65 @@
import { useMemo } from "react";
import { Tag, Tooltip } from "antd";
import { Tooltip } from "antd";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { useTranslation } from "react-i18next";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
const mapDispatchToProps = () => ({});
const colorMap = {
gray: { bg: "#fafafa", border: "#d9d9d9", text: "#000000" },
gold: { bg: "#fffbe6", border: "#ffe58f", text: "#d48806" },
red: { bg: "#fff1f0", border: "#ffccc7", text: "#cf1322" },
blue: { bg: "#e6f7ff", border: "#91d5ff", text: "#0958d9" },
green: { bg: "#f6ffed", border: "#b7eb8f", text: "#389e0d" },
orange: { bg: "#fff7e6", border: "#ffd591", text: "#d46b08" }
};
function CompactTag({ color = "gray", children, tooltip = "" }) {
const colors = colorMap[color] || colorMap.gray;
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
padding: "0 2px",
fontSize: "12px",
lineHeight: "20px",
backgroundColor: colors.bg,
border: `1px solid ${colors.border}`,
borderRadius: "2px",
color: colors.text,
minWidth: "24px",
textAlign: "center"
}}
>
<Tooltip title={tooltip}>{children}</Tooltip>
</span>
);
}
export default connect(mapStateToProps, mapDispatchToProps)(JobPartsQueueCount);
export function JobPartsQueueCount({ bodyshop, parts }) {
const { t } = useTranslation();
const partsStatus = useMemo(() => {
if (!parts) return null;
const statusKeys = ["default_bo", "default_ordered", "default_received", "default_returned"];
return parts.reduce(
(acc, val) => {
if (val.part_type === "PAS" || val.part_type === "PASL") return acc;
acc.total = acc.total + val.count;
acc[val.status] = acc[val.status] + val.count;
acc.total += val.count;
// NOTE: if val.status is null, object key becomes "null"
acc[val.status] = (acc[val.status] ?? 0) + val.count;
return acc;
},
{
@@ -34,45 +70,38 @@ export function JobPartsQueueCount({ bodyshop, parts }) {
);
}, [bodyshop, parts]);
if (!parts) return null;
if (!parts || !partsStatus) return null;
return (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(40px, 1fr))",
gap: "8px",
width: "100%",
justifyItems: "start"
display: "inline-flex", // ✅ shrink-wraps, fixes the “extra box” to the right
gap: 2,
alignItems: "center",
whiteSpace: "nowrap"
}}
>
<Tooltip title="Total">
<Tag style={{ minWidth: "40px", textAlign: "center" }}>{partsStatus.total}</Tag>
</Tooltip>
<Tooltip title={t("dashboard.errors.status_normal")}>
<Tag color="gold" style={{ minWidth: "40px", textAlign: "center" }}>
<CompactTag tooltip="Total" color="gray">
{partsStatus.total}
</CompactTag>
<CompactTag tooltip="No Status" color="gold">
{partsStatus["null"]}
</Tag>
</Tooltip>
<Tooltip title={bodyshop.md_order_statuses.default_bo}>
<Tag color="red" style={{ minWidth: "40px", textAlign: "center" }}>
</CompactTag>
<CompactTag tooltip={bodyshop.md_order_statuses.default_bo} color="red">
{partsStatus[bodyshop.md_order_statuses.default_bo]}
</Tag>
</Tooltip>
<Tooltip title={bodyshop.md_order_statuses.default_ordered}>
<Tag color="blue" style={{ minWidth: "40px", textAlign: "center" }}>
</CompactTag>
<CompactTag tooltip={bodyshop.md_order_statuses.default_ordered} color="blue">
{partsStatus[bodyshop.md_order_statuses.default_ordered]}
</Tag>
</Tooltip>
<Tooltip title={bodyshop.md_order_statuses.default_received}>
<Tag color="green" style={{ minWidth: "40px", textAlign: "center" }}>
</CompactTag>
<CompactTag tooltip={bodyshop.md_order_statuses.default_received} color="green">
{partsStatus[bodyshop.md_order_statuses.default_received]}
</Tag>
</Tooltip>
<Tooltip title={bodyshop.md_order_statuses.default_returned}>
<Tag color="orange" style={{ minWidth: "40px", textAlign: "center" }}>
</CompactTag>
<CompactTag tooltip={bodyshop.md_order_statuses.default_returned} color="orange">
{partsStatus[bodyshop.md_order_statuses.default_returned]}
</Tag>
</Tooltip>
</CompactTag>
</div>
);
}

View File

@@ -18,10 +18,17 @@ const mapStateToProps = createStructuredSelector({
* @param parts
* @param displayMode
* @param popoverPlacement
* @param countsOnly
* @returns {JSX.Element}
* @constructor
*/
export function JobPartsReceived({ bodyshop, parts, displayMode = "full", popoverPlacement = "top" }) {
export function JobPartsReceived({
bodyshop,
parts,
displayMode = "full",
popoverPlacement = "top",
countsOnly = false
}) {
const [open, setOpen] = useState(false);
const { t } = useTranslation();
@@ -61,6 +68,8 @@ export function JobPartsReceived({ bodyshop, parts, displayMode = "full", popove
[canOpen]
);
if (countsOnly) return <JobPartsQueueCount parts={parts} />;
const displayText =
displayMode === "compact" ? summary.percentLabel : `${summary.percentLabel} (${summary.received}/${summary.total})`;
@@ -74,7 +83,7 @@ export function JobPartsReceived({ bodyshop, parts, displayMode = "full", popove
trigger={["click"]}
placement={popoverPlacement}
content={
<div onClick={stop} style={{ minWidth: 260 }}>
<div onClick={stop}>
<JobPartsQueueCount parts={parts} />
</div>
}
@@ -99,7 +108,8 @@ JobPartsReceived.propTypes = {
bodyshop: PropTypes.object,
parts: PropTypes.array,
displayMode: PropTypes.oneOf(["full", "compact"]),
popoverPlacement: PropTypes.string
popoverPlacement: PropTypes.string,
countsOnly: PropTypes.bool
};
export default connect(mapStateToProps)(JobPartsReceived);

View File

@@ -107,9 +107,8 @@ export function JobPayments({ job, bodyshop, setPaymentContext, setCardPaymentCo
context: record
});
}}
>
<EditFilled />
</Button>
icon={<EditFilled />}
/>
<PrintWrapperComponent
templateObject={{
name: TemplateList("payment").payment_receipt.key,

View File

@@ -11,8 +11,7 @@ export default function JobSyncButton({ job }) {
};
if (job?.available_jobs && job?.available_jobs?.length > 0)
return (
<Button onClick={handleClick}>
<SyncOutlined />
<Button onClick={handleClick} icon={<SyncOutlined />}>
{t("jobs.actions.sync")}
</Button>
);

View File

@@ -53,10 +53,8 @@ export function JobsAdminStatus({ insertAuditTrail, bodyshop, job }) {
return (
<Dropdown menu={statusMenu} trigger={["click"]} key="changestatus">
<Button shape="round">
<Button icon={<DownCircleFilled />} iconPlacement="end" shape="round">
<span>{job.status}</span>
<DownCircleFilled />
</Button>
</Dropdown>
);

View File

@@ -94,11 +94,7 @@ export function JobsAvailableScan({ partnerVersion, refetch }) {
{
title: t("general.labels.actions"),
key: "actions",
render: (text, record) => (
<Button onClick={() => handleImport(record.filepath)}>
<DownloadOutlined />
</Button>
)
render: (text, record) => <Button icon={<DownloadOutlined />} onClick={() => handleImport(record.filepath)} />
}
];
@@ -126,21 +122,21 @@ export function JobsAvailableScan({ partnerVersion, refetch }) {
extra={
<Space wrap>
<Button
icon={<SyncOutlined />}
loading={loading}
disabled={!partnerVersion}
onClick={() => {
scanEstimates();
}}
id="scan-estimates-button"
>
<SyncOutlined />
</Button>
/>
<Input.Search
placeholder={t("general.labels.search")}
onChange={(e) => {
setSearchText(e.currentTarget.value);
}}
enterButton
/>
</Space>
}

View File

@@ -135,17 +135,16 @@ export function JobsAvailableComponent({ bodyshop, loading, data, refetch, addJo
refetch();
});
}}
>
<DeleteFilled />
</Button>
icon={<DeleteFilled />}
/>
{!isClosed && (
<>
<Button onClick={() => addJobAsNew(record)} disabled={record.issupplement}>
<PlusCircleFilled />
</Button>
<Button onClick={() => addJobAsSupp(record)}>
<DownloadOutlined />
</Button>
<Button
onClick={() => addJobAsNew(record)}
disabled={record.issupplement}
icon={<PlusCircleFilled />}
/>
<Button onClick={() => addJobAsSupp(record)} icon={<DownloadOutlined />} />
</>
)}
{isClosed && <Alert type="error" title={t("jobs.labels.alreadyclosed")}></Alert>}
@@ -175,9 +174,8 @@ export function JobsAvailableComponent({ bodyshop, loading, data, refetch, addJo
onClick={() => {
refetch();
}}
>
<SyncOutlined />
</Button>
icon={<SyncOutlined />}
/>
<Button
onClick={() => {
deleteAllAvailableJobs()
@@ -198,13 +196,16 @@ export function JobsAvailableComponent({ bodyshop, loading, data, refetch, addJo
>
{t("general.actions.deleteall")}
</Button>
<Input.Search
placeholder={t("general.labels.search")}
onChange={(e) => {
setSearchText(e.currentTarget.value);
}}
enterButton
/>
<Link to="/manage/jobs/new">
<Button>{t("jobs.actions.manualnew")}</Button>
</Link>
</Space>
}
>

View File

@@ -96,10 +96,8 @@ export function JobsChangeStatus({ job, bodyshop, jobRO, insertAuditTrail, isPar
return (
<Dropdown menu={statusMenu} trigger={["click"]} key="changestatus" disabled={jobRO || !job.converted}>
<Button shape="round">
<Button shape="round" icon={<DownCircleFilled />} iconPlacement="end">
<span>{job.status}</span>
<DownCircleFilled />
</Button>
</Dropdown>
);

View File

@@ -43,6 +43,10 @@ export function JobsCloseLines({ bodyshop, job, jobRO }) {
{fields.map((field, index) => (
<tr key={field.key}>
<td>
{/* Hidden field to preserve jobline ID without injecting a div under <tr> */}
<Form.Item noStyle name={[field.name, "id"]}>
<input type="hidden" />
</Form.Item>
<Form.Item
// label={t("joblines.fields.line_desc")}
key={`${index}line_desc`}

View File

@@ -1,18 +1,22 @@
import { useMutation } from "@apollo/client/react";
import { Button, Form, Input, Popover, Select, Space, Switch } from "antd";
import { Button, Divider, Form, Input, Modal, Select, Space, Switch } from "antd";
import axios from "axios";
import { some } from "lodash";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { CONVERT_JOB_TO_RO } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions";
import { selectJobReadOnly } from "../../redux/application/application.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { DMS_MAP, getDmsMode } from "../../utils/dmsUtils";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { useSocket } from "../../contexts/SocketIO/useSocket.js";
import RREarlyROForm from "../dms-post-form/rr-early-ro-form";
const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser
@@ -33,11 +37,27 @@ const mapDispatchToProps = (dispatch) => ({
export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTrail, parentFormIsFieldsTouched }) {
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const [earlyRoCreated, setEarlyRoCreated] = useState(!!job?.dms_id); // Track early RO creation state
const [earlyRoCreatedThisSession, setEarlyRoCreatedThisSession] = useState(false); // Track if created in THIS modal session
const [mutationConvertJob] = useMutation(CONVERT_JOB_TO_RO);
const { t } = useTranslation();
const [form] = Form.useForm();
const notification = useNotification();
const allFormValues = Form.useWatch([], form);
const { socket } = useSocket(); // Extract socket from context
// Get Fortellis treatment for proper DMS mode detection
const {
treatments: { Fortellis }
} = useTreatmentsWithConfig({
attributes: {},
names: ["Fortellis"],
splitKey: bodyshop?.imexshopid
});
// Check if bodyshop has Reynolds integration using the proper getDmsMode function
const dmsMode = getDmsMode(bodyshop, Fortellis.treatment);
const isReynoldsMode = dmsMode === DMS_MAP.reynolds;
const handleConvert = async ({ employee_csr, category, ...values }) => {
if (parentFormIsFieldsTouched()) {
@@ -82,8 +102,54 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
const submitDisabled = useCallback(() => some(allFormValues, (v) => v === undefined), [allFormValues]);
const popMenu = (
<div>
const handleEarlyROSuccess = (result) => {
setEarlyRoCreated(true); // Mark early RO as created
setEarlyRoCreatedThisSession(true); // Mark as created in this session
notification.success({
title: t("jobs.successes.early_ro_created"),
description: `RO Number: ${result.roNumber || "N/A"}`
});
// Delay refetch to keep success message visible for 2 seconds
setTimeout(() => {
refetch?.();
}, 2000);
};
const handleModalClose = () => {
setOpen(false);
};
if (job.converted) return <></>;
return (
<>
<Button
key="convert"
type="primary"
danger
disabled={job.converted || jobRO}
loading={loading}
onClick={() => {
setEarlyRoCreated(!!job?.dms_id); // Initialize state based on current job
setEarlyRoCreatedThisSession(false); // Reset session state when opening modal
setOpen(true);
}}
>
{t("jobs.actions.convert")}
</Button>
{/* Convert Job Modal */}
<Modal
open={open}
onCancel={handleModalClose}
closable={!(earlyRoCreatedThisSession && !job.converted)} // Only restrict if created in THIS session
maskClosable={!(earlyRoCreatedThisSession && !job.converted)} // Only restrict if created in THIS session
title={t("jobs.actions.convert")}
footer={null}
width={700}
destroyOnHidden
>
{/* Standard Convert Form */}
<Form
layout="vertical"
form={form}
@@ -98,6 +164,20 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
referral_source_extra: job.referral_source_extra ?? ""
}}
>
{/* Show Reynolds Early RO section at the top if applicable */}
{isReynoldsMode && !job.dms_id && !earlyRoCreated && (
<>
<RREarlyROForm
bodyshop={bodyshop}
socket={socket}
job={job}
onSuccess={handleEarlyROSuccess}
showCancelButton={false}
/>
<Divider />
</>
)}
<Form.Item
name={["ins_co_nm"]}
label={t("jobs.fields.ins_co_nm")}
@@ -225,34 +305,24 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
<Form.Item label={t("jobs.fields.towin")} name="towin" valuePropName="checked">
<Switch />
</Form.Item>
<Space wrap>
<Button disabled={submitDisabled()} type="primary" danger onClick={() => form.submit()} loading={loading}>
{t("jobs.actions.convert")}
</Button>
<Button onClick={() => setOpen(false)}>{t("general.actions.close")}</Button>
</Space>
</Form>
</div>
);
if (job.converted) return <></>;
return (
<Popover open={open} content={popMenu}>
<Space wrap style={{ marginTop: 16 }}>
<Button
key="convert"
disabled={submitDisabled() || (isReynoldsMode && !job.dms_id && !earlyRoCreated)}
type="primary"
danger
// style={{ display: job.converted ? "none" : "" }}
disabled={job.converted || jobRO}
onClick={() => form.submit()}
loading={loading}
onClick={() => {
setOpen(true);
}}
>
{t("jobs.actions.convert")}
</Button>
</Popover>
<Button onClick={handleModalClose} disabled={earlyRoCreatedThisSession && !job.converted}>
{t("general.actions.close")}
</Button>
</Space>
</Form>
</Modal>
</>
);
}

View File

@@ -24,7 +24,7 @@ export default function JobsCreateVehicleInfoPredefined({ disabled, form }) {
<div>
<Table
size="small"
title={() => <Input.Search onSearch={(value) => setSearch(value)} />}
title={() => <Input.Search onSearch={(value) => setSearch(value)} enterButton/>}
dataSource={filteredPredefinedVehicles}
columns={[
{
@@ -56,9 +56,8 @@ export default function JobsCreateVehicleInfoPredefined({ disabled, form }) {
setOpen(false);
setSearch("");
}}
>
<PlusOutlined />
</Button>
icon={<PlusOutlined />}
/>
)
}
]}

View File

@@ -251,7 +251,6 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
))}
</Select>
</Form.Item>
<Form.Item label={t("jobs.fields.selling_dealer")} name="selling_dealer">
<Input disabled={jobRO} />
</Form.Item>
@@ -267,6 +266,21 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
<Form.Item label={t("jobs.fields.lost_sale_reason")} name="lost_sale_reason">
<Input disabled={jobRO} allowClear />
</Form.Item>
{bodyshop.rr_dealerid && (
<Form.Item label={t("jobs.fields.dms.id")} name="dms_id">
<Input disabled />
</Form.Item>
)}
{bodyshop.rr_dealerid && (
<Form.Item label={t("jobs.fields.dms.advisor")} name="dms_advisor_id">
<Input disabled />
</Form.Item>
)}
{bodyshop.rr_dealerid && (
<Form.Item label={t("jobs.fields.dms.customer")} name="dms_customer_id">
<Input disabled />
</Form.Item>
)}
</FormRow>
</Card>
);

View File

@@ -1,10 +1,11 @@
import { DownCircleFilled } from "@ant-design/icons";
import { useApolloClient, useMutation, useQuery } from "@apollo/client/react";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { Button, Card, Dropdown, Form, Input, Modal, Popconfirm, Popover, Select, Space } from "antd";
import { Button, Card, Dropdown, Form, Input, Modal, Popover, Select, Space } from "antd";
import axios from "axios";
import parsePhoneNumber from "libphonenumber-js";
import { useCallback, useMemo, useRef, useState } from "react";
import { HasRbacAccess } from "../../components/rbac-wrapper/rbac-wrapper.component";
import { useTranslation } from "react-i18next";
import { connect } from "react-redux";
import { Link, useNavigate } from "react-router-dom";
@@ -20,7 +21,7 @@ import { selectJobReadOnly } from "../../redux/application/application.selectors
import { setEmailOptions } from "../../redux/email/email.actions";
import { openChatByPhone, setMessage } from "../../redux/messaging/messaging.actions";
import { setModalContext } from "../../redux/modals/modals.actions";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { selectAuthLevel, selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { DateTimeFormatter } from "../../utils/DateFormatter";
import { TemplateList } from "../../utils/TemplateConstants";
@@ -28,7 +29,6 @@ import dayjs from "../../utils/day";
import { HasFeatureAccess } from "../feature-wrapper/feature-wrapper.component";
import FormDateTimePickerComponent from "../form-date-time-picker/form-date-time-picker.component";
import LockerWrapperComponent from "../lock-wrapper/lock-wrapper.component";
import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component";
import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx";
import AddToProduction from "./jobs-detail-header-actions.addtoproduction.util";
import DuplicateJob from "./jobs-detail-header-actions.duplicate.util";
@@ -39,7 +39,8 @@ const EMPTY_ARRAY = Object.freeze([]);
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
jobRO: selectJobReadOnly,
currentUser: selectCurrentUser
currentUser: selectCurrentUser,
authLevel: selectAuthLevel
});
const mapDispatchToProps = (dispatch) => ({
@@ -117,7 +118,8 @@ export function JobsDetailHeaderActions({
openChatByPhone,
setMessage,
setTimeTicketTaskContext,
setTaskUpsertContext
setTaskUpsertContext,
authLevel
}) {
const { t } = useTranslation();
const client = useApolloClient();
@@ -129,10 +131,6 @@ export function JobsDetailHeaderActions({
const jobId = job?.id;
const watcherVars = useMemo(() => ({ jobid: jobId }), [jobId]);
// Option A: coordinated Dropdown + Popconfirm open state so the menu doesn't unmount before Popconfirm renders.
const [confirmKey, setConfirmKey] = useState(null);
const confirmKeyRef = useRef(null);
const [isCancelScheduleModalVisible, setIsCancelScheduleModalVisible] = useState(false);
const [insertAppointment] = useMutation(INSERT_MANUAL_APPT);
const [deleteJob] = useMutation(DELETE_JOB);
@@ -150,6 +148,8 @@ export function JobsDetailHeaderActions({
const devEmails = ["imex.dev", "rome.dev"];
const prodEmails = ["imex.prod", "rome.prod", "imex.test", "rome.test"];
const canVoidJob = useMemo(() => HasRbacAccess({ authLevel, bodyshop, action: "jobs:void" }), [authLevel, bodyshop]);
const hasValidEmail = (emails) => emails.some((email) => userEmail.endsWith(email));
const canSubmitForTesting = (isDevEnv && hasValidEmail(devEmails)) || (isProdEnv && hasValidEmail(prodEmails));
@@ -157,7 +157,6 @@ export function JobsDetailHeaderActions({
variables: watcherVars,
skip: !jobId,
fetchPolicy: "cache-first",
notifyOnNetworkStatusChange: true
});
const jobWatchersCount = jobWatchersData?.job_watchers?.length ?? job?.job_watchers?.length ?? 0;
@@ -179,83 +178,69 @@ export function JobsDetailHeaderActions({
const jobInPreProduction = preProductionStatuses.includes(jobStatus);
const jobInPostProduction = postProductionStatuses.includes(jobStatus);
const openConfirm = useCallback((key) => {
confirmKeyRef.current = key;
setConfirmKey(key);
setDropdownOpen(true);
}, []);
const makeConfirmId = () =>
globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(16).slice(2)}`;
const closeConfirm = useCallback(() => {
confirmKeyRef.current = null;
setConfirmKey(null);
}, []);
const [modal, modalContextHolder] = Modal.useModal();
const handleDropdownOpenChange = useCallback(
(nextOpen, info) => {
if (!nextOpen && info?.source === "menu" && confirmKeyRef.current) return;
setDropdownOpen(nextOpen);
if (!nextOpen) closeConfirm();
},
[closeConfirm]
);
const confirmInstancesRef = useRef(new Map());
const renderPopconfirmMenuLabel = ({
key,
text,
const closeConfirmById = (id) => {
const inst = confirmInstancesRef.current.get(id);
if (inst) inst.destroy(); // hard close
confirmInstancesRef.current.delete(id);
};
const openConfirmFromMenu = ({
variant = "confirm", // "confirm" | "info" | "warning"
title,
content,
okText,
cancelText,
showCancel = true,
closeDropdownOnConfirm = true,
onConfirm
}) => (
<Popconfirm
title={title}
okText={okText}
cancelText={cancelText}
showCancel={showCancel}
open={confirmKey === key}
onOpenChange={(nextOpen) => {
if (nextOpen) openConfirm(key);
else closeConfirm();
}}
onConfirm={(e) => {
e?.stopPropagation?.();
closeConfirm();
// Critical: for informational popconfirms, keep the dropdown open so the Popconfirm can cleanly close.
if (closeDropdownOnConfirm) {
onOk,
onCancel
}) => {
// close the dropdown immediately; confirm dialog is separate
setDropdownOpen(false);
const id = makeConfirmId();
const openFn = variant === "info" ? modal.info : variant === "warning" ? modal.warning : modal.confirm;
const inst = openFn({
title,
content,
okText,
cancelText,
centered: true,
maskClosable: false,
onCancel: () => {
closeConfirmById(id);
onCancel?.();
},
onOk: async () => {
try {
await onOk?.();
} finally {
closeConfirmById(id);
}
},
...(showCancel ? {} : { okCancel: false })
});
onConfirm?.(e);
}}
onCancel={(e) => {
e?.stopPropagation?.();
closeConfirm();
// Keep dropdown open on cancel so the user can continue using the menu.
}}
getPopupContainer={() => document.body}
>
<div
style={{ width: "100%" }}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
openConfirm(key);
}}
>
{text}
</div>
</Popconfirm>
);
confirmInstancesRef.current.set(id, inst);
return id;
};
const handleDropdownOpenChange = useCallback((nextOpen) => {
setDropdownOpen(nextOpen);
}, []);
// Function to show modal
const showCancelScheduleModal = () => {
setIsCancelScheduleModalVisible(true);
};
// Function to handle Cancel
const handleCancelScheduleModalCancel = () => {
setIsCancelScheduleModalVisible(false);
};
@@ -264,7 +249,7 @@ export function JobsDetailHeaderActions({
DuplicateJob({
apolloClient: client,
jobId: job.id,
config: { defaultOpenStatus: bodyshop.md_ro_statuses.default_imported },
config: { defaultOpenStatus: bodyshop.md_ro_statuses.default_imported, timezone: bodyshop.timezone },
completionCallback: (newJobId) => {
history(`/manage/jobs/${newJobId}`);
notification.success({
@@ -279,7 +264,7 @@ export function JobsDetailHeaderActions({
DuplicateJob({
apolloClient: client,
jobId: job.id,
config: { defaultOpenStatus: bodyshop.md_ro_statuses.default_imported },
config: { defaultOpenStatus: bodyshop.md_ro_statuses.default_imported, timezone: bodyshop.timezone },
completionCallback: (newJobId) => {
history(`/manage/jobs/${newJobId}`);
notification.success({
@@ -476,6 +461,11 @@ export function JobsDetailHeaderActions({
};
const handleVoidJob = async () => {
if (!canVoidJob) {
notification.error({ title: t("general.messages.rbacunauth") });
return;
}
//delete the job.
const result = await voidJob({
variables: {
@@ -964,25 +954,25 @@ export function JobsDetailHeaderActions({
{
key: "duplicate",
id: "job-actions-duplicate",
label: renderPopconfirmMenuLabel({
key: "confirm-duplicate",
text: t("menus.jobsactions.duplicate"),
label: t("menus.jobsactions.duplicate"),
onClick: () =>
openConfirmFromMenu({
title: t("jobs.labels.duplicateconfirm"),
okText: t("general.labels.yes"),
cancelText: t("general.labels.no"),
onConfirm: handleDuplicate
onOk: handleDuplicate
})
},
{
key: "duplicatenolines",
id: "job-actions-duplicatenolines",
label: renderPopconfirmMenuLabel({
key: "confirm-duplicate-nolines",
text: t("menus.jobsactions.duplicatenolines"),
label: t("menus.jobsactions.duplicatenolines"),
onClick: () =>
openConfirmFromMenu({
title: t("jobs.labels.duplicateconfirm"),
okText: t("general.labels.yes"),
cancelText: t("general.labels.no"),
onConfirm: handleDuplicateConfirm
onOk: handleDuplicateConfirm
})
}
]
@@ -1156,26 +1146,25 @@ export function JobsDetailHeaderActions({
menuItems.push({
key: "deletejob",
id: "job-actions-deletejob",
label:
jobWatchersCount === 0
? renderPopconfirmMenuLabel({
key: "confirm-deletejob",
text: t("menus.jobsactions.deletejob"),
label: t("menus.jobsactions.deletejob"),
onClick: () => {
if (jobWatchersCount === 0) {
openConfirmFromMenu({
title: t("jobs.labels.deleteconfirm"),
okText: t("general.labels.yes"),
cancelText: t("general.labels.no"),
onConfirm: handleDeleteJob
})
: renderPopconfirmMenuLabel({
key: "confirm-deletejob-watchers",
text: t("menus.jobsactions.deletejob"),
onOk: handleDeleteJob
});
} else {
// informational "OK only"
openConfirmFromMenu({
variant: "info",
title: t("jobs.labels.deletewatchers"),
showCancel: false,
closeDropdownOnConfirm: false, // <-- FIX: keep dropdown mounted so Popconfirm can close cleanly
onConfirm: () => {
// informational confirm only
okText: t("general.actions.ok"),
showCancel: false
});
}
}
})
});
}
@@ -1188,22 +1177,18 @@ export function JobsDetailHeaderActions({
label: t("appointments.labels.manualevent")
});
if (!jobRO && job.converted) {
if (!jobRO && job.converted && canVoidJob) {
menuItems.push({
key: "voidjob",
id: "job-actions-voidjob",
label: (
<RbacWrapper action="jobs:void" noauth>
{renderPopconfirmMenuLabel({
key: "confirm-voidjob",
text: t("menus.jobsactions.void"),
label: t("menus.jobsactions.void"),
onClick: () =>
openConfirmFromMenu({
title: t("jobs.labels.voidjob"),
okText: t("general.labels.yes"),
cancelText: t("general.labels.no"),
onConfirm: handleVoidJob
})}
</RbacWrapper>
)
onOk: handleVoidJob
})
});
}
@@ -1235,6 +1220,7 @@ export function JobsDetailHeaderActions({
return (
<>
{modalContextHolder}
<Modal
title={t("menus.jobsactions.cancelallappointments")}
open={isCancelScheduleModalVisible}
@@ -1286,9 +1272,8 @@ export function JobsDetailHeaderActions({
open={dropdownOpen}
onOpenChange={handleDropdownOpenChange}
>
<Button>
<Button icon={<DownCircleFilled />} iconPlacement="end">
<span>{t("general.labels.actions")}</span>
<DownCircleFilled />
</Button>
</Dropdown>

View File

@@ -15,7 +15,7 @@ export default async function DuplicateJob({
}) {
logImEXEvent("job_duplicate");
const { defaultOpenStatus } = config;
const { defaultOpenStatus, timezone } = config;
//get a list of all fields on the job
const res = await apolloClient.query({
query: QUERY_JOB_FOR_DUPE,
@@ -31,9 +31,12 @@ export default async function DuplicateJob({
delete existingJob.updatedat;
delete existingJob.cieca_stl;
delete existingJob.cieca_ttl;
!keepJobLines && delete existingJob.clm_total;
const newJob = {
...existingJob,
date_estimated: dayjs().tz(timezone, false).format("YYYY-MM-DD"),
date_open: dayjs(),
status: defaultOpenStatus
};
@@ -70,7 +73,7 @@ export default async function DuplicateJob({
export async function CreateIouForJob({ apolloClient, jobId, config, jobLinesToKeep, currentUser }) {
logImEXEvent("job_create_iou");
const { status } = config;
const { status, timezone } = config;
//get a list of all fields on the job
const res = await apolloClient.query({
query: QUERY_JOB_FOR_DUPE,
@@ -88,10 +91,10 @@ export async function CreateIouForJob({ apolloClient, jobId, config, jobLinesToK
const newJob = {
...existingJob,
converted: true,
status: status,
iouparent: jobId,
date_estimated: dayjs().tz(timezone, false).format("YYYY-MM-DD"),
date_open: dayjs(),
audit_trails: {
data: [

View File

@@ -143,7 +143,7 @@ export function JobsDetailHeader({ job, bodyshop, disabled, insertAuditTrail, is
label={t("jobs.fields.comment")}
styles={{ value: { overflow: "hidden", textOverflow: "ellipsis" } }}
>
<ProductionListColumnComment record={job} />
<ProductionListColumnComment record={job} usePortal={true} />
</DataLabel>
{!isPartsEntry && <DataLabel label={t("jobs.fields.ins_co_nm_short")}>{job.ins_co_nm}</DataLabel>}
<DataLabel label={t("jobs.fields.clm_no")}>{job.clm_no}</DataLabel>
@@ -176,7 +176,7 @@ export function JobsDetailHeader({ job, bodyshop, disabled, insertAuditTrail, is
</DataLabel>
)}
<DataLabel label={t("jobs.fields.production_vars.note")}>
<ProductionListColumnProductionNote record={job} />
<ProductionListColumnProductionNote record={job} usePortal={true} />
</DataLabel>
<DataLabel label={t("jobs.fields.estimate_sent_approval")}>
<Space>

View File

@@ -128,9 +128,7 @@ function JobsDocumentsComponent({
<Row gutter={[16, 16]}>
<Col span={24}>
<Space wrap>
<Button onClick={() => refetch && refetch()}>
<SyncOutlined />
</Button>
<Button onClick={() => refetch && refetch()} icon={<SyncOutlined />} />
<JobsDocumentsGallerySelectAllComponent galleryImages={galleryImages} setGalleryImages={setgalleryImages} />
<JobsDocumentsDownloadButton galleryImages={galleryImages} identifier={downloadIdentifier} />
<JobsDocumentsDeleteButton galleryImages={galleryImages} deletionCallback={billsCallback || refetch} />

View File

@@ -65,9 +65,8 @@ function JobsDocumentsImgproxyComponent({
//Do the imgproxy refresh too
fetchThumbnails();
}}
>
<SyncOutlined />
</Button>
icon={<SyncOutlined />}
/>
<JobsDocumentsGallerySelectAllComponent galleryImages={galleryImages} setGalleryImages={setGalleryImages} />
{!billId && (
<JobsDocumentsGalleryReassign galleryImages={galleryImages} callback={fetchThumbnails || refetch} />

View File

@@ -102,9 +102,8 @@ export function JobsDocumentsLocalGallery({
}
}
}}
>
<SyncOutlined />
</Button>
icon={<SyncOutlined />}
/>
<a href={CreateExplorerLinkForJob({ jobid: job.id })}>
<Button>{t("documents.labels.openinexplorer")}</Button>
</a>

View File

@@ -179,9 +179,8 @@ export default function JobsFindModalComponent({
onClick={() => {
jobsListRefetch();
}}
>
<SyncOutlined />
</Button>
icon={<SyncOutlined />}
/>
<Input
value={modalSearch}
onChange={(e) => {

View File

@@ -224,9 +224,7 @@ export function JobsList({ bodyshop, refetch, loading, jobs, total }) {
</Button>
</>
)}
<Button onClick={() => refetch()}>
<SyncOutlined />
</Button>
<Button onClick={() => refetch()} icon={<SyncOutlined />} />
<Input.Search
placeholder={search.search || t("general.labels.search")}
onSearch={(value) => {

View File

@@ -313,9 +313,7 @@ export function JobsList({ bodyshop }) {
title={t("titles.bc.jobs-active")}
extra={
<Space wrap>
<Button onClick={() => refetch()}>
<SyncOutlined />
</Button>
<Button onClick={() => refetch()} icon={<SyncOutlined />} />
<Input.Search
placeholder={t("general.labels.search")}
onChange={(e) => {

View File

@@ -121,9 +121,12 @@ export function JobNotesComponent({
width: 200,
render: (text, record) => (
<Space wrap>
<Button loading={deleteLoading} disabled={record.audit || jobRO} onClick={() => handleNoteDelete(record.id)}>
<DeleteFilled />
</Button>
<Button
loading={deleteLoading}
disabled={record.audit || jobRO}
onClick={() => handleNoteDelete(record.id)}
icon={<DeleteFilled />}
/>
<Button
disabled={record.audit || jobRO}
onClick={() => {
@@ -135,9 +138,8 @@ export function JobNotesComponent({
}
});
}}
>
<EditFilled />
</Button>
icon={<EditFilled />}
/>
<PrintWrapperComponent
templateObject={{
name: Templates.individual_job_note.key,

View File

@@ -297,9 +297,7 @@ export function JobsReadyList({ bodyshop }) {
extra={
<Space wrap>
<span>({readyStatuses && readyStatuses.join(", ")})</span>
<Button onClick={() => refetch()}>
<SyncOutlined />
</Button>
<Button onClick={() => refetch()} icon={<SyncOutlined />} />
<Input.Search
placeholder={t("general.labels.search")}
onChange={(e) => {

View File

@@ -132,7 +132,13 @@ export function LaborAllocationsAdjustmentEdit({
);
return (
<Popover open={open} onOpenChange={(vis) => setOpen(vis)} content={overlay} trigger="click">
<Popover
getPopupContainer={(trigger) => trigger?.parentElement || document.body}
open={open}
onOpenChange={(vis) => setOpen(vis)}
content={overlay}
trigger="click"
>
{children}
</Popover>
);

View File

@@ -246,9 +246,8 @@ export function PayrollLaborAllocationsTable({
setTotals(data);
refetch();
}}
>
<SyncOutlined />
</Button>
icon={<SyncOutlined />}
/>
</Space>
}
>

View File

@@ -24,6 +24,7 @@ const NotificationCenterComponent = ({
onNotificationClick,
unreadCount,
isEmployee,
isDarkMode,
ref
}) => {
const { t } = useTranslation();
@@ -112,14 +113,16 @@ const NotificationCenterComponent = ({
<Alert title={t("notifications.labels.employee-notification")} type="warning" />
</div>
) : (
<div className={isDarkMode ? "notification-center--dark" : "notification-center--light"} style={{ height: "400px", width: "100%" }}>
<Virtuoso
ref={virtuosoRef}
style={{ height: "400px", width: "100%" }}
style={{ height: "100%", width: "100%" }}
data={notifications}
totalCount={notifications.length}
endReached={loadMore}
itemContent={renderNotification}
/>
</div>
)}
</div>
);

View File

@@ -5,6 +5,7 @@ import NotificationCenterComponent from "./notification-center.component";
import { GET_NOTIFICATIONS } from "../../graphql/notifications.queries";
import { createStructuredSelector } from "reselect";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors.js";
import { selectDarkMode } from "../../redux/application/application.selectors.js";
import day from "../../utils/day.js";
import { INITIAL_NOTIFICATIONS, useSocket } from "../../contexts/SocketIO/useSocket.js";
import { useIsEmployee } from "../../utils/useIsEmployee.js";
@@ -22,7 +23,7 @@ const NOTIFICATION_POLL_INTERVAL_SECONDS = 60;
* @returns {JSX.Element}
* @constructor
*/
const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount, currentUser }) => {
const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount, currentUser, isDarkMode }) => {
const [showUnreadOnly, setShowUnreadOnly] = useState(false);
const [notifications, setNotifications] = useState([]);
const [isLoading, setIsLoading] = useState(false);
@@ -55,7 +56,6 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount,
where: whereClause
},
fetchPolicy: "cache-and-network",
notifyOnNetworkStatusChange: true,
errorPolicy: "all",
pollInterval: isConnected ? 0 : day.duration(NOTIFICATION_POLL_INTERVAL_SECONDS, "seconds").asMilliseconds(),
skip: skipQuery
@@ -213,13 +213,15 @@ const NotificationCenterContainer = ({ visible, onClose, bodyshop, unreadCount,
loadMore={loadMore}
onNotificationClick={handleNotificationClick}
unreadCount={unreadCount}
isDarkMode={isDarkMode}
/>
);
};
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
currentUser: selectCurrentUser
currentUser: selectCurrentUser,
isDarkMode: selectDarkMode
});
export default connect(mapStateToProps, null)(NotificationCenterContainer);

View File

@@ -173,3 +173,11 @@
}
}
}
.notification-center--dark {
color-scheme: dark;
}
.notification-center--light {
color-scheme: light;
}

View File

@@ -48,6 +48,7 @@ export default function OwnerFindModalContainer({
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
onSearch={(val) => callSearchowners({ variables: { search: val.trim() } })}
enterButton
/>
<OwnerFindModalComponent
selectedOwner={selectedOwner}

View File

@@ -2,6 +2,7 @@ import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { store } from "../../redux/store";
import { selectBodyshop } from "../../redux/user/user.selectors";
import { Tooltip } from "antd";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -11,15 +12,27 @@ const mapDispatchToProps = () => ({
});
export default connect(mapStateToProps, mapDispatchToProps)(OwnerNameDisplay);
export function OwnerNameDisplay({ bodyshop, ownerObject }) {
export function OwnerNameDisplay({ bodyshop, ownerObject, withToolTip = false }) {
const emptyTest = ownerObject?.ownr_fn + ownerObject?.ownr_ln + ownerObject?.ownr_co_nm;
if (!emptyTest || emptyTest === "null" || emptyTest.trim() === "") return "N/A";
if (bodyshop.last_name_first)
return `${ownerObject?.ownr_ln || ""}, ${ownerObject?.ownr_fn || ""} ${ownerObject?.ownr_co_nm || ""}`.trim();
return `${ownerObject?.ownr_fn || ""} ${ownerObject?.ownr_ln || ""} ${ownerObject.ownr_co_nm || ""}`.trim();
let returnString;
if (bodyshop.last_name_first) {
returnString =
`${ownerObject?.ownr_ln || ""}, ${ownerObject?.ownr_fn || ""} ${ownerObject?.ownr_co_nm || ""}`.trim();
} else {
returnString = `${ownerObject?.ownr_fn || ""} ${ownerObject?.ownr_ln || ""} ${ownerObject.ownr_co_nm || ""}`.trim();
}
if (withToolTip) {
return (
<Tooltip title={returnString} mouseEnterDelay={0.5}>
{returnString}
</Tooltip>
);
} else {
return returnString;
}
}
export function OwnerNameDisplayFunction(ownerObject, forceFirstLast = false) {

View File

@@ -16,9 +16,10 @@ const OwnerSearchSelect = ({ value, onChange, onBlur, disabled, ref }) => {
SEARCH_OWNERS_BY_ID_FOR_AUTOCOMPLETE
);
const executeSearch = (v) => {
if (v && v.variables?.search !== "" && v.variables.search.length >= 2) callSearch({ variables: v.variables });
const executeSearch = (variables) => {
if (variables?.search !== "" && variables?.search?.length >= 2) callSearch({ variables });
};
const debouncedExecuteSearch = _.debounce(executeSearch, 500);
const handleSearch = (value) => {

View File

@@ -99,9 +99,7 @@ export default function OwnersListComponent({ loading, owners, total, refetch })
</Button>
</>
)}
<Button onClick={() => refetch()}>
<SyncOutlined />
</Button>
<Button onClick={() => refetch()} icon={<SyncOutlined />} />
<Input.Search
placeholder={search.search || t("general.labels.search")}
onSearch={(value) => {

View File

@@ -93,10 +93,7 @@ export function PartDispatchTableComponent({ bodyshop, job, billsQuery }) {
title={t("parts_dispatch.labels.parts_dispatch")}
extra={
<Space wrap>
<Button onClick={() => refetch()}>
<SyncOutlined />
</Button>
<Button onClick={() => refetch()} icon={<SyncOutlined />} />
<Input.Search
placeholder={t("general.labels.search")}
value={searchText}
@@ -104,6 +101,7 @@ export function PartDispatchTableComponent({ bodyshop, job, billsQuery }) {
e.preventDefault();
setSearchText(e.target.value);
}}
enterButton
/>
</Space>
}

View File

@@ -34,9 +34,7 @@ export default function PartsOrderDeleteLine({ disabled, partsLineId, partsOrder
});
}}
>
<Button disabled={disabled}>
<DeleteFilled />
</Button>
<Button disabled={disabled} icon={<DeleteFilled />} />
</Popconfirm>
);
}

View File

@@ -150,9 +150,8 @@ export function PartsOrderListTableDrawerComponent({
}
});
}}
>
<FaTasks />
</Button>
icon={<FaTasks />}
/>
)}
<Popconfirm
title={t("parts_orders.labels.confirmdelete")}
@@ -173,9 +172,7 @@ export function PartsOrderListTableDrawerComponent({
});
}}
>
<Button disabled={jobRO}>
<DeleteFilled />
</Button>
<Button disabled={jobRO} icon={<DeleteFilled />} />
</Popconfirm>
{!isPartsEntry && (
<Button

View File

@@ -295,6 +295,7 @@ export function PartsOrderListTableComponent({
e.preventDefault();
setSearchText(e.target.value);
}}
enterButton
/>
</Space>
}

View File

@@ -1,94 +1,121 @@
import { DownOutlined } from "@ant-design/icons";
import { Dropdown, InputNumber, Space } from "antd";
import { Button, Divider, Dropdown, InputNumber, Space, theme } from "antd";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { logImEXEvent } from "../../firebase/firebase.utils";
const DISCOUNT_PRESETS = [5, 10, 15, 20, 25, 40];
export default function PartsOrderModalPriceChange({ form, field }) {
const { t } = useTranslation();
const menu = {
items: [
{
key: "5",
label: t("parts_orders.labels.discount", { percent: "5%" })
},
{
key: "10",
label: t("parts_orders.labels.discount", { percent: "10%" })
},
{
key: "15",
label: t("parts_orders.labels.discount", { percent: "15%" })
},
{
key: "20",
label: t("parts_orders.labels.discount", { percent: "20%" })
},
{
key: "25",
label: t("parts_orders.labels.discount", { percent: "25%" })
},
{
key: "40",
label: t("parts_orders.labels.discount", { percent: "40%" })
},
{
key: "custom",
label: (
<Space.Compact>
<InputNumber
onClick={(e) => e.stopPropagation()}
onKeyUp={(e) => {
if (e.key === "Enter") {
const { token } = theme.useToken();
const [open, setOpen] = useState(false);
const [customPercent, setCustomPercent] = useState(0);
const applyDiscountPercent = (percent) => {
const pct = Number(percent) || 0;
const values = form.getFieldsValue();
const { parts_order_lines } = values;
const parts_order_lines = values?.parts_order_lines;
const data = Array.isArray(parts_order_lines?.data) ? parts_order_lines.data : [];
if (!data.length) return;
form.setFieldsValue({
parts_order_lines: {
data: parts_order_lines.data.map((p, idx) => {
data: data.map((p, idx) => {
if (idx !== field.name) return p;
console.log(p, e.target.value, (p.act_price || 0) * ((100 - (e.target.value || 0)) / 100));
return {
...p,
act_price: (p.act_price || 0) * ((100 - (e.target.value || 0)) / 100)
act_price: (p.act_price || 0) * ((100 - pct) / 100)
};
})
}
});
e.target.value = 0;
}
}}
min={0}
max={100}
/>
<span style={{ padding: "0 11px", backgroundColor: "#fafafa", border: "1px solid #d9d9d9", borderLeft: 0 }}>%</span>
</Space.Compact>
)
}
],
};
const applyCustom = () => {
logImEXEvent("parts_order_manual_discount", {});
applyDiscountPercent(customPercent);
setCustomPercent(0);
setOpen(false);
};
const menu = {
// Kill the menu “card” styling so our wrapper becomes the single card.
style: {
background: "transparent",
boxShadow: "none"
},
items: DISCOUNT_PRESETS.map((pct) => ({
key: String(pct),
label: t("parts_orders.labels.discount", { percent: `${pct}%` })
})),
onClick: ({ key }) => {
logImEXEvent("parts_order_manual_discount", {});
if (key === "custom") return;
const values = form.getFieldsValue();
const { parts_order_lines } = values;
form.setFieldsValue({
parts_order_lines: {
data: parts_order_lines.data.map((p, idx) => {
if (idx !== field.name) return p;
return {
...p,
act_price: (p.act_price || 0) * ((100 - key) / 100)
};
})
}
});
applyDiscountPercent(key);
setOpen(false);
}
};
return (
<Dropdown menu={menu} trigger="click">
<Dropdown
menu={menu}
trigger={["click"]}
open={open}
onOpenChange={(nextOpen) => setOpen(nextOpen)}
getPopupContainer={(triggerNode) => triggerNode?.parentElement ?? document.body}
popupRender={(menus) => (
<div
// This makes the whole dropdown (menu + footer) look like one panel in both light/dark.
style={{
background: token.colorBgElevated,
borderRadius: token.borderRadiusLG,
boxShadow: token.boxShadowSecondary,
overflow: "hidden",
minWidth: 180
}}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{menus}
<Divider style={{ margin: 0 }} />
<div style={{ padding: token.paddingXS }}>
<Space.Compact style={{ width: "100%" }}>
<InputNumber
value={customPercent}
min={0}
max={100}
precision={0}
controls={false}
style={{ width: "100%" }}
formatter={(v) => (v === null || v === undefined ? "" : `${v}%`)}
parser={(v) =>
String(v ?? "")
.replace("%", "")
.trim()
}
onChange={(v) => setCustomPercent(v ?? 0)}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Enter") {
e.preventDefault();
applyCustom();
}
}}
/>
<Button type="primary" onClick={applyCustom}>
{t("general.labels.apply")}
</Button>
</Space.Compact>
</div>
</div>
)}
>
<Space>
%
<DownOutlined />
% <DownOutlined />
</Space>
</Dropdown>
);

View File

@@ -13,6 +13,13 @@ import PartsOrderModalPriceChange from "./parts-order-modal-price-change.compone
import DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
import { selectIsPartsEntry } from "../../redux/application/application.selectors.js";
const PriceInputWrapper = ({ value, onChange, form, field }) => (
<Space.Compact style={{ width: "100%" }}>
<PartsOrderModalPriceChange form={form} field={field} />
<CurrencyInput style={{ flex: 1 }} value={value} onChange={onChange} />
</Space.Compact>
);
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
isPartsEntry: selectIsPartsEntry
@@ -59,7 +66,7 @@ export function PartsOrderModalComponent({
return (
<div>
<Form.Item name="returnfrombill" hidden>
<Input />
<Input type="hidden" />
</Form.Item>
<LayoutFormRow grow noDivider>
<Form.Item
@@ -199,10 +206,7 @@ export function PartsOrderModalComponent({
key={`${index}act_price`}
name={[field.name, "act_price"]}
>
<Space.Compact style={{ width: "100%" }}>
<PartsOrderModalPriceChange form={form} field={field} />
<CurrencyInput style={{ flex: 1 }} />
</Space.Compact>
<PriceInputWrapper form={form} field={field} />
</Form.Item>
{isReturn && (
<Form.Item

View File

@@ -17,7 +17,6 @@ import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { GenerateDocument } from "../../utils/RenderTemplate";
import { TemplateList } from "../../utils/TemplateConstants";
import AlertComponent from "../alert/alert.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import PartsOrderModalComponent from "./parts-order-modal.component";
import axios from "axios";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
@@ -66,7 +65,7 @@ export function PartsOrderModalContainer({
const sendTypeState = useState("e");
const sendType = sendTypeState[0];
const { loading, error, data } = useQuery(QUERY_ALL_VENDORS_FOR_ORDER, {
const { error, data } = useQuery(QUERY_ALL_VENDORS_FOR_ORDER, {
skip: !open,
variables: { jobId: jobId },
fetchPolicy: "network-only",
@@ -89,20 +88,11 @@ export function PartsOrderModalContainer({
return {
...p,
job_line_id: jobLineId
job_line_id: jobLineId,
...(isReturn && { cm_received: false })
};
});
const missingIdx = forcedLines.findIndex((l) => !l?.job_line_id);
if (missingIdx !== -1) {
notification.error({
title: t("parts_orders.errors.creating"),
description: `Missing job_line_id for parts line #${missingIdx + 1}`
});
setSaving(false);
return;
}
let insertResult;
try {
insertResult = await insertPartOrder({
@@ -371,6 +361,7 @@ export function PartsOrderModalContainer({
}
}, [open, linesToOrder, form]);
//This used to have a loading component spinner for the vendor data. With Apollo 4, the NetworkState isn't emitting correctly, so loading just gets set to true the second time, and no longer works as expected.
return (
<Modal
open={open}
@@ -389,9 +380,6 @@ export function PartsOrderModalContainer({
>
{error ? <AlertComponent title={error.message} type="error" /> : null}
<Form form={form} layout="vertical" autoComplete="no" onFinish={handleFinish} initialValues={initialValues}>
{loading ? (
<LoadingSpinner />
) : (
<PartsOrderModalComponent
form={form}
vendorList={data?.vendors || []}
@@ -400,7 +388,6 @@ export function PartsOrderModalContainer({
preferredMake={data && data.jobs[0] && data.jobs[0].v_make_desc}
job={job}
/>
)}
</Form>
</Modal>
);

Some files were not shown because too many files have changed in this diff Show More