Merge branch 'master-AIO' into feature/IO-3515-ocr-bill-posting
This commit is contained in:
@@ -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
|
||||||
|
|||||||
593
_reference/refactorReports/REACT-19-DEPRECATION-FIXES.md
Normal file
593
_reference/refactorReports/REACT-19-DEPRECATION-FIXES.md
Normal 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
@@ -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
|
|
||||||
|
|||||||
@@ -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
3
client/.gitignore
vendored
@@ -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
1021
client/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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`}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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`}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")} />
|
||||||
|
|||||||
@@ -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")} />
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
367
client/src/components/dms-post-form/rr-early-ro-form.jsx
Normal file
367
client/src/components/dms-post-form/rr-early-ro-form.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
client/src/components/dms-post-form/rr-early-ro-modal.jsx
Normal file
33
client/src/components/dms-post-form/rr-early-ro-modal.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 : []} />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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`}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -246,9 +246,8 @@ export function PayrollLaborAllocationsTable({
|
|||||||
setTotals(data);
|
setTotals(data);
|
||||||
refetch();
|
refetch();
|
||||||
}}
|
}}
|
||||||
>
|
icon={<SyncOutlined />}
|
||||||
<SyncOutlined />
|
/>
|
||||||
</Button>
|
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -173,3 +173,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notification-center--dark {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-center--light {
|
||||||
|
color-scheme: light;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,9 +34,7 @@ export default function PartsOrderDeleteLine({ disabled, partsLineId, partsOrder
|
|||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Button disabled={disabled}>
|
<Button disabled={disabled} icon={<DeleteFilled />} />
|
||||||
<DeleteFilled />
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -295,6 +295,7 @@ export function PartsOrderListTableComponent({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setSearchText(e.target.value);
|
setSearchText(e.target.value);
|
||||||
}}
|
}}
|
||||||
|
enterButton
|
||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user