Compare commits

..

1 Commits

Author SHA1 Message Date
Dave
1ad7468d14 feature/IO-3401-Parts-Rec-Enhanced - Implement 2025-12-23 11:27:14 -05:00
20 changed files with 165 additions and 115 deletions

View File

@@ -253,10 +253,6 @@ export function ContractConvertToRo({ bodyshop, currentUser, contract, disabled
}
};
if (currentUser?.email) {
newJob.created_user_email = currentUser.email;
}
//Calcualte the new job totals.
const newTotals = (

View File

@@ -43,18 +43,16 @@ export function JobCreateIOU({ bodyshop, currentUser, job, selectedJobLines, tec
const handleCreateIou = async () => {
setLoading(true);
//Query all of the job details to recreate.
const iouId = await CreateIouForJob({
apolloClient: client,
jobLinesToKeep: selectedJobLines,
jobId: job.id,
config: {
const iouId = await CreateIouForJob(
client,
job.id,
{
status: bodyshop.md_ro_statuses.default_open,
bodyshopid: bodyshop.id,
useremail: currentUser.email
},
currentUser
});
selectedJobLines
);
notification.open({
type: "success",
message: t("jobs.successes.ioucreated"),

View File

@@ -0,0 +1,105 @@
import { useCallback, useMemo, useState } from "react";
import PropTypes from "prop-types";
import { Popover } from "antd";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import JobPartsQueueCount from "../job-parts-queue-count/job-parts-queue-count.component";
import { useTranslation } from "react-i18next";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
/**
* Displays "Parts Received" summary (modeled after the Production Board List column),
* and on click shows a popover with the Parts Status grid (existing JobPartsQueueCount UI).
* @param bodyshop
* @param parts
* @param displayMode
* @param popoverPlacement
* @returns {JSX.Element}
* @constructor
*/
export function JobPartsReceived({ bodyshop, parts, displayMode = "full", popoverPlacement = "top" }) {
const [open, setOpen] = useState(false);
const { t } = useTranslation();
const summary = useMemo(() => {
const receivedStatus = bodyshop?.md_order_statuses?.default_received;
if (!Array.isArray(parts) || parts.length === 0 || !receivedStatus) {
return { total: 0, received: 0, percentLabel: t("general.labels.na") };
}
// Keep consistent with JobPartsQueueCount: exclude PAS / PASL from parts math
const { total, received } = parts.reduce(
(acc, val) => {
if (val?.part_type === "PAS" || val?.part_type === "PASL") return acc;
const count = Number(val?.count || 0);
acc.total += count;
if (val?.status === receivedStatus) {
acc.received += count;
}
return acc;
},
{ total: 0, received: 0 }
);
const percentLabel = total > 0 ? `${Math.round((received / total) * 100)}%` : t("general.labels.na");
return { total, received, percentLabel };
}, [parts, bodyshop?.md_order_statuses?.default_received]);
const canOpen = summary.total > 0;
const handleOpenChange = useCallback(
(nextOpen) => {
if (!canOpen) return;
setOpen(nextOpen);
},
[canOpen]
);
const displayText =
displayMode === "compact" ? summary.percentLabel : `${summary.percentLabel} (${summary.received}/${summary.total})`;
// Prevent row/cell click handlers (table selection, drawer selection, etc.)
const stop = (e) => e.stopPropagation();
return (
<Popover
open={open}
onOpenChange={handleOpenChange}
trigger={["click"]}
placement={popoverPlacement}
content={
<div onClick={stop} style={{ minWidth: 260 }}>
<JobPartsQueueCount parts={parts} />
</div>
}
>
<div
onClick={stop}
style={{
width: "100%",
height: "19px",
cursor: canOpen ? "pointer" : "default",
userSelect: "none"
}}
title={canOpen ? t("production.labels.click_for_statuses") : undefined}
>
{displayText}
</div>
</Popover>
);
}
JobPartsReceived.propTypes = {
bodyshop: PropTypes.object,
parts: PropTypes.array,
displayMode: PropTypes.oneOf(["full", "compact"]),
popoverPlacement: PropTypes.string
};
export default connect(mapStateToProps)(JobPartsReceived);

View File

@@ -154,10 +154,6 @@ export function JobsAvailableContainer({ bodyshop, currentUser, insertAuditTrail
: {})
};
if (currentUser?.email) {
newJob.created_user_email = currentUser.email;
}
if (selectedOwner) {
newJob.ownerid = selectedOwner;
delete newJob.owner;

View File

@@ -175,33 +175,25 @@ export function JobsDetailHeaderActions({
};
const handleDuplicate = () =>
DuplicateJob({
apolloClient: client,
jobId: job.id,
config: { defaultOpenStatus: bodyshop.md_ro_statuses.default_imported },
completionCallback: (newJobId) => {
DuplicateJob(
client,
job.id,
{ defaultOpenStatus: bodyshop.md_ro_statuses.default_imported },
(newJobId) => {
history(`/manage/jobs/${newJobId}`);
notification.success({
message: t("jobs.successes.duplicated")
});
},
keepJobLines: true,
currentUser
});
true
);
const handleDuplicateConfirm = () =>
DuplicateJob({
apolloClient: client,
jobId: job.id,
config: { defaultOpenStatus: bodyshop.md_ro_statuses.default_imported },
completionCallback: (newJobId) => {
history(`/manage/jobs/${newJobId}`);
notification.success({
message: t("jobs.successes.duplicated")
});
},
keepJobLines: false,
currentUser
DuplicateJob(client, job.id, { defaultOpenStatus: bodyshop.md_ro_statuses.default_imported }, (newJobId) => {
history(`/manage/jobs/${newJobId}`);
notification.success({
message: t("jobs.successes.duplicated")
});
});
const handleFinish = async (values) => {

View File

@@ -5,14 +5,7 @@ import { INSERT_NEW_JOB, QUERY_JOB_FOR_DUPE } from "../../graphql/jobs.queries";
import dayjs from "../../utils/day";
import i18n from "i18next";
export default async function DuplicateJob({
apolloClient,
jobId,
config,
completionCallback,
keepJobLines = false,
currentUser
}) {
export default async function DuplicateJob(apolloClient, jobId, config, completionCallback, keepJobLines = false) {
logImEXEvent("job_duplicate");
const { defaultOpenStatus } = config;
@@ -26,7 +19,6 @@ export default async function DuplicateJob({
const existingJob = _.cloneDeep(jobs_by_pk);
delete existingJob.__typename;
delete existingJob.id;
delete existingJob.created_user_email;
delete existingJob.createdat;
delete existingJob.updatedat;
delete existingJob.cieca_stl;
@@ -37,10 +29,6 @@ export default async function DuplicateJob({
status: defaultOpenStatus
};
if (currentUser?.email) {
newJob.created_user_email = currentUser.email;
}
const _tempLines = _.cloneDeep(existingJob.joblines);
_tempLines.forEach((line) => {
delete line.id;
@@ -67,7 +55,7 @@ export default async function DuplicateJob({
return;
}
export async function CreateIouForJob({ apolloClient, jobId, config, jobLinesToKeep, currentUser }) {
export async function CreateIouForJob(apolloClient, jobId, config, jobLinesToKeep) {
logImEXEvent("job_create_iou");
const { status } = config;
@@ -121,9 +109,6 @@ export async function CreateIouForJob({ apolloClient, jobId, config, jobLinesToK
delete newJob.joblines;
newJob.joblines = { data: _tempLines };
if (currentUser?.email) {
newJob.created_user_email = currentUser.email;
}
const res2 = await apolloClient.mutate({
mutation: INSERT_NEW_JOB,
variables: { job: [newJob] }

View File

@@ -16,11 +16,11 @@ import { pageLimit } from "../../utils/config";
import { alphaSort, dateSort } from "../../utils/sorters";
import useLocalStorage from "../../utils/useLocalStorage";
import AlertComponent from "../alert/alert.component";
import JobPartsQueueCount from "../job-parts-queue-count/job-parts-queue-count.component";
import JobRemoveFromPartsQueue from "../job-remove-from-parst-queue/job-remove-from-parts-queue.component";
import OwnerNameDisplay, { OwnerNameDisplayFunction } from "../owner-name-display/owner-name-display.component";
import ProductionListColumnComment from "../production-list-columns/production-list-columns.comment.component";
import { logImEXEvent } from "../../firebase/firebase.utils";
import JobPartsReceived from "../job-parts-received/job-parts-received.component";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
@@ -235,7 +235,9 @@ export function PartsQueueListComponent({ bodyshop }) {
title: t("jobs.fields.partsstatus"),
dataIndex: "partsstatus",
key: "partsstatus",
render: (text, record) => <JobPartsQueueCount parts={record.joblines_status} />
render: (text, record) => (
<JobPartsReceived parts={record.joblines_status} displayMode="full" popoverPlacement="topLeft" />
)
},
{
title: t("jobs.fields.comment"),

View File

@@ -19,6 +19,7 @@ import JobPartsQueueCount from "../job-parts-queue-count/job-parts-queue-count.c
import OwnerNameDisplay from "../owner-name-display/owner-name-display.component";
import ShareToTeamsButton from "../share-to-teams/share-to-teams.component.jsx";
import { PiMicrosoftTeamsLogo } from "react-icons/pi";
import ProductionListColumnPartsReceived from "../production-list-columns/production-list-columns.partsreceived.component";
const cardColor = (ssbuckets, totalHrs) => {
const bucket = ssbuckets.find((bucket) => bucket.gte <= totalHrs && (!bucket.lt || bucket.lt > totalHrs));
@@ -312,6 +313,20 @@ const TasksToolTip = ({ metadata, cardSettings, t }) =>
</Col>
);
const PartsReceivedComponent = ({ metadata, cardSettings, card }) =>
cardSettings?.partsreceived && (
<Col span={24} style={{ textAlign: "center" }}>
<ProductionListColumnPartsReceived
displayMode="full"
popoverPlacement="topLeft"
record={{
...metadata,
id: card?.id,
refetch: card?.refetch
}}
/>
</Col>
);
export default function ProductionBoardCard({ technician, card, bodyshop, cardSettings }) {
const { t } = useTranslation();
const { metadata } = card;
@@ -411,6 +426,7 @@ export default function ProductionBoardCard({ technician, card, bodyshop, cardSe
<SubletsComponent metadata={metadata} cardSettings={cardSettings} />
<ProductionNoteComponent metadata={metadata} cardSettings={cardSettings} card={card} />
<PartsStatusComponent metadata={metadata} cardSettings={cardSettings} />
<PartsReceivedComponent metadata={metadata} cardSettings={cardSettings} card={card} />
</Row>
);

View File

@@ -18,7 +18,8 @@ const InformationSettings = ({ t }) => (
"partsstatus",
"estimator",
"subtotal",
"tasks"
"tasks",
"partsreceived"
].map((item) => (
<Col xs={24} sm={12} md={8} lg={6} key={item}>
<Form.Item name={item} valuePropName="checked">

View File

@@ -74,6 +74,7 @@ const defaultKanbanSettings = {
cardSize: "small",
model_info: true,
kiosk: false,
partsreceived: false,
totalHrs: true,
totalAmountInProduction: false,
totalLAB: true,

View File

@@ -1,33 +1,5 @@
import { useMemo } from "react";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { selectBodyshop } from "../../redux/user/user.selectors";
import JobPartsReceived from "../job-parts-received/job-parts-received.component";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop
});
const mapDispatchToProps = () => ({
//setUserLanguage: language => dispatch(setUserLanguage(language))
});
export default connect(mapStateToProps, mapDispatchToProps)(ProductionListColumnPartsReceived);
export function ProductionListColumnPartsReceived({ bodyshop, record }) {
const amount = useMemo(() => {
const amount = record.joblines_status.reduce(
(acc, val) => {
acc.total += val.count;
acc.received =
val.status === bodyshop.md_order_statuses.default_received ? acc.received + val.count : acc.received;
return acc;
},
{ total: 0, received: 0 }
);
return {
...amount,
percent: amount.total !== 0 ? ((amount.received / amount.total) * 100).toFixed(0) + "%" : "N/A"
};
}, [record, bodyshop.md_order_statuses]);
return `${amount.percent} (${amount.received}/${amount.total})`;
export default function ProductionListColumnPartsReceived({ record }) {
return <JobPartsReceived parts={record.joblines_status} displayMode="full" popoverPlacement="topLeft" />;
}

View File

@@ -9,23 +9,21 @@ import RbacWrapper from "../../components/rbac-wrapper/rbac-wrapper.component";
import { INSERT_NEW_JOB } from "../../graphql/jobs.queries";
import { QUERY_OWNER_FOR_JOB_CREATION } from "../../graphql/owners.queries";
import { setBreadcrumbs, setSelectedHeader } from "../../redux/application/application.actions";
import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
import { selectBodyshop } from "../../redux/user/user.selectors";
import InstanceRenderManager from "../../utils/instanceRenderMgr";
import JobsCreateComponent from "./jobs-create.component";
import JobCreateContext from "./jobs-create.context";
import { useNotification } from "../../contexts/Notifications/notificationContext.jsx";
import { logImEXEvent } from "../../firebase/firebase.utils";
const mapStateToProps = createStructuredSelector({
bodyshop: selectBodyshop,
currentUser: selectCurrentUser
bodyshop: selectBodyshop
});
const mapDispatchToProps = (dispatch) => ({
setBreadcrumbs: (breadcrumbs) => dispatch(setBreadcrumbs(breadcrumbs)),
setSelectedHeader: (key) => dispatch(setSelectedHeader(key))
});
function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, currentUser }) {
function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader }) {
const { t } = useTranslation();
const notification = useNotification();
@@ -76,7 +74,7 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, curr
}, [t, setBreadcrumbs, setSelectedHeader]);
const runInsertJob = (job) => {
insertJob({ variables: { job } })
insertJob({ variables: { job: job } })
.then((resp) => {
setState({
...state,
@@ -152,11 +150,6 @@ function JobsCreateContainer({ bodyshop, setBreadcrumbs, setSelectedHeader, curr
if (job.owner === null) delete job.owner;
if (job.vehicle === null) delete job.vehicle;
// Associate to the current user if one exists
if (currentUser?.email) {
job.created_user_email = currentUser.email;
}
runInsertJob(job);
};

View File

@@ -2950,6 +2950,8 @@
"settings": "Error saving board settings: {{error}}"
},
"labels": {
"click_for_statuses": "Click to view parts statuses",
"partsreceived": "Parts Received",
"actual_in": "Actual In",
"addnewprofile": "Add New Profile",
"alert": "Alert",

View File

@@ -2950,6 +2950,8 @@
"settings": ""
},
"labels": {
"click_for_statuses": "",
"partsreceived": "",
"actual_in": "",
"addnewprofile": "",
"alert": "",

View File

@@ -2950,6 +2950,8 @@
"settings": ""
},
"labels": {
"click_for_statuses": "",
"partsreceived": "",
"actual_in": "",
"addnewprofile": "",
"alert": "",

View File

@@ -3684,7 +3684,6 @@
- completed_tasks
- converted
- created_at
- created_user_email
- cust_pr
- date_estimated
- date_exported
@@ -3962,7 +3961,6 @@
- completed_tasks
- converted
- created_at
- created_user_email
- cust_pr
- date_estimated
- date_exported
@@ -4253,7 +4251,6 @@
- completed_tasks
- converted
- created_at
- created_user_email
- cust_pr
- date_estimated
- date_exported
@@ -4644,7 +4641,7 @@
request_transform:
body:
action: transform
template: "{\r\n \"event\": {\r\n \"session_variables\": {\r\n \"x-hasura-user-id\": {{$body?.event?.session_variables?.x-hasura-user-id ?? \"Internal\"}},\r\n \"x-hasura-role\": {{$body?.event?.session_variables?.x-hasura-role ?? \"Internal\"}}\r\n }, \r\n \"op\": {{$body.event.op}},\r\n \"data\": {\r\n \"new\": {\r\n \"id\": {{$body.event.data.new.id}},\r\n \"shopid\": {{$body.event.data.new?.shopid}},\r\n \"ro_number\": {{$body.event.data.new?.ro_number}},\r\n \"created_user_email\": {{$body.event.data.new?.created_user_email}}\r\n }\r\n }\r\n },\r\n \"trigger\": {\r\n \"name\": \"notifications_jobs_autoadd\"\r\n },\r\n \"table\": {\r\n \"schema\": \"public\",\r\n \"name\": \"jobs\"\r\n }\r\n}\r\n"
template: "{\r\n \"event\": {\r\n \"session_variables\": {\r\n \"x-hasura-user-id\": {{$body?.event?.session_variables?.x-hasura-user-id ?? \"Internal\"}},\r\n \"x-hasura-role\": {{$body?.event?.session_variables?.x-hasura-role ?? \"Internal\"}}\r\n }, \r\n \"op\": {{$body.event.op}},\r\n \"data\": {\r\n \"new\": {\r\n \"id\": {{$body.event.data.new.id}},\r\n \"shopid\": {{$body.event.data.new?.shopid}},\r\n \"ro_number\": {{$body.event.data.new?.ro_number}}\r\n }\r\n }\r\n },\r\n \"trigger\": {\r\n \"name\": \"notifications_jobs_autoadd\"\r\n },\r\n \"table\": {\r\n \"schema\": \"public\",\r\n \"name\": \"jobs\"\r\n }\r\n}\r\n"
method: POST
query_params: {}
template_engine: Kriti

View File

@@ -1,4 +0,0 @@
-- Could not auto-generate a down migration.
-- Please write an appropriate down migration for the SQL below:
-- alter table "public"."jobs" add column "created_user_email" text
-- null;

View File

@@ -1,2 +0,0 @@
alter table "public"."jobs" add column "created_user_email" text
null;

View File

@@ -3089,19 +3089,17 @@ exports.INSERT_JOB_WATCHERS = `
`;
exports.GET_NOTIFICATION_WATCHERS = `
query GET_NOTIFICATION_WATCHERS($shopId: uuid!, $employeeIds: [uuid!]!, $createdUserEmail: String!) {
query GET_NOTIFICATION_WATCHERS($shopId: uuid!, $employeeIds: [uuid!]!) {
associations(where: {
_and: [
{ shopid: { _eq: $shopId } },
{ active: { _eq: true } },
{ notifications_autoadd: { _eq: true } },
{ useremail: { _eq: $createdUserEmail } }
{ notifications_autoadd: { _eq: true } }
]
}) {
id
useremail
}
employees(where: { id: { _in: $employeeIds }, shopid: { _eq: $shopId }, active: { _eq: true } }) {
user_email
}

View File

@@ -39,7 +39,6 @@ const autoAddWatchers = async (req) => {
const jobId = event?.data?.new?.id;
const shopId = event?.data?.new?.shopid;
const roNumber = event?.data?.new?.ro_number || "unknown";
const createdUserEmail = event?.data?.new?.created_user_email || "Unknown";
if (!jobId || !shopId) {
throw new Error(`Missing jobId (${jobId}) or shopId (${shopId}) for auto-add watchers`);
@@ -62,8 +61,7 @@ const autoAddWatchers = async (req) => {
const [notificationData, existingWatchersData] = await Promise.all([
gqlClient.request(GET_NOTIFICATION_WATCHERS, {
shopId,
employeeIds: notificationFollowers,
createdUserEmail
employeeIds: notificationFollowers
}),
gqlClient.request(GET_JOB_WATCHERS_MINIMAL, { jobid: jobId })
]);