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.development.local
.env.test.local .env.test.local
.env.production.local .env.production.local
bodyshop_translations.babel .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_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com
VITE_APP_AMP_KEY=6228a598e57cd66875cfd41604f1f891 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_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com VITE_APP_AMP_URL=https://vp8k908qy2.execute-api.ca-central-1.amazonaws.com
VITE_APP_AMP_KEY=46b1193a867d4e3131ae4c3a64a3fc78 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 # Sentry Config File
.sentryclirc .sentryclirc
/dev-dist /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, "private": true,
"proxy": "http://localhost:4000", "proxy": "http://localhost:4000",
"dependencies": { "dependencies": {
"@amplitude/analytics-browser": "^2.33.4", "@amplitude/analytics-browser": "^2.34.0",
"@ant-design/pro-layout": "^7.22.6", "@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", "@emotion/is-prop-valid": "^1.4.0",
"@fingerprintjs/fingerprintjs": "^5.0.1", "@fingerprintjs/fingerprintjs": "^5.0.1",
"@firebase/analytics": "^0.10.19", "@firebase/analytics": "^0.10.19",
@@ -21,14 +25,14 @@
"@jsreport/browser-client": "^3.1.0", "@jsreport/browser-client": "^3.1.0",
"@reduxjs/toolkit": "^2.11.2", "@reduxjs/toolkit": "^2.11.2",
"@sentry/cli": "^3.1.0", "@sentry/cli": "^3.1.0",
"@sentry/react": "^10.35.0", "@sentry/react": "^10.38.0",
"@sentry/vite-plugin": "^4.7.0", "@sentry/vite-plugin": "^4.8.0",
"@splitsoftware/splitio-react": "^2.6.1", "@splitsoftware/splitio-react": "^2.6.1",
"@tanem/react-nprogress": "^5.0.56", "@tanem/react-nprogress": "^5.0.58",
"antd": "^6.2.1", "antd": "^6.2.2",
"apollo-link-logger": "^3.0.0", "apollo-link-logger": "^3.0.0",
"autosize": "^6.0.1", "autosize": "^6.0.1",
"axios": "^1.13.2", "axios": "^1.13.4",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"css-box-model": "^1.2.1", "css-box-model": "^1.2.1",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
@@ -38,31 +42,30 @@
"env-cmd": "^11.0.0", "env-cmd": "^11.0.0",
"exifr": "^7.1.3", "exifr": "^7.1.3",
"graphql": "^16.12.0", "graphql": "^16.12.0",
"graphql-ws": "^6.0.6", "graphql-ws": "^6.0.7",
"i18next": "^25.8.0", "i18next": "^25.8.0",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"immutability-helper": "^3.1.1", "immutability-helper": "^3.1.1",
"libphonenumber-js": "^1.12.34", "libphonenumber-js": "^1.12.36",
"lightningcss": "^1.31.0", "lightningcss": "^1.31.1",
"logrocket": "^11.0.0", "logrocket": "^12.0.0",
"markerjs2": "^2.32.7", "markerjs2": "^2.32.7",
"memoize-one": "^6.0.0", "memoize-one": "^6.0.0",
"normalize-url": "^8.1.1", "normalize-url": "^8.1.1",
"object-hash": "^3.0.0", "object-hash": "^3.0.0",
"phone": "^3.1.69", "phone": "^3.1.70",
"posthog-js": "^1.335.0", "posthog-js": "^1.336.4",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"query-string": "^9.3.1", "query-string": "^9.3.1",
"raf-schd": "^4.0.3", "raf-schd": "^4.0.3",
"react": "^19.2.3", "react": "^19.2.4",
"react-big-calendar": "^1.19.4", "react-big-calendar": "^1.19.4",
"react-color": "^2.19.3", "react-color": "^2.19.3",
"react-cookie": "^8.0.1", "react-cookie": "^8.0.1",
"react-dom": "^19.2.3", "react-dom": "^19.2.4",
"react-drag-listview": "^2.0.0",
"react-grid-gallery": "^1.0.1", "react-grid-gallery": "^1.0.1",
"react-grid-layout": "^2.2.2", "react-grid-layout": "^2.2.2",
"react-i18next": "^16.5.3", "react-i18next": "^16.5.4",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-image-lightbox": "^5.1.4", "react-image-lightbox": "^5.1.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
@@ -71,10 +74,10 @@
"react-product-fruits": "^2.2.62", "react-product-fruits": "^2.2.62",
"react-redux": "^9.2.0", "react-redux": "^9.2.0",
"react-resizable": "^3.1.3", "react-resizable": "^3.1.3",
"react-router-dom": "^7.12.0", "react-router-dom": "^7.13.0",
"react-sticky": "^6.0.3", "react-sticky": "^6.0.3",
"react-virtuoso": "^4.18.1", "react-virtuoso": "^4.18.1",
"recharts": "^3.6.0", "recharts": "^3.7.0",
"redux": "^5.0.1", "redux": "^5.0.1",
"redux-actions": "^3.0.3", "redux-actions": "^3.0.3",
"redux-persist": "^6.0.0", "redux-persist": "^6.0.0",
@@ -82,7 +85,7 @@
"redux-state-sync": "^3.1.4", "redux-state-sync": "^3.1.4",
"reselect": "^5.1.1", "reselect": "^5.1.1",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
"sass": "^1.97.2", "sass": "^1.97.3",
"socket.io-client": "^4.8.3", "socket.io-client": "^4.8.3",
"styled-components": "^6.3.8", "styled-components": "^6.3.8",
"vite-plugin-ejs": "^1.7.0", "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'", "postinstall": "echo 'when updating react-big-calendar, remember to check to localizer in the calendar wrapper'",
"analyze": "source-map-explorer 'build/static/js/*.js'", "analyze": "source-map-explorer 'build/static/js/*.js'",
"start": "vite", "start": "vite",
"build": "dotenvx run --env-file=.env.development.imex -- vite build", "build": "vite build",
"start:imex": "dotenvx run --env-file=.env.development.imex -- vite", "build:dev:imex": "dotenvx run --env-file=.env.development.imex --env-file=.env.development.local.overrides -- vite build",
"start:rome": "dotenvx run --env-file=.env.development.rome -- vite", "build:dev:rome": "dotenvx run --env-file=.env.development.rome --env-file=.env.development.local.overrides -- vite build",
"preview:imex": "dotenvx run --env-file=.env.development.imex -- vite preview", "build:test:imex": "dotenvx run --env-file=.env.test.imex -- vite build",
"preview:rome": "dotenvx run --env-file=.env.development.rome -- vite preview", "build:test:rome": "dotenvx run --env-file=.env.test.rome -- vite build",
"build:test:imex": "env-cmd -f .env.test.imex -- npm run build", "build:production:imex": "env-cmd -f .env.production.imex vite build",
"build:test:rome": "env-cmd -f .env.test.rome -- npm run build", "build:production:rome": "env-cmd -f .env.production.rome vite build",
"build:production:imex": "env-cmd -f .env.production.imex -- npm run build", "start:imex": "dotenvx run --env-file=.env.development.imex --env-file=.env.development.local.overrides -- vite",
"build:production:rome": "env-cmd -f .env.production.rome -- npm run build", "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 .", "madge": "madge --image ./madge-graph.svg --extensions js,jsx,ts,tsx --circular .",
"eulaize": "node src/utils/eulaize.js", "eulaize": "node src/utils/eulaize.js",
"test:unit": "vitest run", "test:unit": "vitest run",
@@ -151,7 +156,7 @@
"eslint": "^9.39.2", "eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-compiler": "^19.1.0-rc.2", "eslint-plugin-react-compiler": "^19.1.0-rc.2",
"globals": "^17.1.0", "globals": "^17.2.0",
"jsdom": "^27.4.0", "jsdom": "^27.4.0",
"memfs": "^4.56.10", "memfs": "^4.56.10",
"os-browserify": "^0.3.0", "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 { useEffect, useMemo } from "react";
import { CookiesProvider } from "react-cookie"; import { CookiesProvider } from "react-cookie";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect, useSelector } from "react-redux"; import { useDispatch, useSelector } from "react-redux";
import { createStructuredSelector } from "reselect";
import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component"; import GlobalLoadingBar from "../components/global-loading-bar/global-loading-bar.component";
import { setDarkMode } from "../redux/application/application.actions"; import { setDarkMode } from "../redux/application/application.actions";
import { selectDarkMode } from "../redux/application/application.selectors"; import { selectDarkMode } from "../redux/application/application.selectors";
@@ -28,93 +27,102 @@ const config = {
function SplitClientProvider({ children }) { function SplitClientProvider({ children }) {
const imexshopid = useSelector((state) => state.user.imexshopid); const imexshopid = useSelector((state) => state.user.imexshopid);
const splitClient = useSplitClient({ key: imexshopid || "anon" }); const splitClient = useSplitClient({ key: imexshopid || "anon" });
useEffect(() => { useEffect(() => {
if (splitClient && imexshopid) { if (import.meta.env.DEV && splitClient && imexshopid) {
console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`); console.log(`Split client initialized with key: ${imexshopid}, isReady: ${splitClient.isReady}`);
} }
}, [splitClient, imexshopid]); }, [splitClient, imexshopid]);
return children; return children;
} }
const mapStateToProps = createStructuredSelector({ function AppContainer() {
currentUser: selectCurrentUser
});
const mapDispatchToProps = (dispatch) => ({
setDarkMode: (isDarkMode) => dispatch(setDarkMode(isDarkMode)),
signOutStart: () => dispatch(signOutStart())
});
function AppContainer({ currentUser, setDarkMode, signOutStart }) {
const { t } = useTranslation(); const { t } = useTranslation();
const dispatch = useDispatch();
const currentUser = useSelector(selectCurrentUser);
const isDarkMode = useSelector(selectDarkMode); const isDarkMode = useSelector(selectDarkMode);
const theme = useMemo(() => getTheme(isDarkMode), [isDarkMode]); 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 // Global seamless logout listener with redirect to /signin
useEffect(() => { useEffect(() => {
const handleSeamlessLogout = (event) => { const handleSeamlessLogout = (event) => {
if (event.data?.type !== "seamlessLogoutRequest") return; 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) { if (currentUser?.authorized !== true) {
window.parent.postMessage( window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "already_logged_out" }, targetOrigin);
{ type: "seamlessLogoutResponse", status: "already_logged_out" },
requestOrigin || "*"
);
return; return;
} }
signOutStart(); dispatch(signOutStart());
window.parent.postMessage({ type: "seamlessLogoutResponse", status: "logged_out" }, requestOrigin || "*"); window.parent?.postMessage({ type: "seamlessLogoutResponse", status: "logged_out" }, targetOrigin);
}; };
window.addEventListener("message", handleSeamlessLogout); window.addEventListener("message", handleSeamlessLogout);
return () => { return () => {
window.removeEventListener("message", handleSeamlessLogout); 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(() => { useEffect(() => {
document.documentElement.setAttribute("data-theme", isDarkMode ? "dark" : "light"); document.documentElement.dataset.theme = isDarkMode ? "dark" : "light";
return () => document.documentElement.removeAttribute("data-theme");
}, [isDarkMode]); }, [isDarkMode]);
// Sync darkMode with localStorage // Sync darkMode with localStorage
useEffect(() => { useEffect(() => {
if (currentUser?.uid) { const uid = currentUser?.uid;
const savedMode = localStorage.getItem(`dark-mode-${currentUser.uid}`);
if (savedMode !== null) { if (!uid) {
setDarkMode(JSON.parse(savedMode)); dispatch(setDarkMode(false));
} else { return;
setDarkMode(false);
}
} else {
setDarkMode(false);
} }
}, [currentUser?.uid, setDarkMode]);
const key = `dark-mode-${uid}`;
const raw = localStorage.getItem(key);
if (raw == null) {
dispatch(setDarkMode(false));
return;
}
try {
dispatch(setDarkMode(Boolean(JSON.parse(raw))));
} catch {
dispatch(setDarkMode(false));
}
}, [currentUser?.uid, dispatch]);
// Persist darkMode // Persist darkMode
useEffect(() => { useEffect(() => {
if (currentUser?.uid) { const uid = currentUser?.uid;
localStorage.setItem(`dark-mode-${currentUser.uid}`, JSON.stringify(isDarkMode)); if (!uid) return;
}
localStorage.setItem(`dark-mode-${uid}`, JSON.stringify(isDarkMode));
}, [isDarkMode, currentUser?.uid]); }, [isDarkMode, currentUser?.uid]);
return ( return (
<CookiesProvider> <CookiesProvider>
<ApolloProvider client={client}> <ApolloProvider client={client}>
<ConfigProvider <ConfigProvider input={antdInput} locale={enLocale} theme={theme} form={antdForm}>
input={{ autoComplete: "new-password" }}
locale={enLocale}
theme={theme}
form={{
validateMessages: {
required: t("general.validation.required", { label: "${label}" })
}
}}
>
<GlobalLoadingBar /> <GlobalLoadingBar />
<SplitFactoryProvider config={config}> <SplitFactoryProvider config={config}>
<SplitClientProvider> <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) { if (currentUser.authorized && bodyshop) {
client.setAttribute("imexshopid", bodyshop.imexshopid); client.setAttribute("imexshopid", bodyshop.imexshopid);
if ( if (client.getTreatment("LogRocket_Tracking") === "on") {
client.getTreatment("LogRocket_Tracking") === "on" ||
window.location.hostname ===
InstanceRenderMgr({
imex: "beta.imex.online",
rome: "beta.romeonline.io"
})
) {
console.log("LR Start"); console.log("LR Start");
LogRocket.init( LogRocket.init(
InstanceRenderMgr({ InstanceRenderMgr({

View File

@@ -446,3 +446,32 @@
//.rbc-time-header-gutter { //.rbc-time-header-gutter {
// padding: 0; // 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} refetch={refetch}
/> />
{bodyshop.accountingconfig && bodyshop.accountingconfig.qbo && <QboAuthorizeComponent />} {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> </Space>
} }
> >
<Table <Table
loading={loading} loading={loading}
dataSource={dataSource} dataSource={dataSource}
pagination={{ placement: "top", pageSize: exportPageLimit }} pagination={{ placement: "top", pageSize: exportPageLimit, showSizeChanger: false }}
columns={columns} columns={columns}
rowKey="id" rowKey="id"
onChange={handleTableChange} onChange={handleTableChange}

View File

@@ -182,14 +182,20 @@ export function AccountingPayablesTableComponent({ bodyshop, loading, payments,
refetch={refetch} refetch={refetch}
/> />
{bodyshop.accountingconfig && bodyshop.accountingconfig.qbo && <QboAuthorizeComponent />} {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> </Space>
} }
> >
<Table <Table
loading={loading} loading={loading}
dataSource={dataSource} dataSource={dataSource}
pagination={{ placement: "top", pageSize: exportPageLimit }} pagination={{ placement: "top", pageSize: exportPageLimit, showSizeChanger: false }}
columns={columns} columns={columns}
rowKey="id" rowKey="id"
onChange={handleTableChange} onChange={handleTableChange}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,119 +17,137 @@ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
currentUser: selectCurrentUser currentUser: selectCurrentUser
}); });
const mapDispatchToProps = () => ({ const mapDispatchToProps = () => ({});
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(mapStateToProps, mapDispatchToProps)(BilllineAddInventory); export default connect(mapStateToProps, mapDispatchToProps)(BilllineAddInventory);
export function BilllineAddInventory({ currentUser, bodyshop, billline, disabled, jobid }) { export function BilllineAddInventory({ currentUser, bodyshop, billline, disabled, jobid }) {
const [loading, setLoading] = useState(false); 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 [insertInventoryLine] = useMutation(INSERT_INVENTORY_AND_CREDIT);
const notification = useNotification(); const notification = useNotification();
const inventoryCount = billline?.inventories?.length ?? 0;
const quantity = billline?.quantity ?? 0;
const addToInventory = async () => { const addToInventory = async () => {
setLoading(true); if (loading) return;
//Check to make sure there are no existing items already in the inventory. // Defensive: row identity can transiently desync during remove/add reindexing.
if (!billline) {
const cm = {
vendorid: bodyshop.inhousevendorid,
invoice_number: "ih",
jobid: jobid,
isinhouse: true,
is_credit_memo: true,
date: dayjs().format("YYYY-MM-DD"),
federal_tax_rate: bodyshop.bill_tax_rates.federal_tax_rate,
state_tax_rate: bodyshop.bill_tax_rates.state_tax_rate,
local_tax_rate: bodyshop.bill_tax_rates.local_tax_rate,
total: 0,
billlines: [
{
actual_price: billline.actual_price,
actual_cost: billline.actual_cost,
quantity: billline.quantity,
line_desc: billline.line_desc,
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
}
}
]
};
cm.total = CalculateBillTotal(cm).enteredTotal.getAmount() / 100;
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.
joblineStatus: bodyshop.md_order_statuses.default_returned,
inv: {
shopid: bodyshop.id,
billlineid: billline.id,
actual_price: billline.actual_price,
actual_cost: billline.actual_cost,
quantity: billline.quantity,
line_desc: billline.line_desc
},
cm: { ...cm, billlines: { data: cm.billlines } }, //Fix structure for apollo insert.
pol: {
returnfrombill: billid,
vendorid: bodyshop.inhousevendorid,
deliver_by: dayjs().format("YYYY-MM-DD"),
parts_order_lines: {
data: [
{
line_desc: billline.line_desc,
act_price: billline.actual_price,
cost: billline.actual_cost,
quantity: billline.quantity,
job_line_id: billline.joblineid === "noline" ? null : billline.joblineid,
part_type: billline.jobline && billline.jobline.part_type,
cm_received: true
}
]
},
order_date: "2022-06-01",
orderedby: currentUser.email,
jobid: jobid,
user_email: currentUser.email,
return: true,
status: "Ordered"
}
},
refetchQueries: ["QUERY_BILL_BY_PK"]
});
if (!insertResult.errors) {
notification.success({
title: t("inventory.successes.inserted")
});
} else {
notification.error({ notification.error({
title: t("inventory.errors.inserting", { title: t("inventory.errors.inserting", { error: "Bill line is missing (please try again)." })
error: JSON.stringify(insertResult.errors)
})
}); });
return;
} }
setLoading(false); setLoading(true);
try {
const taxes = billline?.applicable_taxes ?? {};
const cm = {
vendorid: bodyshop.inhousevendorid,
invoice_number: "ih",
jobid: jobid,
isinhouse: true,
is_credit_memo: true,
date: dayjs().format("YYYY-MM-DD"),
federal_tax_rate: bodyshop.bill_tax_rates.federal_tax_rate,
state_tax_rate: bodyshop.bill_tax_rates.state_tax_rate,
local_tax_rate: bodyshop.bill_tax_rates.local_tax_rate,
total: 0,
billlines: [
{
actual_price: billline.actual_price,
actual_cost: billline.actual_cost,
quantity: billline.quantity,
line_desc: billline.line_desc,
cost_center: billline.cost_center,
deductedfromlbr: billline.deductedfromlbr,
applicable_taxes: {
local: taxes.local,
state: taxes.state,
federal: taxes.federal
}
}
]
};
cm.total = CalculateBillTotal(cm).enteredTotal.getAmount() / 100;
const insertResult = await insertInventoryLine({
variables: {
joblineId: billline.joblineid === "noline" ? billline.id : billline.joblineid,
joblineStatus: bodyshop.md_order_statuses.default_returned,
inv: {
shopid: bodyshop.id,
billlineid: billline.id,
actual_price: billline.actual_price,
actual_cost: billline.actual_cost,
quantity: billline.quantity,
line_desc: billline.line_desc
},
cm: { ...cm, billlines: { data: cm.billlines } },
pol: {
returnfrombill: billid,
vendorid: bodyshop.inhousevendorid,
deliver_by: dayjs().format("YYYY-MM-DD"),
parts_order_lines: {
data: [
{
line_desc: billline.line_desc,
act_price: billline.actual_price,
cost: billline.actual_cost,
quantity: billline.quantity,
job_line_id: billline.joblineid === "noline" ? null : billline.joblineid,
part_type: billline.jobline && billline.jobline.part_type,
cm_received: true
}
]
},
order_date: "2022-06-01",
orderedby: currentUser.email,
jobid: jobid,
user_email: currentUser.email,
return: true,
status: "Ordered"
}
},
refetchQueries: ["QUERY_BILL_BY_PK"]
});
if (!insertResult?.errors?.length) {
notification.success({
title: t("inventory.successes.inserted")
});
} else {
notification.error({
title: t("inventory.errors.inserting", {
error: JSON.stringify(insertResult.errors)
})
});
}
} catch (err) {
notification.error({
title: t("inventory.errors.inserting", {
error: err?.message || String(err)
})
});
} finally {
setLoading(false);
}
}; };
return ( return (
<Tooltip title={t("inventory.actions.addtoinventory")}> <Tooltip title={t("inventory.actions.addtoinventory")}>
<Button <Button
icon={<FileAddFilled />}
loading={loading} loading={loading}
disabled={disabled || billline?.inventories?.length >= billline.quantity} disabled={disabled || inventoryCount >= quantity}
onClick={addToInventory} onClick={addToInventory}
> >
<FileAddFilled /> {inventoryCount > 0 && <div>({inventoryCount} in inv)</div>}
{billline?.inventories?.length > 0 && <div>({billline?.inventories?.length} in inv)</div>}
</Button> </Button>
</Tooltip> </Tooltip>
); );

View File

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

View File

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

View File

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

View File

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

View File

@@ -128,7 +128,7 @@ function ChatConversationListComponent({ conversationList, selectedConversation,
onClick={() => setSelectedConversation(item.id)} onClick={() => setSelectedConversation(item.id)}
className={`chat-list-item ${item.id === selectedConversation ? "chat-list-selected-conversation" : ""}`} 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: "70%", textAlign: "left" }}>{cardContentLeft}</div>
<div style={{ display: "inline-block", width: "30%", textAlign: "right" }}>{cardContentRight}</div> <div style={{ display: "inline-block", width: "30%", textAlign: "right" }}>{cardContentRight}</div>
</Card> </Card>

View File

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

View File

@@ -47,7 +47,6 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
const [getConversations, { loading, data, refetch, called }] = useLazyQuery(CONVERSATION_LIST_QUERY, { const [getConversations, { loading, data, refetch, called }] = useLazyQuery(CONVERSATION_LIST_QUERY, {
fetchPolicy: "network-only", fetchPolicy: "network-only",
nextFetchPolicy: "network-only", nextFetchPolicy: "network-only",
notifyOnNetworkStatusChange: true,
...(pollInterval > 0 ? { pollInterval } : {}) ...(pollInterval > 0 ? { pollInterval } : {})
}); });
@@ -108,9 +107,12 @@ export function ChatPopupComponent({ chatVisible, selectedConversation, toggleCh
hasLoadedConversationsOnceRef.current = true; hasLoadedConversationsOnceRef.current = true;
getConversations({ variables: { offset: 0 } }).catch((err) => { getConversations({ variables: { offset: 0 } }).catch((err) => {
console.error(`Error fetching conversations: ${err?.message || ""}`, 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 () => { const handleManualRefresh = async () => {
try { try {

View File

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

View File

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

View File

@@ -123,6 +123,7 @@ export default function ContractsJobsComponent({ loading, data, selectedJob, han
placeholder={t("general.labels.search")} placeholder={t("general.labels.search")}
value={state.search} value={state.search}
onChange={(e) => setState({ ...state, search: e.target.value })} 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={() => setContractFinderContext()}>{t("contracts.actions.find")}</Button>
<Button onClick={() => refetch()}> <Button onClick={() => refetch()} icon={<SyncOutlined />} />
<SyncOutlined />
</Button>
<Input.Search <Input.Search
placeholder={search.searh || t("general.labels.search")} placeholder={search.searh || t("general.labels.search")}
onSearch={(value) => { onSearch={(value) => {
const updatedSearch = { ...search, search: value }; const updatedSearch = { ...search, search: value };
history({ search: queryString.stringify(updatedSearch) }); history({ search: queryString.stringify(updatedSearch) });
}} }}
enterButton
/> />
</Space> </Space>
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -23,13 +23,13 @@ export default connect(mapStateToProps, mapDispatchToProps)(DmsCustomerSelector)
* @constructor * @constructor
*/ */
export function DmsCustomerSelector(props) { export function DmsCustomerSelector(props) {
const { bodyshop, jobid, socket, rrOptions = {} } = props; const { bodyshop, jobid, job, socket, rrOptions = {} } = props;
// Centralized "mode" (provider + transport) // Centralized "mode" (provider + transport)
const mode = props.mode; const mode = props.mode;
// Stable base props for children // 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) { switch (mode) {
case DMS_MAP.reynolds: { 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} /> render: (_t, r) => <Checkbox disabled checked={r.vinOwner} />
}, },
{ {
title: t("jobs.fields.dms.name1"), title: t("jobs.fields.dms.first_name"),
dataIndex: ["customerName", "firstName"], dataIndex: ["customerName", "firstName"],
key: "firstName", key: "firstName",
sorter: (a, b) => alphaSort(a.customerName?.firstName, b.customerName?.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"], dataIndex: ["customerName", "lastName"],
key: "lastName", key: "lastName",
sorter: (a, b) => alphaSort(a.customerName?.lastName, b.customerName?.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 { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { alphaSort } from "../../utils/sorters"; import { alphaSort } from "../../utils/sorters";
@@ -47,6 +47,7 @@ const rrAddressToString = (addr) => {
export default function RRCustomerSelector({ export default function RRCustomerSelector({
jobid, jobid,
socket, socket,
job,
rrOpenRoLimit = false, rrOpenRoLimit = false,
onRrOpenRoFinished, onRrOpenRoFinished,
rrValidationPending = false, rrValidationPending = false,
@@ -59,15 +60,26 @@ export default function RRCustomerSelector({
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
// Show dialog automatically when validation is pending // Show dialog automatically when validation is pending
// BUT: skip this for early RO flow (job already has dms_id)
useEffect(() => { useEffect(() => {
if (rrValidationPending) setOpen(true); if (rrValidationPending && !job?.dms_id) {
}, [rrValidationPending]); setOpen(true);
}
}, [rrValidationPending, job?.dms_id]);
// Listen for RR customer selection list // Listen for RR customer selection list
useEffect(() => { useEffect(() => {
if (!socket) return; if (!socket) return;
const handleRrSelectCustomer = (list) => { const handleRrSelectCustomer = (list) => {
const normalized = normalizeRrList(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); setOpen(true);
setCustomerList(normalized); setCustomerList(normalized);
const firstOwner = normalized.find((r) => r.vinOwner)?.custNo; const firstOwner = normalized.find((r) => r.vinOwner)?.custNo;
@@ -127,6 +139,10 @@ export default function RRCustomerSelector({
}); });
}; };
const handleClose = () => {
setOpen(false);
};
const refreshRrSearch = () => { const refreshRrSearch = () => {
setRefreshing(true); setRefreshing(true);
const to = setTimeout(() => setRefreshing(false), 12000); const to = setTimeout(() => setRefreshing(false), 12000);
@@ -141,8 +157,6 @@ export default function RRCustomerSelector({
socket.emit("rr-export-job", { jobId: jobid }); socket.emit("rr-export-job", { jobId: jobid });
}; };
if (!open) return null;
const columns = [ const columns = [
{ title: t("jobs.fields.dms.id"), dataIndex: "custNo", key: "custNo" }, { title: t("jobs.fields.dms.id"), dataIndex: "custNo", key: "custNo" },
{ {
@@ -169,8 +183,45 @@ export default function RRCustomerSelector({
return !rrOwnerSet.has(String(record.custNo)); 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 (
<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 ( return (
<Col span={24}> <Modal
open={open}
onCancel={handleClose}
footer={null}
width={800}
title={t("dms.selectCustomer")}
>
<Table <Table
title={() => ( title={() => (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}> <div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
@@ -196,8 +247,8 @@ export default function RRCustomerSelector({
/> />
)} )}
{/* Validation step banner */} {/* Validation step banner - only show for NON-early RO flow (legacy) */}
{rrValidationPending && ( {rrValidationPending && !job?.dms_id && (
<Alert <Alert
type="info" type="info"
showIcon showIcon
@@ -262,6 +313,6 @@ export default function RRCustomerSelector({
getCheckboxProps: (record) => ({ disabled: rrDisableRow(record) }) getCheckboxProps: (record) => ({ disabled: rrDisableRow(record) })
}} }}
/> />
</Col> </Modal>
); );
} }

View File

@@ -69,7 +69,7 @@ export function DmsLogEvents({
return { return {
key: idx, key: idx,
color: logLevelColor(level), color: logLevelColor(level),
children: ( content: (
<Space orientation="vertical" size={4} style={{ display: "flex" }}> <Space orientation="vertical" size={4} style={{ display: "flex" }}>
{/* Row 1: summary + inline "Details" toggle */} {/* Row 1: summary + inline "Details" toggle */}
<Space wrap align="start"> <Space wrap align="start">
@@ -113,7 +113,7 @@ export function DmsLogEvents({
[logs, openSet, colorizeJson, isDarkMode, showDetails] [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"]} name={[field.name, "name"]}
rules={[{ required: true }]} 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) => ( {bodyshop.cdk_configuration?.payers?.map((payer) => (
<Select.Option key={payer.name}>{payer.name}</Select.Option> <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> <Typography.Title>=</Typography.Title>
<Statistic <Statistic
title={t("jobs.labels.dms.notallocated")} 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()} value={discrep.toFormat()}
/> />
<Button disabled={disablePost} htmlType="submit"> <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 ( return (
<Card title={t("jobs.labels.dms.postingform")}> <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={form} form={form}
layout="vertical" layout="vertical"
@@ -218,96 +228,96 @@ export default function RRPostForm({
initialValues={initialValues} initialValues={initialValues}
> >
<Row gutter={[16, 12]} align="bottom"> <Row gutter={[16, 12]} align="bottom">
{/* Advisor + inline Refresh */} {/* Advisor + inline Refresh - Only show if no early RO */}
<Col xs={24} sm={24} md={12} lg={8}> {!hasEarlyRO && (
<Form.Item label={t("jobs.fields.dms.advisor")} required> <Col xs={24} sm={24} md={12} lg={8}>
<Space.Compact block> <Form.Item label={t("jobs.fields.dms.advisor")} required>
<Form.Item <Space.Compact block>
name="advisorNo" <Form.Item
noStyle name="advisorNo"
rules={[{ required: true, message: t("general.validation.required") }]} noStyle
> rules={[{ required: true, message: t("general.validation.required") }]}
<Select >
style={{ flex: 1 }} <Select
loading={advLoading} style={{ flex: 1 }}
allowClear loading={advLoading}
placeholder={t("general.actions.select", "Select...")} allowClear
popupMatchSelectWidth placeholder={t("general.actions.select", "Select...")}
options={advisors popupMatchSelectWidth
.map((a) => { options={advisors
const value = getAdvisorNumber(a); .map((a) => {
if (value == null) return null; const value = getAdvisorNumber(a);
return { value: String(value), label: getAdvisorLabel(a) || String(value) }; if (value == null) return null;
}) return { value: String(value), label: getAdvisorLabel(a) || String(value) };
.filter(Boolean)} })
notFoundContent={advLoading ? t("general.labels.loading") : t("general.labels.none")} .filter(Boolean)}
/> notFoundContent={advLoading ? t("general.labels.loading") : t("general.labels.none")}
</Form.Item> />
<Tooltip title={t("general.actions.refresh")}> </Form.Item>
<Button <Tooltip title={t("general.actions.refresh")}>
aria-label={t("general.actions.refresh")}
icon={<ReloadOutlined />}
onClick={() => fetchRrAdvisors(true)}
loading={advLoading}
/>
</Tooltip>
</Space.Compact>
</Form.Item>
</Col>
{/* RR OpCode (prefix / base / suffix) */}
<Col xs={24} sm={12} md={12} lg={8}>
<Form.Item
required
label={
<Space size="small" align="center">
{t("jobs.fields.dms.rr_opcode", "RR OpCode")}
{isCustomOpCode && (
<Button <Button
type="link" aria-label={t("general.actions.refresh")}
size="small" icon={<ReloadOutlined />}
icon={<RollbackOutlined />} onClick={() => fetchRrAdvisors(true)}
onClick={handleResetOpCode} loading={advLoading}
style={{ padding: 0 }} />
> </Tooltip>
{t("jobs.fields.dms.rr_opcode_reset", "Reset")} </Space.Compact>
</Button> </Form.Item>
)} </Col>
</Space> )}
}
> {/* RR OpCode (prefix / base / suffix) - Only show if no early RO */}
<Space.Compact block> {!hasEarlyRO && (
<Form.Item name="opPrefix" noStyle> <Col xs={24} sm={12} md={12} lg={8}>
<Input <Form.Item
allowClear required
maxLength={4} label={
style={{ width: "30%" }} <Space size="small" align="center">
placeholder={t("jobs.fields.dms.rr_opcode_prefix", "Prefix")} {t("jobs.fields.dms.rr_opcode", "RR OpCode")}
/> {isCustomOpCode && (
</Form.Item> <Button
<Form.Item type="link"
name="opBase" size="small"
noStyle icon={<RollbackOutlined />}
rules={[{ required: true, message: t("general.validation.required") }]} onClick={handleResetOpCode}
> style={{ padding: 0 }}
<Input >
allowClear {t("jobs.fields.dms.rr_opcode_reset", "Reset")}
maxLength={10} </Button>
style={{ width: "40%" }} )}
placeholder={t("jobs.fields.dms.rr_opcode_base", "Base")} </Space>
/> }
</Form.Item> >
<Form.Item name="opSuffix" noStyle> <Space.Compact block>
<Input <Form.Item name="opPrefix" noStyle>
allowClear <Input
maxLength={4} allowClear
style={{ width: "30%" }} maxLength={4}
placeholder={t("jobs.fields.dms.rr_opcode_suffix", "Suffix")} style={{ width: "30%" }}
/> placeholder={t("jobs.fields.dms.rr_opcode_prefix", "Prefix")}
</Form.Item> />
</Space.Compact> </Form.Item>
</Form.Item> <Form.Item name="opBase" noStyle rules={[{ required: true }]}>
</Col> <Input
allowClear
maxLength={10}
style={{ width: "40%" }}
placeholder={t("jobs.fields.dms.rr_opcode_base", "Base")}
/>
</Form.Item>
<Form.Item name="opSuffix" noStyle>
<Input
allowClear
maxLength={4}
style={{ width: "30%" }}
placeholder={t("jobs.fields.dms.rr_opcode_suffix", "Suffix")}
/>
</Form.Item>
</Space.Compact>
</Form.Item>
</Col>
)}
<Col xs={12} sm={8} md={6} lg={4}> <Col xs={12} sm={8} md={6} lg={4}>
<Form.Item name="kmin" label={t("jobs.fields.kmin")} initialValue={job?.kmin} rules={[{ required: true }]}> <Form.Item name="kmin" label={t("jobs.fields.kmin")} initialValue={job?.kmin} rules={[{ required: true }]}>
@@ -355,13 +365,14 @@ export default function RRPostForm({
{/* Validation */} {/* Validation */}
<Form.Item shouldUpdate> <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 ( return (
<Space size="large" wrap align="center"> <Space size="large" wrap align="center">
<Statistic title={t("jobs.labels.subtotal")} value={totals.totalSale.toFormat()} /> <Statistic title={t("jobs.labels.subtotal")} value={totals.totalSale.toFormat()} />
<Typography.Title>=</Typography.Title> <Typography.Title>=</Typography.Title>
<Button disabled={!advisorOk} htmlType="submit"> <Button disabled={!advisorOk} htmlType="submit" type={hasEarlyRO ? "default" : "primary"}>
{t("jobs.actions.dms.post")} {hasEarlyRO ? t("jobs.actions.dms.update_ro") : t("jobs.actions.dms.post")}
</Button> </Button>
</Space> </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 [callSearch, { loading, error, data }] = useLazyQuery(GLOBAL_SEARCH_QUERY);
const navigate = useNavigate(); const navigate = useNavigate();
const executeSearch = (v) => { const executeSearch = (variables) => {
if (v && v.variables.search && v.variables.search !== "" && v.variables.search.length >= 3) callSearch(v); if (variables?.search !== "" && variables?.search?.length >= 3)
callSearch({
variables
});
}; };
const debouncedExecuteSearch = _.debounce(executeSearch, 750); const debouncedExecuteSearch = _.debounce(executeSearch, 750);
@@ -157,7 +160,9 @@ export default function GlobalSearch() {
return ( return (
<AutoComplete <AutoComplete
options={options} options={options}
onSearch={handleSearch} showSearch={{
onSearch: handleSearch
}}
defaultActiveFirstOption defaultActiveFirstOption
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key !== "Enter") return; if (e.key !== "Enter") return;

View File

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

View File

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

View File

@@ -97,7 +97,7 @@ export function Jobd3RdPartyModal({ bodyshop, jobId, job, technician }) {
return ( return (
<> <>
<Button onClick={showModal}>{t("printcenter.jobs.3rdpartypayer")}</Button> <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 onFinish={handleFinish} autoComplete={"off"} layout="vertical" form={form}>
<Form.Item label={t("bills.fields.vendor")} name="vendorid"> <Form.Item label={t("bills.fields.vendor")} name="vendorid">
<VendorSearchSelect options={VendorAutoCompleteData?.vendors} onSelect={handleVendorSelect} /> <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" }} /> <Input.TextArea rows={3} value={note} onChange={(e) => setNote(e.target.value)} style={{ maxWidth: "8vw" }} />
)} )}
<Button onClick={toggleEdit} loading={loading}> <Button onClick={toggleEdit} loading={loading} icon={editing ? <SaveFilled /> : <EditFilled />} />
{editing ? <SaveFilled /> : <EditFilled />}
</Button>
</Space> </Space>
</DataLabel> </DataLabel>
); );

View File

@@ -159,9 +159,8 @@ export function JobAuditTrail({ bodyshop, jobId }) {
onClick={() => { onClick={() => {
refetch(); refetch();
}} }}
> icon={<SyncOutlined />}
<SyncOutlined /> />
</Button>
} }
> >
<Table loading={loading} columns={columns} rowKey="id" dataSource={data ? data.audit_trail : []} /> <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) => ( renderItem={(template) => (
<List.Item <List.Item
actions={[ actions={[
<Button key="checkListTemplateButton" loading={loading} onClick={() => renderTemplate(template)}> <Button
<PrinterFilled /> key="checkListTemplateButton"
</Button> loading={loading}
onClick={() => renderTemplate(template)}
icon={<PrinterFilled />}
/>
]} ]}
> >
<List.Item.Meta <List.Item.Meta

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,29 +1,65 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { Tag, Tooltip } from "antd"; import { Tooltip } from "antd";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import { useTranslation } from "react-i18next";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
}); });
const mapDispatchToProps = () => ({ const mapDispatchToProps = () => ({});
//setUserLanguage: language => dispatch(setUserLanguage(language))
}); 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 default connect(mapStateToProps, mapDispatchToProps)(JobPartsQueueCount);
export function JobPartsQueueCount({ bodyshop, parts }) { export function JobPartsQueueCount({ bodyshop, parts }) {
const { t } = useTranslation();
const partsStatus = useMemo(() => { const partsStatus = useMemo(() => {
if (!parts) return null; if (!parts) return null;
const statusKeys = ["default_bo", "default_ordered", "default_received", "default_returned"]; const statusKeys = ["default_bo", "default_ordered", "default_received", "default_returned"];
return parts.reduce( return parts.reduce(
(acc, val) => { (acc, val) => {
if (val.part_type === "PAS" || val.part_type === "PASL") return acc; 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; return acc;
}, },
{ {
@@ -34,45 +70,38 @@ export function JobPartsQueueCount({ bodyshop, parts }) {
); );
}, [bodyshop, parts]); }, [bodyshop, parts]);
if (!parts) return null; if (!parts || !partsStatus) return null;
return ( return (
<div <div
style={{ style={{
display: "grid", display: "inline-flex", // ✅ shrink-wraps, fixes the “extra box” to the right
gridTemplateColumns: "repeat(auto-fit, minmax(40px, 1fr))", gap: 2,
gap: "8px", alignItems: "center",
width: "100%", whiteSpace: "nowrap"
justifyItems: "start"
}} }}
> >
<Tooltip title="Total"> <CompactTag tooltip="Total" color="gray">
<Tag style={{ minWidth: "40px", textAlign: "center" }}>{partsStatus.total}</Tag> {partsStatus.total}
</Tooltip> </CompactTag>
<Tooltip title={t("dashboard.errors.status_normal")}>
<Tag color="gold" style={{ minWidth: "40px", textAlign: "center" }}> <CompactTag tooltip="No Status" color="gold">
{partsStatus["null"]} {partsStatus["null"]}
</Tag> </CompactTag>
</Tooltip>
<Tooltip title={bodyshop.md_order_statuses.default_bo}> <CompactTag tooltip={bodyshop.md_order_statuses.default_bo} color="red">
<Tag color="red" style={{ minWidth: "40px", textAlign: "center" }}> {partsStatus[bodyshop.md_order_statuses.default_bo]}
{partsStatus[bodyshop.md_order_statuses.default_bo]} </CompactTag>
</Tag>
</Tooltip> <CompactTag tooltip={bodyshop.md_order_statuses.default_ordered} color="blue">
<Tooltip title={bodyshop.md_order_statuses.default_ordered}> {partsStatus[bodyshop.md_order_statuses.default_ordered]}
<Tag color="blue" style={{ minWidth: "40px", textAlign: "center" }}> </CompactTag>
{partsStatus[bodyshop.md_order_statuses.default_ordered]} <CompactTag tooltip={bodyshop.md_order_statuses.default_received} color="green">
</Tag> {partsStatus[bodyshop.md_order_statuses.default_received]}
</Tooltip> </CompactTag>
<Tooltip title={bodyshop.md_order_statuses.default_received}> <CompactTag tooltip={bodyshop.md_order_statuses.default_returned} color="orange">
<Tag color="green" style={{ minWidth: "40px", textAlign: "center" }}> {partsStatus[bodyshop.md_order_statuses.default_returned]}
{partsStatus[bodyshop.md_order_statuses.default_received]} </CompactTag>
</Tag>
</Tooltip>
<Tooltip title={bodyshop.md_order_statuses.default_returned}>
<Tag color="orange" style={{ minWidth: "40px", textAlign: "center" }}>
{partsStatus[bodyshop.md_order_statuses.default_returned]}
</Tag>
</Tooltip>
</div> </div>
); );
} }

View File

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

View File

@@ -107,9 +107,8 @@ export function JobPayments({ job, bodyshop, setPaymentContext, setCardPaymentCo
context: record context: record
}); });
}} }}
> icon={<EditFilled />}
<EditFilled /> />
</Button>
<PrintWrapperComponent <PrintWrapperComponent
templateObject={{ templateObject={{
name: TemplateList("payment").payment_receipt.key, 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) if (job?.available_jobs && job?.available_jobs?.length > 0)
return ( return (
<Button onClick={handleClick}> <Button onClick={handleClick} icon={<SyncOutlined />}>
<SyncOutlined />
{t("jobs.actions.sync")} {t("jobs.actions.sync")}
</Button> </Button>
); );

View File

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

View File

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

View File

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

View File

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

View File

@@ -43,6 +43,10 @@ export function JobsCloseLines({ bodyshop, job, jobRO }) {
{fields.map((field, index) => ( {fields.map((field, index) => (
<tr key={field.key}> <tr key={field.key}>
<td> <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 <Form.Item
// label={t("joblines.fields.line_desc")} // label={t("joblines.fields.line_desc")}
key={`${index}line_desc`} key={`${index}line_desc`}

View File

@@ -1,18 +1,22 @@
import { useMutation } from "@apollo/client/react"; 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 axios from "axios";
import { some } from "lodash"; import { some } from "lodash";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
import { CONVERT_JOB_TO_RO } from "../../graphql/jobs.queries"; import { CONVERT_JOB_TO_RO } from "../../graphql/jobs.queries";
import { insertAuditTrail } from "../../redux/application/application.actions"; import { insertAuditTrail } from "../../redux/application/application.actions";
import { selectJobReadOnly } from "../../redux/application/application.selectors"; import { selectJobReadOnly } from "../../redux/application/application.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import AuditTrailMapping from "../../utils/AuditTrailMappings"; import AuditTrailMapping from "../../utils/AuditTrailMappings";
import InstanceRenderManager from "../../utils/instanceRenderMgr"; import InstanceRenderManager from "../../utils/instanceRenderMgr";
import { DMS_MAP, getDmsMode } from "../../utils/dmsUtils";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx"; 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({ const mapStateToProps = createStructuredSelector({
//currentUser: selectCurrentUser //currentUser: selectCurrentUser
@@ -33,11 +37,27 @@ const mapDispatchToProps = (dispatch) => ({
export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTrail, parentFormIsFieldsTouched }) { export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTrail, parentFormIsFieldsTouched }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [loading, setLoading] = 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 [mutationConvertJob] = useMutation(CONVERT_JOB_TO_RO);
const { t } = useTranslation(); const { t } = useTranslation();
const [form] = Form.useForm(); const [form] = Form.useForm();
const notification = useNotification(); const notification = useNotification();
const allFormValues = Form.useWatch([], form); 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 }) => { const handleConvert = async ({ employee_csr, category, ...values }) => {
if (parentFormIsFieldsTouched()) { if (parentFormIsFieldsTouched()) {
@@ -82,177 +102,227 @@ export function JobsConvertButton({ bodyshop, job, refetch, jobRO, insertAuditTr
const submitDisabled = useCallback(() => some(allFormValues, (v) => v === undefined), [allFormValues]); const submitDisabled = useCallback(() => some(allFormValues, (v) => v === undefined), [allFormValues]);
const popMenu = ( const handleEarlyROSuccess = (result) => {
<div> setEarlyRoCreated(true); // Mark early RO as created
<Form setEarlyRoCreatedThisSession(true); // Mark as created in this session
layout="vertical" notification.success({
form={form} title: t("jobs.successes.early_ro_created"),
onFinish={handleConvert} description: `RO Number: ${result.roNumber || "N/A"}`
initialValues={{ });
driveable: true, // Delay refetch to keep success message visible for 2 seconds
towin: job.towin, setTimeout(() => {
ca_gst_registrant: job.ca_gst_registrant, refetch?.();
employee_csr: job.employee_csr, }, 2000);
category: job.category, };
referral_source: job.referral_source,
referral_source_extra: job.referral_source_extra ?? "" 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);
}} }}
> >
<Form.Item {t("jobs.actions.convert")}
name={["ins_co_nm"]} </Button>
label={t("jobs.fields.ins_co_nm")}
rules={[ {/* Convert Job Modal */}
{ <Modal
required: true open={open}
//message: t("general.validation.required"), 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}
onFinish={handleConvert}
initialValues={{
driveable: true,
towin: job.towin,
ca_gst_registrant: job.ca_gst_registrant,
employee_csr: job.employee_csr,
category: job.category,
referral_source: job.referral_source,
referral_source_extra: job.referral_source_extra ?? ""
}}
> >
<Select showSearch> {/* Show Reynolds Early RO section at the top if applicable */}
{bodyshop.md_ins_cos.map((s, i) => ( {isReynoldsMode && !job.dms_id && !earlyRoCreated && (
<Select.Option key={i} value={s.name}> <>
{s.name} <RREarlyROForm
</Select.Option> bodyshop={bodyshop}
))} socket={socket}
</Select> job={job}
</Form.Item> onSuccess={handleEarlyROSuccess}
{bodyshop.enforce_class && ( showCancelButton={false}
/>
<Divider />
</>
)}
<Form.Item <Form.Item
name={"class"} name={["ins_co_nm"]}
label={t("jobs.fields.class")} label={t("jobs.fields.ins_co_nm")}
rules={[ rules={[
{ {
required: bodyshop.enforce_class required: true
//message: t("general.validation.required"), //message: t("general.validation.required"),
} }
]} ]}
> >
<Select> <Select showSearch>
{bodyshop.md_classes.map((s) => ( {bodyshop.md_ins_cos.map((s, i) => (
<Select.Option key={s} value={s}> <Select.Option key={i} value={s.name}>
{s} {s.name}
</Select.Option> </Select.Option>
))} ))}
</Select> </Select>
</Form.Item> </Form.Item>
)} {bodyshop.enforce_class && (
{bodyshop.enforce_referral && (
<>
<Form.Item <Form.Item
name={"referral_source"} name={"class"}
label={t("jobs.fields.referralsource")} label={t("jobs.fields.class")}
rules={[ rules={[
{ {
required: bodyshop.enforce_referral required: bodyshop.enforce_class
//message: t("general.validation.required"), //message: t("general.validation.required"),
} }
]} ]}
> >
<Select> <Select>
{bodyshop.md_referral_sources.map((s) => ( {bodyshop.md_classes.map((s) => (
<Select.Option key={s} value={s}> <Select.Option key={s} value={s}>
{s} {s}
</Select.Option> </Select.Option>
))} ))}
</Select> </Select>
</Form.Item> </Form.Item>
<Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra"> )}
<Input /> {bodyshop.enforce_referral && (
</Form.Item> <>
</> <Form.Item
)} name={"referral_source"}
{bodyshop.enforce_conversion_csr && ( label={t("jobs.fields.referralsource")}
<Form.Item rules={[
name={"employee_csr"} {
label={t( required: bodyshop.enforce_referral
InstanceRenderManager({ //message: t("general.validation.required"),
imex: "jobs.fields.employee_csr", }
rome: "jobs.fields.employee_csr_writer" ]}
}) >
)} <Select>
rules={[ {bodyshop.md_referral_sources.map((s) => (
{ <Select.Option key={s} value={s}>
required: bodyshop.enforce_conversion_csr {s}
//message: t("general.validation.required"), </Select.Option>
} ))}
]} </Select>
> </Form.Item>
<Select <Form.Item label={t("jobs.fields.referral_source_extra")} name="referral_source_extra">
showSearch={{ <Input />
optionFilterProp: "children", </Form.Item>
filterOption: (input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0 </>
}} )}
style={{ width: 200 }} {bodyshop.enforce_conversion_csr && (
<Form.Item
name={"employee_csr"}
label={t(
InstanceRenderManager({
imex: "jobs.fields.employee_csr",
rome: "jobs.fields.employee_csr_writer"
})
)}
rules={[
{
required: bodyshop.enforce_conversion_csr
//message: t("general.validation.required"),
}
]}
> >
{bodyshop.employees <Select
.filter((emp) => emp.active) showSearch={{
.map((emp) => ( optionFilterProp: "children",
<Select.Option value={emp.id} key={emp.id} name={`${emp.first_name} ${emp.last_name}`}> filterOption: (input, option) => option.props.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
{`${emp.first_name} ${emp.last_name}`} }}
style={{ width: 200 }}
>
{bodyshop.employees
.filter((emp) => emp.active)
.map((emp) => (
<Select.Option value={emp.id} key={emp.id} name={`${emp.first_name} ${emp.last_name}`}>
{`${emp.first_name} ${emp.last_name}`}
</Select.Option>
))}
</Select>
</Form.Item>
)}
{bodyshop.enforce_conversion_category && (
<Form.Item
name={"category"}
label={t("jobs.fields.category")}
rules={[
{
required: bodyshop.enforce_conversion_category
//message: t("general.validation.required"),
}
]}
>
<Select allowClear>
{bodyshop.md_categories.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option> </Select.Option>
))} ))}
</Select> </Select>
</Form.Item> </Form.Item>
)} )}
{bodyshop.enforce_conversion_category && ( {bodyshop.region_config.toLowerCase().startsWith("ca") && (
<Form.Item <Form.Item label={t("jobs.fields.ca_gst_registrant")} name="ca_gst_registrant" valuePropName="checked">
name={"category"} <Switch />
label={t("jobs.fields.category")} </Form.Item>
rules={[ )}
{ <Form.Item label={t("jobs.fields.driveable")} name="driveable" valuePropName="checked">
required: bodyshop.enforce_conversion_category <Switch />
//message: t("general.validation.required"), </Form.Item>
} <Form.Item label={t("jobs.fields.towin")} name="towin" valuePropName="checked">
]}
>
<Select allowClear>
{bodyshop.md_categories.map((s) => (
<Select.Option key={s} value={s}>
{s}
</Select.Option>
))}
</Select>
</Form.Item>
)}
{bodyshop.region_config.toLowerCase().startsWith("ca") && (
<Form.Item label={t("jobs.fields.ca_gst_registrant")} name="ca_gst_registrant" valuePropName="checked">
<Switch /> <Switch />
</Form.Item> </Form.Item>
)}
<Form.Item label={t("jobs.fields.driveable")} name="driveable" valuePropName="checked">
<Switch />
</Form.Item>
<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 <></>; <Space wrap style={{ marginTop: 16 }}>
<Button
return ( disabled={submitDisabled() || (isReynoldsMode && !job.dms_id && !earlyRoCreated)}
<Popover open={open} content={popMenu}> type="primary"
<Button danger
key="convert" onClick={() => form.submit()}
type="primary" loading={loading}
danger >
// style={{ display: job.converted ? "none" : "" }} {t("jobs.actions.convert")}
disabled={job.converted || jobRO} </Button>
loading={loading} <Button onClick={handleModalClose} disabled={earlyRoCreatedThisSession && !job.converted}>
onClick={() => { {t("general.actions.close")}
setOpen(true); </Button>
}} </Space>
> </Form>
{t("jobs.actions.convert")} </Modal>
</Button> </>
</Popover>
); );
} }

View File

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

View File

@@ -251,7 +251,6 @@ export function JobsDetailGeneral({ bodyshop, jobRO, job, form }) {
))} ))}
</Select> </Select>
</Form.Item> </Form.Item>
<Form.Item label={t("jobs.fields.selling_dealer")} name="selling_dealer"> <Form.Item label={t("jobs.fields.selling_dealer")} name="selling_dealer">
<Input disabled={jobRO} /> <Input disabled={jobRO} />
</Form.Item> </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"> <Form.Item label={t("jobs.fields.lost_sale_reason")} name="lost_sale_reason">
<Input disabled={jobRO} allowClear /> <Input disabled={jobRO} allowClear />
</Form.Item> </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> </FormRow>
</Card> </Card>
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -132,7 +132,13 @@ export function LaborAllocationsAdjustmentEdit({
); );
return ( 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} {children}
</Popover> </Popover>
); );

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import { connect } from "react-redux";
import { createStructuredSelector } from "reselect"; import { createStructuredSelector } from "reselect";
import { store } from "../../redux/store"; import { store } from "../../redux/store";
import { selectBodyshop } from "../../redux/user/user.selectors"; import { selectBodyshop } from "../../redux/user/user.selectors";
import { Tooltip } from "antd";
const mapStateToProps = createStructuredSelector({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop bodyshop: selectBodyshop
@@ -11,15 +12,27 @@ const mapDispatchToProps = () => ({
}); });
export default connect(mapStateToProps, mapDispatchToProps)(OwnerNameDisplay); 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; const emptyTest = ownerObject?.ownr_fn + ownerObject?.ownr_ln + ownerObject?.ownr_co_nm;
if (!emptyTest || emptyTest === "null" || emptyTest.trim() === "") return "N/A"; if (!emptyTest || emptyTest === "null" || emptyTest.trim() === "") return "N/A";
if (bodyshop.last_name_first) let returnString;
return `${ownerObject?.ownr_ln || ""}, ${ownerObject?.ownr_fn || ""} ${ownerObject?.ownr_co_nm || ""}`.trim(); if (bodyshop.last_name_first) {
returnString =
return `${ownerObject?.ownr_fn || ""} ${ownerObject?.ownr_ln || ""} ${ownerObject.ownr_co_nm || ""}`.trim(); `${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) { 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 SEARCH_OWNERS_BY_ID_FOR_AUTOCOMPLETE
); );
const executeSearch = (v) => { const executeSearch = (variables) => {
if (v && v.variables?.search !== "" && v.variables.search.length >= 2) callSearch({ variables: v.variables }); if (variables?.search !== "" && variables?.search?.length >= 2) callSearch({ variables });
}; };
const debouncedExecuteSearch = _.debounce(executeSearch, 500); const debouncedExecuteSearch = _.debounce(executeSearch, 500);
const handleSearch = (value) => { const handleSearch = (value) => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,94 +1,121 @@
import { DownOutlined } from "@ant-design/icons"; 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 { useTranslation } from "react-i18next";
import { logImEXEvent } from "../../firebase/firebase.utils"; import { logImEXEvent } from "../../firebase/firebase.utils";
const DISCOUNT_PRESETS = [5, 10, 15, 20, 25, 40];
export default function PartsOrderModalPriceChange({ form, field }) { export default function PartsOrderModalPriceChange({ form, field }) {
const { t } = useTranslation(); const { t } = useTranslation();
const menu = { const { token } = theme.useToken();
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 values = form.getFieldsValue();
const { parts_order_lines } = values;
form.setFieldsValue({ const [open, setOpen] = useState(false);
parts_order_lines: { const [customPercent, setCustomPercent] = useState(0);
data: parts_order_lines.data.map((p, idx) => {
if (idx !== field.name) return p; const applyDiscountPercent = (percent) => {
console.log(p, e.target.value, (p.act_price || 0) * ((100 - (e.target.value || 0)) / 100)); const pct = Number(percent) || 0;
return {
...p, const values = form.getFieldsValue();
act_price: (p.act_price || 0) * ((100 - (e.target.value || 0)) / 100) 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({
e.target.value = 0; parts_order_lines: {
} data: data.map((p, idx) => {
}} if (idx !== field.name) return p;
min={0} return {
max={100} ...p,
/> act_price: (p.act_price || 0) * ((100 - pct) / 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 }) => { onClick: ({ key }) => {
logImEXEvent("parts_order_manual_discount", {}); logImEXEvent("parts_order_manual_discount", {});
if (key === "custom") return; applyDiscountPercent(key);
const values = form.getFieldsValue(); setOpen(false);
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)
};
})
}
});
} }
}; };
return ( 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> <Space>
% % <DownOutlined />
<DownOutlined />
</Space> </Space>
</Dropdown> </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 DateTimePicker from "../form-date-time-picker/form-date-time-picker.component.jsx";
import { selectIsPartsEntry } from "../../redux/application/application.selectors.js"; 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({ const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop, bodyshop: selectBodyshop,
isPartsEntry: selectIsPartsEntry isPartsEntry: selectIsPartsEntry
@@ -59,7 +66,7 @@ export function PartsOrderModalComponent({
return ( return (
<div> <div>
<Form.Item name="returnfrombill" hidden> <Form.Item name="returnfrombill" hidden>
<Input /> <Input type="hidden" />
</Form.Item> </Form.Item>
<LayoutFormRow grow noDivider> <LayoutFormRow grow noDivider>
<Form.Item <Form.Item
@@ -199,10 +206,7 @@ export function PartsOrderModalComponent({
key={`${index}act_price`} key={`${index}act_price`}
name={[field.name, "act_price"]} name={[field.name, "act_price"]}
> >
<Space.Compact style={{ width: "100%" }}> <PriceInputWrapper form={form} field={field} />
<PartsOrderModalPriceChange form={form} field={field} />
<CurrencyInput style={{ flex: 1 }} />
</Space.Compact>
</Form.Item> </Form.Item>
{isReturn && ( {isReturn && (
<Form.Item <Form.Item

View File

@@ -17,7 +17,6 @@ import AuditTrailMapping from "../../utils/AuditTrailMappings";
import { GenerateDocument } from "../../utils/RenderTemplate"; import { GenerateDocument } from "../../utils/RenderTemplate";
import { TemplateList } from "../../utils/TemplateConstants"; import { TemplateList } from "../../utils/TemplateConstants";
import AlertComponent from "../alert/alert.component"; import AlertComponent from "../alert/alert.component";
import LoadingSpinner from "../loading-spinner/loading-spinner.component";
import PartsOrderModalComponent from "./parts-order-modal.component"; import PartsOrderModalComponent from "./parts-order-modal.component";
import axios from "axios"; import axios from "axios";
import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react"; import { useTreatmentsWithConfig } from "@splitsoftware/splitio-react";
@@ -66,7 +65,7 @@ export function PartsOrderModalContainer({
const sendTypeState = useState("e"); const sendTypeState = useState("e");
const sendType = sendTypeState[0]; 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, skip: !open,
variables: { jobId: jobId }, variables: { jobId: jobId },
fetchPolicy: "network-only", fetchPolicy: "network-only",
@@ -89,20 +88,11 @@ export function PartsOrderModalContainer({
return { return {
...p, ...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; let insertResult;
try { try {
insertResult = await insertPartOrder({ insertResult = await insertPartOrder({
@@ -371,6 +361,7 @@ export function PartsOrderModalContainer({
} }
}, [open, linesToOrder, form]); }, [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 ( return (
<Modal <Modal
open={open} open={open}
@@ -389,18 +380,14 @@ export function PartsOrderModalContainer({
> >
{error ? <AlertComponent title={error.message} type="error" /> : null} {error ? <AlertComponent title={error.message} type="error" /> : null}
<Form form={form} layout="vertical" autoComplete="no" onFinish={handleFinish} initialValues={initialValues}> <Form form={form} layout="vertical" autoComplete="no" onFinish={handleFinish} initialValues={initialValues}>
{loading ? ( <PartsOrderModalComponent
<LoadingSpinner /> form={form}
) : ( vendorList={data?.vendors || []}
<PartsOrderModalComponent sendTypeState={sendTypeState}
form={form} isReturn={isReturn}
vendorList={data?.vendors || []} preferredMake={data && data.jobs[0] && data.jobs[0].v_make_desc}
sendTypeState={sendTypeState} job={job}
isReturn={isReturn} />
preferredMake={data && data.jobs[0] && data.jobs[0].v_make_desc}
job={job}
/>
)}
</Form> </Form>
</Modal> </Modal>
); );

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