diff --git a/.prettierrc.js b/.prettierrc.js new file mode 100644 index 000000000..a2bd52a34 --- /dev/null +++ b/.prettierrc.js @@ -0,0 +1,16 @@ +exports.default = { + printWidth: 120, + useTabs: false, + tabWidth: 2, + trailingComma: "es5", + semi: true, + singleQuote: false, + bracketSpacing: true, + arrowParens: "always", + jsxSingleQuote: false, + bracketSameLine: false, + endOfLine: "lf", + importOrder: ["^@core/(.*)$", "^@server/(.*)$", "^@ui/(.*)$", "^[./]"], + importOrderSeparation: true, + importOrderSortSpecifiers: true, +}; diff --git a/_reference/reportFiltersAndSorters.md b/_reference/reportFiltersAndSorters.md index 3d680f387..2aea3f11a 100644 --- a/_reference/reportFiltersAndSorters.md +++ b/_reference/reportFiltersAndSorters.md @@ -3,7 +3,13 @@ This documentation details the schema required for `.filters` files on the report server. It is used to dynamically modify the graphQL query and provide the user more power over their reports. -# Special Notes +For filters and sorters, valid types include (`type` key in the schema): +- string (default) +- number +- bool or boolean +- date + +## Special Notes - When passing the data to the template server, the property filters and sorters is added to the data object and will reflect the filters and sorters the user has selected ## High level Schema Overview @@ -40,9 +46,10 @@ Filters effect the where clause of the graphQL query. They are used to filter th A note on special notation used in the `name` field. ## Reflection + Filters can make use of reflection to pre-fill select boxes, the following is an example of that in the filters file. -``` +```json { "name": "jobs.status", "translation": "jobs.fields.status", @@ -67,7 +74,9 @@ The following cases are available - `special.employees` - This will reflect the employees `bodyshop.employees` - `special.first_names` - This will reflect the first names `bodyshop.employees` - `special.last_names` - This will reflect the last names `bodyshop.employees` -- +- `special.referral_sources` - This will reflect the referral sources `bodyshop.md_referral_sources +- `special.class`- This will reflect the class `bodyshop.md_classes` +- ### Path without brackets, multi level `"name": "jobs.joblines.mod_lb_hrs",` @@ -142,8 +151,7 @@ query gendoc_hours_sold_detail_open($starttz: timestamptz!, $endtz: timestamptz! - Will only support two level of nesting in the graphQL query `jobs.joblines.mod_lb_hrs` vs `[jobs].joblines.mod_lb_hrs` is fine, but `jobs.[joblines.].some_table.mod_lb_hrs` is not. -- The `dates` object is not yet implemented and will be added in a future release. -- The type object must be 'string' or 'number' and is case-sensitive. +- The type object must be 'string' or 'number' or 'bool' or 'boolean' or 'date' and is case-sensitive. - The `translation` key is used to look up the label in the GUI, if it is not found, the `label` key is used. - Do not add the ability to filter things that are already filtered as part of the original query, this would be redundant and could cause issues. @@ -158,6 +166,7 @@ query gendoc_hours_sold_detail_open($starttz: timestamptz!, $endtz: timestamptz! using the sorters. ### Default Sorters + - A sorter can be given a default object containing a `order` and `direction` key value. This will be used to sort the report if the user does not select any of the sorters themselves. - The `order` key is the order in which the sorters are applied, and the `direction` key is the direction of the sort, either `asc` or `desc`. @@ -172,4 +181,4 @@ query gendoc_hours_sold_detail_open($starttz: timestamptz!, $endtz: timestamptz! "direction": "asc" } } -``` \ No newline at end of file +``` diff --git a/client/src/components/bill-delete-button/bill-delete-button.component.jsx b/client/src/components/bill-delete-button/bill-delete-button.component.jsx index e0f096754..af7748228 100644 --- a/client/src/components/bill-delete-button/bill-delete-button.component.jsx +++ b/client/src/components/bill-delete-button/bill-delete-button.component.jsx @@ -5,10 +5,22 @@ import React, {useState} from "react"; import {useTranslation} from "react-i18next"; import {DELETE_BILL} from "../../graphql/bills.queries"; import RbacWrapper from "../rbac-wrapper/rbac-wrapper.component"; +import {insertAuditTrail} from "../../redux/application/application.actions"; +import AuditTrailMapping from "../../utils/AuditTrailMappings"; +import { connect } from "react-redux"; +import { createStructuredSelector } from "reselect"; -export default function BillDeleteButton({bill, callback}) { +const mapStateToProps = createStructuredSelector({}); +const mapDispatchToProps = (dispatch) => ({ + insertAuditTrail: ({ jobid, operation }) => + dispatch(insertAuditTrail({ jobid, operation })), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(BillDeleteButton); + +export function BillDeleteButton({ bill, jobid, callback, insertAuditTrail }) { const [loading, setLoading] = useState(false); - const {t} = useTranslation(); + const { t } = useTranslation(); const [deleteBill] = useMutation(DELETE_BILL); const handleDelete = async () => { @@ -35,7 +47,11 @@ export default function BillDeleteButton({bill, callback}) { }); if (!!!result.errors) { - notification["success"]({message: t("bills.successes.deleted")}); + notification["success"]({ message: t("bills.successes.deleted") }); + insertAuditTrail({ + jobid: jobid, + operation: AuditTrailMapping.billdeleted(bill.invoice_number), + }); if (callback && typeof callback === "function") callback(bill.id); } else { diff --git a/client/src/components/bills-list-table/bills-list-table.component.jsx b/client/src/components/bills-list-table/bills-list-table.component.jsx index 5f0237260..a03dea379 100644 --- a/client/src/components/bills-list-table/bills-list-table.component.jsx +++ b/client/src/components/bills-list-table/bills-list-table.component.jsx @@ -50,17 +50,17 @@ export function BillsListTableComponent({ const Templates = TemplateList("bill"); const bills = billsQuery.data ? billsQuery.data.bills : []; - const {refetch} = billsQuery; + const { refetch } = billsQuery; const recordActions = (record, showView = false) => ( {showView && ( )} - + alphaSort(a.status, b.status), + filteredValue: filter?.status || null, filters: [ { text: t("courtesycars.status.in"), @@ -64,7 +78,7 @@ export default function CourtesyCarsList({loading, courtesycars, refetch}) { sortOrder: state.sortedInfo.columnKey === "status" && state.sortedInfo.order, render: (text, record) => { - const {nextservicedate, nextservicekm, mileage, insuranceexpires} = + const { nextservicedate, nextservicekm, mileage, insuranceexpires } = record; const mileageOver = nextservicekm ? nextservicekm <= mileage : false; @@ -75,19 +89,23 @@ export default function CourtesyCarsList({loading, courtesycars, refetch}) { const insuranceOver = insuranceexpires && dayjs(insuranceexpires).endOf("day").isBefore(dayjs()); + return ( {t(record.status)} {(mileageOver || dueForService || insuranceOver) && ( - - + + )} @@ -99,6 +117,7 @@ export default function CourtesyCarsList({loading, courtesycars, refetch}) { dataIndex: "readiness", key: "readiness", sorter: (a, b) => alphaSort(a.readiness, b.readiness), + filteredValue: filter?.readiness || null, filters: [ { text: t("courtesycars.readiness.ready"), @@ -214,7 +233,8 @@ export default function CourtesyCarsList({loading, courtesycars, refetch}) { ]; const handleTableChange = (pagination, filters, sorter) => { - setState({...state, filteredInfo: filters, sortedInfo: sorter}); + setState({ ...state, sortedInfo: sorter }); + setFilter(filters); }; const tableData = searchText diff --git a/client/src/components/jobs-close-export-button/jobs-close-export-button.component.jsx b/client/src/components/jobs-close-export-button/jobs-close-export-button.component.jsx index 3755e4338..ffc921a00 100644 --- a/client/src/components/jobs-close-export-button/jobs-close-export-button.component.jsx +++ b/client/src/components/jobs-close-export-button/jobs-close-export-button.component.jsx @@ -9,10 +9,12 @@ import {createStructuredSelector} from "reselect"; import {auth, logImEXEvent} from "../../firebase/firebase.utils"; import {INSERT_EXPORT_LOG} from "../../graphql/accounting.queries"; import {UPDATE_JOB} from "../../graphql/jobs.queries"; +import { insertAuditTrail } from "../../redux/application/application.actions"; import { selectBodyshop, selectCurrentUser, } from "../../redux/user/user.selectors"; +import AuditTrailMapping from "../../utils/AuditTrailMappings"; import client from "../../utils/GraphQLClient"; const mapStateToProps = createStructuredSelector({ @@ -20,6 +22,11 @@ const mapStateToProps = createStructuredSelector({ currentUser: selectCurrentUser, }); +const mapDispatchToProps = (dispatch) => ({ + insertAuditTrail: ({ jobid, operation }) => + dispatch(insertAuditTrail({ jobid, operation })), +}); + function updateJobCache(items) { client.cache.modify({ id: "ROOT_QUERY", @@ -40,9 +47,10 @@ export function JobsCloseExportButton({ disabled, setSelectedJobs, refetch, + insertAuditTrail, }) { const history = useNavigate(); - const {t} = useTranslation(); + const { t } = useTranslation(); const [updateJob] = useMutation(UPDATE_JOB); const [insertExportLog] = useMutation(INSERT_EXPORT_LOG); const [loading, setLoading] = useState(false); @@ -181,6 +189,10 @@ export function JobsCloseExportButton({ key: "jobsuccessexport", message: t("jobs.successes.exported"), }); + insertAuditTrail({ + jobid: jobId, + operation: AuditTrailMapping.jobexported(), + }); updateJobCache( jobUpdateResponse.data.update_jobs.returning.map((job) => job.id) ); @@ -192,12 +204,20 @@ export function JobsCloseExportButton({ }); } } - if (bodyshop.accountingconfig && bodyshop.accountingconfig.qbo && successfulTransactions.length > 0) { + if ( + bodyshop.accountingconfig && + bodyshop.accountingconfig.qbo && + successfulTransactions.length > 0 + ) { notification.open({ type: "success", key: "jobsuccessexport", message: t("jobs.successes.exported"), }); + insertAuditTrail({ + jobid: jobId, + operation: AuditTrailMapping.jobexported(), + }); updateJobCache([ ...new Set( successfulTransactions.map( @@ -227,4 +247,7 @@ export function JobsCloseExportButton({ ); } -export default connect(mapStateToProps, null)(JobsCloseExportButton); +export default connect( + mapStateToProps, + mapDispatchToProps +)(JobsCloseExportButton); diff --git a/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx b/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx index cd7b81456..3e7fd50e8 100644 --- a/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx +++ b/client/src/components/jobs-detail-header-actions/jobs-detail-header-actions.component.jsx @@ -237,6 +237,10 @@ export function JobsDetailHeaderActions({ message: JSON.stringify(result.errors), }), }); + insertAuditTrail({ + jobid: job.id, + operation: AuditTrailMapping.jobvoid(), + }); return; } if (e.key === "email") @@ -470,6 +474,24 @@ export function JobsDetailHeaderActions({ }); }; + const handleSuspend = (e) => { + logImEXEvent("production_toggle_alert"); + //e.stopPropagation(); + updateJob({ + variables: { + jobId: job.id, + job: { + suspended: !job.suspended, + }, + }, + }); + insertAuditTrail({ + jobid: job.id, + operation: AuditTrailMapping.jobsuspend( + !!job.suspended ? !job.suspended : true + ), + }); + }; // Function to handle OK const handleCancelScheduleOK = async () => { @@ -504,19 +526,6 @@ export function JobsDetailHeaderActions({ } }; - const handleSuspend = (e) => { - logImEXEvent("production_toggle_alert"); - //e.stopPropagation(); - updateJob({ - variables: { - jobId: job.id, - job: { - suspended: !job.suspended, - }, - }, - }); - }; - const popOverContent = (
diff --git a/client/src/components/jobs-export-all-button/jobs-export-all-button.component.jsx b/client/src/components/jobs-export-all-button/jobs-export-all-button.component.jsx index 919e71421..03f4ed6a0 100644 --- a/client/src/components/jobs-export-all-button/jobs-export-all-button.component.jsx +++ b/client/src/components/jobs-export-all-button/jobs-export-all-button.component.jsx @@ -9,7 +9,12 @@ import {createStructuredSelector} from "reselect"; import {auth, logImEXEvent} from "../../firebase/firebase.utils"; import {INSERT_EXPORT_LOG} from "../../graphql/accounting.queries"; import {UPDATE_JOBS} from "../../graphql/jobs.queries"; -import {selectBodyshop, selectCurrentUser,} from "../../redux/user/user.selectors"; +import { insertAuditTrail } from "../../redux/application/application.actions"; +import AuditTrailMapping from "../../utils/AuditTrailMappings"; +import { + selectBodyshop, + selectCurrentUser, +} from "../../redux/user/user.selectors"; import client from "../../utils/GraphQLClient"; const mapStateToProps = createStructuredSelector({ @@ -17,6 +22,11 @@ const mapStateToProps = createStructuredSelector({ currentUser: selectCurrentUser, }); +const mapDispatchToProps = (dispatch) => ({ + insertAuditTrail: ({ jobid, operation }) => + dispatch(insertAuditTrail({ jobid, operation })), +}); + function updateJobCache(items) { client.cache.modify({ id: "ROOT_QUERY", @@ -38,8 +48,9 @@ export function JobsExportAllButton({ loadingCallback, completedCallback, refetch, + insertAuditTrail, }) { - const {t} = useTranslation(); + const { t } = useTranslation(); const [updateJob] = useMutation(UPDATE_JOBS); const [insertExportLog] = useMutation(INSERT_EXPORT_LOG); @@ -168,47 +179,64 @@ export function JobsExportAllButton({ }, }); - if (!!!jobUpdateResponse.errors) { - notification.open({ - type: "success", - key: "jobsuccessexport", - message: t("jobs.successes.exported"), - }); - updateJobCache( - jobUpdateResponse.data.update_jobs.returning.map( - (job) => job.id - ) - ); - } else { - notification["error"]({ - message: t("jobs.errors.exporting", { - error: JSON.stringify(jobUpdateResponse.error), - }), - }); - } - } - if (bodyshop.accountingconfig && bodyshop.accountingconfig.qbo && successfulTransactions.length > 0) { - notification.open({ - type: "success", - key: "jobsuccessexport", - message: t("jobs.successes.exported"), - }); - updateJobCache([ - ...new Set( - successfulTransactions.map( - (st) => - st[ - bodyshop.accountingconfig && bodyshop.accountingconfig.qbo - ? "jobid" - : "id" - ] - ) - ), - ]); - } - } - }) - ); + if (!!!jobUpdateResponse.errors) { + notification.open({ + type: "success", + key: "jobsuccessexport", + message: t("jobs.successes.exported"), + }); + jobUpdateResponse.data.update_jobs.returning.forEach((job) => { + insertAuditTrail({ + jobid: job.id, + operation: AuditTrailMapping.jobexported(), + }); + }); + updateJobCache( + jobUpdateResponse.data.update_jobs.returning.map( + (job) => job.id + ) + ); + } else { + notification["error"]({ + message: t("jobs.errors.exporting", { + error: JSON.stringify(jobUpdateResponse.error), + }), + }); + } + } + if ( + bodyshop.accountingconfig && + bodyshop.accountingconfig.qbo && + successfulTransactions.length > 0 + ) { + notification.open({ + type: "success", + key: "jobsuccessexport", + message: t("jobs.successes.exported"), + }); + const successfulTransactionsSet = [ + ...new Set( + successfulTransactions.map( + (st) => + st[ + bodyshop.accountingconfig && bodyshop.accountingconfig.qbo + ? "jobid" + : "id" + ] + ) + ), + ]; + if (successfulTransactionsSet.length > 0) { + insertAuditTrail({ + jobid: successfulTransactionsSet[0], + operation: AuditTrailMapping.jobexported(), + }); + } + updateJobCache(successfulTransactionsSet); + } + } + }) + ); if (!!completedCallback) completedCallback([]); if (!!loadingCallback) loadingCallback(false); @@ -222,4 +250,7 @@ export function JobsExportAllButton({ ); } -export default connect(mapStateToProps, null)(JobsExportAllButton); +export default connect( + mapStateToProps, + mapDispatchToProps +)(JobsExportAllButton); diff --git a/client/src/components/report-center-modal/report-center-modal-filters-sorters-component.jsx b/client/src/components/report-center-modal/report-center-modal-filters-sorters-component.jsx index 1fc7a4700..892ce67d5 100644 --- a/client/src/components/report-center-modal/report-center-modal-filters-sorters-component.jsx +++ b/client/src/components/report-center-modal/report-center-modal-filters-sorters-component.jsx @@ -6,7 +6,7 @@ import {useTranslation} from "react-i18next"; import {getOrderOperatorsByType, getWhereOperatorsByType} from "../../utils/graphQLmodifier"; import LoadingSkeleton from "../loading-skeleton/loading-skeleton.component"; import {generateInternalReflections} from "./report-center-modal-utils"; - +import {FormDatePicker} from "../form-date-picker/form-date-picker.component.jsx"; export default function ReportCenterModalFiltersSortersComponent({form, bodyshop}) { return ( @@ -31,10 +31,9 @@ function FiltersSection({filters, form, bodyshop}) { const {t} = useTranslation(); return ( - + - {(fields, {add, remove, move}) => { + {(fields, {add, remove}) => { return (
{fields.map((field, index) => ( @@ -72,7 +71,8 @@ function FiltersSection({filters, form, bodyshop}) { + dependencies={[['filters', field.name, "field"],['filters', field.name, "value"]]} + > { () => { const name = form.getFieldValue(['filters', field.name, "field"]); @@ -82,7 +82,6 @@ function FiltersSection({filters, form, bodyshop}) { key={`${index}operator`} label={t('reportcenter.labels.advanced_filters_filter_operator')} name={[field.name, "operator"]} - dependencies={[]} rules={[ { required: true, @@ -92,20 +91,32 @@ function FiltersSection({filters, form, bodyshop}) { > trigger.parentNode} + onChange={(value) => { + form.setFieldValue(fieldPath, value); + }} + /> + ); + } return ( trigger.parentNode} + options={[ + { + label: t('reportcenter.labels.advanced_filters_true'), + value: true + }, + { + label: t('reportcenter.labels.advanced_filters_false'), + value: false + } + ]} + onChange={(value) => form.setFieldValue(fieldPath, value)} + /> + ); + } return ( form.setFieldValue(fieldPath, e.target.value)}/> + disabled={!operator} + onChange={(e) => form.setFieldValue(fieldPath, e.target.value)} + /> ); })() } @@ -206,13 +265,12 @@ function FiltersSection({filters, form, bodyshop}) { * @returns {JSX.Element} * @constructor */ -function SortersSection({sorters, form}) { +function SortersSection({sorters}) { const {t} = useTranslation(); return ( - + - {(fields, {add, remove, move}) => { + {(fields, {add, remove}) => { return (
Sorters diff --git a/client/src/components/report-center-modal/report-center-modal-utils.js b/client/src/components/report-center-modal/report-center-modal-utils.js index 2f9fc5e87..97b693a61 100644 --- a/client/src/components/report-center-modal/report-center-modal-utils.js +++ b/client/src/components/report-center-modal/report-center-modal-utils.js @@ -8,6 +8,21 @@ import {uniqBy} from "lodash"; */ const getValueFromPath = (obj, path) => path.split('.').reduce((prev, curr) => prev?.[curr], obj); +/** + * Generate options from array + * @param bodyshop + * @param path + * @returns {unknown[]} + */ +const generateOptionsFromArray = (bodyshop, path) => { + const options = getValueFromPath(bodyshop, path); + return uniqBy(options.map((value) => ({ + label: value, + value: value, + })), 'value'); +} + + /** * Valid internal reflections * Note: This is intended for future functionality @@ -46,15 +61,16 @@ const generateOptionsFromObject = (bodyshop, path, labelPath, valuePath) => { */ const generateSpecialReflections = (bodyshop, finalPath) => { switch (finalPath) { + // Special case because Referral Sources is an Array, not an Object. + case 'referral_source': + return generateOptionsFromArray(bodyshop, 'md_referral_sources'); + case 'class': + return generateOptionsFromArray(bodyshop, 'md_classes'); case 'cost_centers': return generateOptionsFromObject(bodyshop, 'md_responsibility_centers.costs', 'name', 'name'); // Special case because Categories is an Array, not an Object. case 'categories': - const catOptions = getValueFromPath(bodyshop, 'md_categories'); - return uniqBy(catOptions.map((value) => ({ - label: value, - value: value, - })), 'value'); + return generateOptionsFromArray(bodyshop, 'md_categories'); case 'insurance_companies': return generateOptionsFromObject(bodyshop, 'md_ins_cos', 'name', 'name'); case 'employee_teams': @@ -118,4 +134,4 @@ const generateInternalReflections = ({bodyshop, upperPath, finalPath}) => { } }; -export {generateInternalReflections,} \ No newline at end of file +export {generateInternalReflections} \ No newline at end of file diff --git a/client/src/components/scoreboard-day-stats/scoreboard-day-stats.component.jsx b/client/src/components/scoreboard-day-stats/scoreboard-day-stats.component.jsx index 437983a69..a673b4c20 100644 --- a/client/src/components/scoreboard-day-stats/scoreboard-day-stats.component.jsx +++ b/client/src/components/scoreboard-day-stats/scoreboard-day-stats.component.jsx @@ -26,6 +26,8 @@ export function ScoreboardDayStats({bodyshop, date, entries}) { return acc + value.bodyhrs; }, 0); + const numJobs = entries.length; + return ( bodyHrs ? "red" : "green"}} - label="B" + label="Body" value={bodyHrs.toFixed(1)} /> paintHrs ? "red" : "green"}} - label="P" + label="Refinish" value={paintHrs.toFixed(1)} /> - - + + + ); } diff --git a/client/src/components/scoreboard-targets-table/scoreboard-targets-table.component.jsx b/client/src/components/scoreboard-targets-table/scoreboard-targets-table.component.jsx index 3cfb5aeff..75f98c69f 100644 --- a/client/src/components/scoreboard-targets-table/scoreboard-targets-table.component.jsx +++ b/client/src/components/scoreboard-targets-table/scoreboard-targets-table.component.jsx @@ -26,227 +26,260 @@ export function ScoreboardTargetsTable({bodyshop, scoreBoardlist}) { const values = useMemo(() => { const dateHash = _.groupBy(scoreBoardlist, "date"); - let ret = { - todayBody: 0, - todayPaint: 0, - weeklyPaint: 0, - weeklyBody: 0, - toDateBody: 0, - toDatePaint: 0, - }; + let ret = { + todayBody: 0, + todayPaint: 0, + todayJobs: 0, + weeklyPaint: 0, + weeklyJobs: 0, + weeklyBody: 0, + toDateBody: 0, + toDatePaint: 0, + toDateJobs: 0, + }; - const today = dayjs(); - if (dateHash[today.format("YYYY-MM-DD")]) { - dateHash[today.format("YYYY-MM-DD")].forEach((d) => { - ret.todayBody = ret.todayBody + d.bodyhrs; - ret.todayPaint = ret.todayPaint + d.painthrs; - }); - } + const today = dayjs(); + if (dateHash[today.format("YYYY-MM-DD")]) { + dateHash[today.format("YYYY-MM-DD")].forEach((d) => { + ret.todayBody = ret.todayBody + d.bodyhrs; + ret.todayPaint = ret.todayPaint + d.painthrs; + ret.todayJobs++; + }); + } - let StartOfWeek = dayjs().startOf("week"); - while (StartOfWeek.isSameOrBefore(today)) { - if (dateHash[StartOfWeek.format("YYYY-MM-DD")]) { - dateHash[StartOfWeek.format("YYYY-MM-DD")].forEach((d) => { - ret.weeklyBody = ret.weeklyBody + d.bodyhrs; - ret.weeklyPaint = ret.weeklyPaint + d.painthrs; - }); - } - StartOfWeek = StartOfWeek.add(1, "day"); - } + let StartOfWeek = dayjs().startOf("week"); + while (StartOfWeek.isSameOrBefore(today)) { + if (dateHash[StartOfWeek.format("YYYY-MM-DD")]) { + dateHash[StartOfWeek.format("YYYY-MM-DD")].forEach((d) => { + ret.weeklyBody = ret.weeklyBody + d.bodyhrs; + ret.weeklyPaint = ret.weeklyPaint + d.painthrs; + ret.weeklyJobs++; + }); + } + StartOfWeek = StartOfWeek.add(1, "day"); + } - let startOfMonth = dayjs().startOf("month"); - while (startOfMonth.isSameOrBefore(today)) { - if (dateHash[startOfMonth.format("YYYY-MM-DD")]) { - dateHash[startOfMonth.format("YYYY-MM-DD")].forEach((d) => { - ret.toDateBody = ret.toDateBody + d.bodyhrs; - ret.toDatePaint = ret.toDatePaint + d.painthrs; - }); - } - startOfMonth = startOfMonth.add(1, "day"); - } + let startOfMonth = dayjs().startOf("month"); + while (startOfMonth.isSameOrBefore(today)) { + if (dateHash[startOfMonth.format("YYYY-MM-DD")]) { + dateHash[startOfMonth.format("YYYY-MM-DD")].forEach((d) => { + ret.toDateBody = ret.toDateBody + d.bodyhrs; + ret.toDatePaint = ret.toDatePaint + d.painthrs; + ret.toDateJobs++; + }); + } + startOfMonth = startOfMonth.add(1, "day"); + } return ret; }, [scoreBoardlist]); - return ( - } - > - - - } - /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); + return ( + } + > + + + } + /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); } - export default connect( - mapStateToProps, - mapDispatchToProps + mapStateToProps, + mapDispatchToProps )(ScoreboardTargetsTable); diff --git a/client/src/pages/dms/dms.container.jsx b/client/src/pages/dms/dms.container.jsx index f6509b6ba..0c2d01825 100644 --- a/client/src/pages/dms/dms.container.jsx +++ b/client/src/pages/dms/dms.container.jsx @@ -16,8 +16,13 @@ import LoadingSpinner from "../../components/loading-spinner/loading-spinner.com import {OwnerNameDisplayFunction} from "../../components/owner-name-display/owner-name-display.component"; import {auth} from "../../firebase/firebase.utils"; import {QUERY_JOB_EXPORT_DMS} from "../../graphql/jobs.queries"; -import {setBreadcrumbs, setSelectedHeader,} from "../../redux/application/application.actions"; +import { + insertAuditTrail, + setBreadcrumbs, + setSelectedHeader, +} from "../../redux/application/application.actions"; import {selectBodyshop} from "../../redux/user/user.selectors"; +import AuditTrailMapping from "../../utils/AuditTrailMappings"; const mapStateToProps = createStructuredSelector({ bodyshop: selectBodyshop, @@ -26,6 +31,8 @@ const mapStateToProps = createStructuredSelector({ const mapDispatchToProps = (dispatch) => ({ setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)), setSelectedHeader: (key) => dispatch(setSelectedHeader(key)), + insertAuditTrail: ({ jobid, operation }) => + dispatch(insertAuditTrail({ jobid, operation })), }); export default connect(mapStateToProps, mapDispatchToProps)(DmsContainer); @@ -45,7 +52,12 @@ export const socket = SocketIO( } ); -export function DmsContainer({bodyshop, setBreadcrumbs, setSelectedHeader}) { +export function DmsContainer({ + bodyshop, + setBreadcrumbs, + setSelectedHeader, + insertAuditTrail, + }) { const {t} = useTranslation(); const [logLevel, setLogLevel] = useState("DEBUG"); const history = useNavigate(); @@ -103,6 +115,10 @@ export function DmsContainer({bodyshop, setBreadcrumbs, setSelectedHeader}) { notification.success({ message: t("jobs.successes.exported"), }); + insertAuditTrail({ + jobid: payload, + operation: AuditTrailMapping.jobexported(), + }); history("/manage/accounting/receivables"); }); diff --git a/client/src/redux/application/application.sagas.js b/client/src/redux/application/application.sagas.js index a2f410d52..ba001271e 100644 --- a/client/src/redux/application/application.sagas.js +++ b/client/src/redux/application/application.sagas.js @@ -281,12 +281,12 @@ export function* insertAuditTrailSaga({ yield client.mutate({ mutation: INSERT_AUDIT_TRAIL, variables, - update(cache, {data}) { + update(cache, { data }) { cache.modify({ fields: { - audit_trail(existingAuditTrail, {readField}) { + audit_trail(existingAuditTrail, { readField }) { const newAuditTrail = cache.writeQuery({ - data: data.insert_audit_trail_one, + data: data, query: INSERT_AUDIT_TRAIL, variables, }); diff --git a/client/src/translations/en_us/common.json b/client/src/translations/en_us/common.json index 50463c401..ddd7dbc11 100644 --- a/client/src/translations/en_us/common.json +++ b/client/src/translations/en_us/common.json @@ -107,13 +107,14 @@ "alerttoggle": "Alert Toggle set to {{status}}", "appointmentcancel": "Appointment canceled. Lost Reason: {{lost_sale_reason}}.", "appointmentinsert": "Appointment created. Appointment Date: {{start}}.", - "billposted": "Bill with invoice number {{invoice_number}} posted.", + "billdeleted": "Bill with invoice number {{invoice_number}} deleted.", + "billposted": "Bill with invoice number {{invoice_number}} posted.", "billupdated": "Bill with invoice number {{invoice_number}} updated.", "failedpayment": "Failed payment", "jobassignmentchange": "Employee {{name}} assigned to {{operation}}", "jobassignmentremoved": "Employee assignment removed for {{operation}}", "jobchecklist": "Checklist type \"{{type}}\" completed. In production set to {{inproduction}}. Status set to {{status}}.", - "jobconverted": "Job converted and assigned number {{ro_number}}.", + "jobconverted": "Job converted and assigned number {{ro_number}}.","jobexported": "Job has been exported.", "jobfieldchanged": "Job field $t(jobs.fields.{{field}}) changed to {{value}}.", "jobimported": "Job imported.", "jobinproductionchange": "Job production status set to {{inproduction}}", @@ -126,8 +127,9 @@ "jobspartsorder": "Parts order {{order_number}} added to Job.", "jobspartsreturn": "Parts return {{order_number}} added to Job.", "jobstatuschange": "Job status changed to {{status}}.", - "jobsupplement": "Job supplement imported." - } + "jobsupplement": "Job supplement imported.", + "jobsuspend": "Suspend Toggle set to {{status}}", + "jobvoid": "Job has been voided."} }, "billlines": { "actions": { @@ -2620,6 +2622,8 @@ "advanced_filters_sorters": "Sorters", "advanced_filters_filter_field": "Field", "advanced_filters_sorter_field": "Field", + "advanced_filters_true": "True", + "advanced_filters_false": "False", "advanced_filters_sorter_direction": "Direction", "advanced_filters_filter_operator": "Operator", "advanced_filters_filter_value": "Value", @@ -2793,7 +2797,8 @@ "allemployeetimetickets": "All Employee Time Tickets", "asoftodaytarget": "As of Today", "body": "Body", - "bodycharttitle": "Body Targets vs Actual", + "bodyabbrev": "B", + "bodycharttitle": "Body Targets vs Actual", "calendarperiod": "Periods based on calendar weeks/months.", "combinedcharttitle": "Combined Targets vs Actual", "dailyactual": "Actual (D)", @@ -2808,14 +2813,14 @@ "priorweek": "Prior Week", "productivestatistics": "Productive Hours Statistics", "productivetimeticketsoverdate": "Productive Hours over Selected Dates", - "refinish": "Refinish", + "refinish": "Refinish","refinishabbrev": "R", "refinishcharttitle": "Refinish Targets vs Actual", "targets": "Targets", "thismonth": "This Month", "thisweek": "This Week", "timetickets": "Time Tickets", "timeticketsemployee": "Time Tickets by Employee", - "todateactual": "Actual (MTD)", + "todateactual": "Actual (MTD)","total": "Total", "totalhrs": "Total Hours", "totaloverperiod": "Total over Selected Dates", "weeklyactual": "Actual (W)", diff --git a/client/src/translations/es/common.json b/client/src/translations/es/common.json index f511c3dee..ea403b6ed 100644 --- a/client/src/translations/es/common.json +++ b/client/src/translations/es/common.json @@ -107,14 +107,16 @@ "alerttoggle": "", "appointmentcancel": "", "appointmentinsert": "", - "billposted": "", + "billdeleted": "", + "billposted": "", "billupdated": "", "failedpayment": "", "jobassignmentchange": "", "jobassignmentremoved": "", "jobchecklist": "", "jobconverted": "", - "jobfieldchanged": "", + "jobexported": "", + "jobfieldchanged": "", "jobimported": "", "jobinproductionchange": "", "jobinvoiced": "", @@ -126,8 +128,9 @@ "jobspartsorder": "", "jobspartsreturn": "", "jobstatuschange": "", - "jobsupplement": "" - } + "jobsupplement": "", + "jobsuspend": "", + "jobvoid": ""} }, "billlines": { "actions": { @@ -2620,6 +2623,8 @@ "advanced_filters_sorters": "", "advanced_filters_filter_field": "", "advanced_filters_sorter_field": "", + "advanced_filters_true": "", + "advanced_filters_false": "", "advanced_filters_sorter_direction": "", "advanced_filters_filter_operator": "", "advanced_filters_filter_value": "", @@ -2793,7 +2798,8 @@ "allemployeetimetickets": "", "asoftodaytarget": "", "body": "", - "bodycharttitle": "", + "bodyabbrev": "", + "bodycharttitle": "", "calendarperiod": "", "combinedcharttitle": "", "dailyactual": "", @@ -2809,13 +2815,14 @@ "productivestatistics": "", "productivetimeticketsoverdate": "", "refinish": "", - "refinishcharttitle": "", + "refinishabbrev": "", + "refinishcharttitle": "", "targets": "", "thismonth": "", "thisweek": "", "timetickets": "", "timeticketsemployee": "", - "todateactual": "", + "todateactual": "","total": "", "totalhrs": "", "totaloverperiod": "", "weeklyactual": "", diff --git a/client/src/translations/fr/common.json b/client/src/translations/fr/common.json index 19007b8e1..569b13e3e 100644 --- a/client/src/translations/fr/common.json +++ b/client/src/translations/fr/common.json @@ -107,14 +107,16 @@ "alerttoggle": "", "appointmentcancel": "", "appointmentinsert": "", - "billposted": "", + "billdeleted": "", + "billposted": "", "billupdated": "", "failedpayment": "", "jobassignmentchange": "", "jobassignmentremoved": "", "jobchecklist": "", "jobconverted": "", - "jobfieldchanged": "", + "jobexported": "", + "jobfieldchanged": "", "jobimported": "", "jobinproductionchange": "", "jobinvoiced": "", @@ -126,8 +128,9 @@ "jobspartsorder": "", "jobspartsreturn": "", "jobstatuschange": "", - "jobsupplement": "" - } + "jobsupplement": "", + "jobsuspend": "", + "jobvoid": ""} }, "billlines": { "actions": { @@ -2620,6 +2623,8 @@ "advanced_filters_sorters": "", "advanced_filters_filter_field": "", "advanced_filters_sorter_field": "", + "advanced_filters_true": "", + "advanced_filters_false": "", "advanced_filters_sorter_direction": "", "advanced_filters_filter_operator": "", "advanced_filters_filter_value": "", @@ -2793,7 +2798,8 @@ "allemployeetimetickets": "", "asoftodaytarget": "", "body": "", - "bodycharttitle": "", + "bodyabbrev": "", + "bodycharttitle": "", "calendarperiod": "", "combinedcharttitle": "", "dailyactual": "", @@ -2809,13 +2815,14 @@ "productivestatistics": "", "productivetimeticketsoverdate": "", "refinish": "", - "refinishcharttitle": "", + "refinishabbrev": "", + "refinishcharttitle": "", "targets": "", "thismonth": "", "thisweek": "", "timetickets": "", "timeticketsemployee": "", - "todateactual": "", + "todateactual": "","total": "", "totalhrs": "", "totaloverperiod": "", "weeklyactual": "", diff --git a/client/src/utils/AuditTrailMappings.js b/client/src/utils/AuditTrailMappings.js index 6a5b9ed56..e88d723f0 100644 --- a/client/src/utils/AuditTrailMappings.js +++ b/client/src/utils/AuditTrailMappings.js @@ -20,6 +20,8 @@ const AuditTrailMapping = { i18n.t("audit_trail.messages.appointmentcancel", {lost_sale_reason}), appointmentinsert: (start) => i18n.t("audit_trail.messages.appointmentinsert", {start}), + billdeleted: (invoice_number) => + i18n.t("audit_trail.messages.billdeleted", { invoice_number }), billposted: (invoice_number) => i18n.t("audit_trail.messages.billposted", {invoice_number}), billupdated: (invoice_number) => @@ -33,6 +35,7 @@ const AuditTrailMapping = { i18n.t("audit_trail.messages.jobchecklist", {type, inproduction, status}), jobconverted: (ro_number) => i18n.t("audit_trail.messages.jobconverted", {ro_number}), + jobexported: () => i18n.t("audit_trail.messages.jobexported"), jobfieldchange: (field, value) => i18n.t("audit_trail.messages.jobfieldchanged", {field, value}), jobimported: () => i18n.t("audit_trail.messages.jobimported"), @@ -51,6 +54,8 @@ const AuditTrailMapping = { jobstatuschange: (status) => i18n.t("audit_trail.messages.jobstatuschange", {status}), jobsupplement: () => i18n.t("audit_trail.messages.jobsupplement"), + jobsuspend: (status) => i18n.t("audit_trail.messages.jobsuspend", { status }), + jobvoid: () => i18n.t("audit_trail.messages.jobvoid"), }; export default AuditTrailMapping; diff --git a/client/src/utils/graphQLmodifier.js b/client/src/utils/graphQLmodifier.js index 77aa14036..d31c6584a 100644 --- a/client/src/utils/graphQLmodifier.js +++ b/client/src/utils/graphQLmodifier.js @@ -2,22 +2,66 @@ import {Kind, parse, print, visit} from "graphql"; import client from "./GraphQLClient"; import {gql} from "@apollo/client"; +/* eslint-disable no-loop-func */ + +/** + * The available operators for filtering (string) + * @type {[{label: string, value: string},{label: string, value: string},{label: string, value: string},{label: string, value: string},{label: string, value: string},null,null,null]} + */ const STRING_OPERATORS = [ {value: "_eq", label: "equals"}, {value: "_neq", label: "does not equal"}, {value: "_like", label: "contains"}, {value: "_nlike", label: "does not contain"}, {value: "_ilike", label: "contains case-insensitive"}, - {value: "_nilike", label: "does not contain case-insensitive"} + {value: "_nilike", label: "does not contain case-insensitive"}, + {value: "_in", label: "in", type: "array"}, + {value: "_nin", label: "not in", type: "array"} ]; + +/** + * The available operators for filtering (dates) + * @type {[{label: string, value: string},{label: string, value: string},{label: string, value: string},{label: string, value: string},{label: string, value: string},null,null,null]} + */ +const DATE_OPERATORS = [ + {value: "_eq", label: "equals"}, + {value: "_neq", label: "does not equal"}, + {value: "_gt", label: "greater than"}, + {value: "_lt", label: "less than"}, + {value: "_gte", label: "greater than or equal"}, + {value: "_lte", label: "less than or equal"}, + {value: "_in", label: "in", type: "array"}, + {value: "_nin", label: "not in", type: "array"} +]; + +/** + * The available operators for filtering (booleans) + * @type {[{label: string, value: string},{label: string, value: string}]} + */ +const BOOLEAN_OPERATORS = [ + {value: "_eq", label: "equals"}, + {value: "_neq", label: "does not equal"}, +]; + +/** + * The available operators for filtering (numbers) + * @type {[{label: string, value: string},{label: string, value: string},{label: string, value: string},{label: string, value: string},{label: string, value: string},null,null,null]} + */ const NUMBER_OPERATORS = [ {value: "_eq", label: "equals"}, {value: "_neq", label: "does not equal"}, {value: "_gt", label: "greater than"}, {value: "_lt", label: "less than"}, {value: "_gte", label: "greater than or equal"}, - {value: "_lte", label: "less than or equal"} + {value: "_lte", label: "less than or equal"}, + {value: "_in", label: "in", type: "array"}, + {value: "_nin", label: "not in", type: "array"} ]; + +/** + * The available operators for sorting + * @type {[{label: string, value: string},{label: string, value: string}]} + */ const ORDER_BY_OPERATORS = [ {value: "asc", label: "ascending"}, {value: "desc", label: "descending"} @@ -31,7 +75,6 @@ export function getOrderOperatorsByType() { return ORDER_BY_OPERATORS; } - /** * Get the available operators for filtering * @param type @@ -40,13 +83,14 @@ export function getOrderOperatorsByType() { export function getWhereOperatorsByType(type = 'string') { const operators = { string: STRING_OPERATORS, - number: NUMBER_OPERATORS + number: NUMBER_OPERATORS, + boolean: BOOLEAN_OPERATORS, + bool: BOOLEAN_OPERATORS, + date: DATE_OPERATORS }; return operators[type]; } -/* eslint-disable no-loop-func */ - /** * Parse a GraphQL query into an AST * @param query @@ -78,11 +122,9 @@ export async function generateTemplate(templateQueryToExecute, templateObject, u // Parse the query and apply the filters and sorters const ast = parseQuery(templateQueryToExecute); - let filterFields = []; if (templateObject?.filters && templateObject?.filters?.length) { - applyFilters(ast, templateObject.filters, filterFields); - wrapFiltersInAnd(ast, filterFields); + applyFilters(ast, templateObject.filters); } if (templateObject?.sorters && templateObject?.sorters?.length) { @@ -109,7 +151,6 @@ export async function generateTemplate(templateQueryToExecute, templateObject, u return {contextData, useShopSpecificTemplate}; } - /** * Apply sorters to the AST * @param ast @@ -170,10 +211,59 @@ export function applySorters(ast, sorters) { }); } +/** + * Apply Top Level Sub to the AST + * @param node + * @param fieldPath + * @param filterField + */ +function applyTopLevelSub(node, fieldPath, filterField) { + // Find or create the where argument for the top-level subfield + let whereArg = node.selectionSet.selections + .find(selection => selection.name.value === fieldPath[0]) + ?.arguments.find(arg => arg.name.value === 'where'); + + if (!whereArg) { + whereArg = { + kind: Kind.ARGUMENT, + name: {kind: Kind.NAME, value: 'where'}, + value: {kind: Kind.OBJECT, fields: []}, + }; + const topLevelSubSelection = node.selectionSet.selections.find(selection => + selection.name.value === fieldPath[0] + ); + if (topLevelSubSelection) { + topLevelSubSelection.arguments = topLevelSubSelection.arguments || []; + topLevelSubSelection.arguments.push(whereArg); + } + } + + // Correctly position the nested filter without an extra 'where' + if (fieldPath.length > 2) { // More than one level deep + let currentField = whereArg.value; + fieldPath.slice(1, -1).forEach((path, index) => { + let existingField = currentField.fields.find(f => f.name.value === path); + if (!existingField) { + existingField = { + kind: Kind.OBJECT_FIELD, + name: {kind: Kind.NAME, value: path}, + value: {kind: Kind.OBJECT, fields: []} + }; + currentField.fields.push(existingField); + } + currentField = existingField.value; + }); + currentField.fields.push(filterField); + } else { // Directly under the top level + whereArg.value.fields.push(filterField); + } +} + /** * Apply filters to the AST * @param ast * @param filters + * @returns {ASTNode} */ export function applyFilters(ast, filters) { return visit(ast, { @@ -182,192 +272,197 @@ export function applyFilters(ast, filters) { filters.forEach(filter => { const fieldPath = filter.field.split('.'); let topLevel = false; + let topLevelSub = false; // Determine if the filter should be applied at the top level - if (fieldPath[0].startsWith('[') && fieldPath[0].endsWith(']')) { - fieldPath[0] = fieldPath[0].substring(1, fieldPath[0].length - 1); // Strip the brackets + if (fieldPath.length === 2) { topLevel = true; } - if (topLevel) { - // Construct the filter for a top-level application - const targetFieldName = fieldPath[fieldPath.length - 1]; - const filterValue = { - kind: getGraphQLKind(filter.value), - value: filter.value, - }; - - const nestedFilter = { - kind: Kind.OBJECT_FIELD, - name: {kind: Kind.NAME, value: targetFieldName}, - value: { - kind: Kind.OBJECT, - fields: [{ - kind: Kind.OBJECT_FIELD, - name: {kind: Kind.NAME, value: filter.operator}, - value: filterValue, - }], - }, - }; - - // Find or create the where argument for the top-level field - let whereArg = node.selectionSet.selections - .find(selection => selection.name.value === fieldPath[0]) - ?.arguments.find(arg => arg.name.value === 'where'); - - if (!whereArg) { - whereArg = { - kind: Kind.ARGUMENT, - name: {kind: Kind.NAME, value: 'where'}, - value: {kind: Kind.OBJECT, fields: []}, - }; - const topLevelSelection = node.selectionSet.selections.find(selection => - selection.name.value === fieldPath[0] - ); - if (topLevelSelection) { - topLevelSelection.arguments = topLevelSelection.arguments || []; - topLevelSelection.arguments.push(whereArg); - } - } - - // Correctly position the nested filter without an extra 'where' - if (fieldPath.length > 2) { // More than one level deep - let currentField = whereArg.value; - fieldPath.slice(1, -1).forEach((path, index) => { - let existingField = currentField.fields.find(f => f.name.value === path); - if (!existingField) { - existingField = { - kind: Kind.OBJECT_FIELD, - name: {kind: Kind.NAME, value: path}, - value: {kind: Kind.OBJECT, fields: []} - }; - currentField.fields.push(existingField); - } - currentField = existingField.value; - }); - currentField.fields.push(nestedFilter); - } else { // Directly under the top level - whereArg.value.fields.push(nestedFilter); - } - } else { - // Initialize a reference to the current selection to traverse down the AST - let currentSelection = node; - let whereArgFound = false; - - // Iterate over the fieldPath, except for the last entry, to navigate the structure - for (let i = 0; i < fieldPath.length - 1; i++) { - const fieldName = fieldPath[i]; - let fieldFound = false; - - // Check if the current selection has a selectionSet and selections - if (currentSelection.selectionSet && currentSelection.selectionSet.selections) { - // Look for the field in the current selection's selections - const selection = currentSelection.selectionSet.selections.find(sel => sel.name.value === fieldName); - if (selection) { - // Move down the AST to the found selection - currentSelection = selection; - fieldFound = true; - } - } - - // If the field was not found in the current path, it's an issue - if (!fieldFound) { - console.error(`Field ${fieldName} not found in the current selection.`); - return; // Exit the loop and function due to error - } - } - - // At this point, currentSelection should be the parent field where the filter needs to be applied - // Check if the 'where' argument already exists in the current selection - const whereArg = currentSelection.arguments.find(arg => arg.name.value === 'where'); - if (whereArg) { - whereArgFound = true; - } else { - // If not found, create a new 'where' argument for the current selection - currentSelection.arguments.push({ - kind: Kind.ARGUMENT, - name: {kind: Kind.NAME, value: 'where'}, - value: {kind: Kind.OBJECT, fields: []} // Empty fields array to be populated with the filter - }); - } - - // Assuming the last entry in fieldPath is the field to apply the filter on - const targetField = fieldPath[fieldPath.length - 1]; - const filterValue = { - kind: getGraphQLKind(filter.value), - value: filter.value, - }; - - // Construct the filter field object - const filterField = { - kind: Kind.OBJECT_FIELD, - name: {kind: Kind.NAME, value: targetField}, - value: { - kind: Kind.OBJECT, - fields: [{ - kind: Kind.OBJECT_FIELD, - name: {kind: Kind.NAME, value: filter.operator}, - value: filterValue, - }], - }, - }; - - // Add the filter field to the 'where' clause of the current selection - if (whereArgFound) { - whereArg.value.fields.push(filterField); - } else { - // If the whereArg was newly created, find it again (since we didn't store its reference) and add the filter - currentSelection.arguments.find(arg => arg.name.value === 'where').value.fields.push(filterField); - } + if (fieldPath.length > 2 && fieldPath[0].startsWith('[') && fieldPath[0].endsWith(']')) { + fieldPath[0] = fieldPath[0].substring(1, fieldPath[0].length - 1); // Strip the brackets + topLevelSub = true; } + // Construct the filter for a top-level application + const targetFieldName = fieldPath[fieldPath.length - 1]; + + let filterValue = createFilterValue(filter); + let filterField = createFilterField(targetFieldName, filter, filterValue); + + if (topLevel) { + applyTopLevelFilter(node, fieldPath, filterField); + } else if (topLevelSub) { + applyTopLevelSub(node, fieldPath, filterField); + } else { + applyNestedFilter(node, fieldPath, filterField); + } }); } } }); } +/** + * Create a filter value based on the filter + * @param filter + * @returns {{kind: (Kind|Kind.INT), value}|{kind: Kind.LIST, values: *}} + */ +function createFilterValue(filter) { + if (Array.isArray(filter.value)) { + // If it's an array, create a list value with the array items + return { + kind: Kind.LIST, + values: filter.value.map(item => ({ + kind: getGraphQLKind(item), + value: item, + })), + }; + } else { + // If it's not an array, use the existing logic + return { + kind: getGraphQLKind(filter.value), + value: filter.value, + }; + } +} + +/** + * Create a filter field based on the target field and filter + * @param targetFieldName + * @param filter + * @param filterValue + * @returns {{kind: Kind.OBJECT_FIELD, name: {kind: Kind.NAME, value}, value: {kind: Kind.OBJECT, fields: [{kind: Kind.OBJECT_FIELD, name: {kind: Kind.NAME, value}, value}]}}} + */ +function createFilterField(targetFieldName, filter, filterValue) { + return { + kind: Kind.OBJECT_FIELD, + name: {kind: Kind.NAME, value: targetFieldName}, + value: { + kind: Kind.OBJECT, + fields: [{ + kind: Kind.OBJECT_FIELD, + name: {kind: Kind.NAME, value: filter.operator}, + value: filterValue, + }], + }, + }; +} + +/** + * Apply a top-level filter to the AST + * @param node + * @param fieldPath + * @param filterField + */ +function applyTopLevelFilter(node, fieldPath, filterField) { + // Find or create the where argument for the top-level field + let whereArg = node.selectionSet.selections + .find(selection => selection.name.value === fieldPath[0]) + ?.arguments.find(arg => arg.name.value === 'where'); + + if (!whereArg) { + whereArg = { + kind: Kind.ARGUMENT, + name: {kind: Kind.NAME, value: 'where'}, + value: {kind: Kind.OBJECT, fields: []}, + }; + const topLevelSelection = node.selectionSet.selections.find(selection => + selection.name.value === fieldPath[0] + ); + if (topLevelSelection) { + topLevelSelection.arguments = topLevelSelection.arguments || []; + topLevelSelection.arguments.push(whereArg); + } + } + + // Correctly position the nested filter without an extra 'where' + if (fieldPath.length > 2) { // More than one level deep + let currentField = whereArg.value; + fieldPath.slice(1, -1).forEach((path, index) => { + let existingField = currentField.fields.find(f => f.name.value === path); + if (!existingField) { + existingField = { + kind: Kind.OBJECT_FIELD, + name: {kind: Kind.NAME, value: path}, + value: {kind: Kind.OBJECT, fields: []} + }; + currentField.fields.push(existingField); + } + currentField = existingField.value; + }); + currentField.fields.push(filterField); + } else { // Directly under the top level + whereArg.value.fields.push(filterField); + } +} + +/** + * Apply a nested filter to the AST + * @param node + * @param fieldPath + * @param filterField + */ +function applyNestedFilter(node, fieldPath, filterField) { + // Initialize a reference to the current selection to traverse down the AST + let currentSelection = node; + + // Iterate over the fieldPath, except for the last entry, to navigate the structure + for (let i = 0; i < fieldPath.length - 1; i++) { + const fieldName = fieldPath[i]; + let fieldFound = false; + + // Check if the current selection has a selectionSet and selections + if (currentSelection.selectionSet && currentSelection.selectionSet.selections) { + // Look for the field in the current selection's selections + const selection = currentSelection.selectionSet.selections.find(sel => sel.name.value === fieldName); + if (selection) { + // Move down the AST to the found selection + currentSelection = selection; + fieldFound = true; + } + } + + // If the field was not found in the current path, it's an issue + if (!fieldFound) { + console.error(`Field ${fieldName} not found in the current selection.`); + return; // Exit the loop and function due to error + } + } + + // At this point, currentSelection should be the parent field where the filter needs to be applied + // Check if the 'where' argument already exists in the current selection + const whereArg = currentSelection.arguments.find(arg => arg.name.value === 'where'); + if (!whereArg) { + // If not found, create a new 'where' argument for the current selection + currentSelection.arguments.push({ + kind: Kind.ARGUMENT, + name: {kind: Kind.NAME, value: 'where'}, + value: {kind: Kind.OBJECT, fields: []} // Empty fields array to be populated with the filter + }); + } + + // Add the filter field to the 'where' clause of the current selection + currentSelection.arguments.find(arg => arg.name.value === 'where').value.fields.push(filterField); +} + /** * Get the GraphQL kind for a value * @param value * @returns {Kind|Kind.INT} */ function getGraphQLKind(value) { - if (typeof value === 'number') { + if (Array.isArray(value)) { + return Kind.LIST; + } else if (typeof value === 'number') { return value % 1 === 0 ? Kind.INT : Kind.FLOAT; } else if (typeof value === 'boolean') { return Kind.BOOLEAN; } else if (typeof value === 'string') { return Kind.STRING; + } else if (value instanceof Date) { + return Kind.STRING; // GraphQL does not have a Date type, so we return it as a string } - // Extend with more types as needed } -/** - * Wrap filters in an 'and' object - * @param ast - * @param filterFields - */ -export function wrapFiltersInAnd(ast, filterFields) { - visit(ast, { - OperationDefinition: { - enter(node) { - node.selectionSet.selections.forEach((selection) => { - let whereArg = selection.arguments.find(arg => arg.name.value === 'where'); - if (filterFields.length > 1) { - const andFilter = { - kind: Kind.OBJECT_FIELD, - name: {kind: Kind.NAME, value: '_and'}, - value: {kind: Kind.LIST, values: filterFields} - }; - whereArg.value.fields.push(andFilter); - } else if (filterFields.length === 1) { - whereArg.value.fields.push(filterFields[0].fields[0]); - } - }); - } - } - }); -} - -/* eslint-enable no-loop-func */ \ No newline at end of file +/* eslint-enable no-loop-func */ diff --git a/package.json b/package.json index 4243c00fc..00e256da3 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@aws-sdk/client-ses": "^3.515.0", "@aws-sdk/credential-provider-node": "^3.515.0", "@opensearch-project/opensearch": "^2.5.0", + "@azure/storage-blob": "^12.17.0", "aws4": "^1.12.0", "axios": "^1.6.5", "bluebird": "^3.7.2", @@ -61,6 +62,7 @@ "xmlbuilder2": "^3.1.1" }, "devDependencies": { + "@trivago/prettier-plugin-sort-imports": "^4.3.0", "concurrently": "^8.2.2", "source-map-explorer": "^2.5.2" }