-
- {scenarioNotificationsOn && (
+
{scenarioNotificationsOn && (
-
setNotificationVisible(false)}
- unreadCount={unreadCount}
- />
+
+ setNotificationVisible(false)}
+ unreadCount={unreadCount}
+ />
+
)}
+
+ setTaskCenterVisible(false)} />
+
);
}
diff --git a/client/src/components/task-center/task-center.component.jsx b/client/src/components/task-center/task-center.component.jsx
new file mode 100644
index 000000000..697beed09
--- /dev/null
+++ b/client/src/components/task-center/task-center.component.jsx
@@ -0,0 +1,94 @@
+// client/src/components/task-center/task-center.component.jsx
+import { Virtuoso } from "react-virtuoso";
+import { Badge, Button, Space, Spin, Switch, Tooltip, Typography } from "antd";
+import { CheckCircleFilled, CheckCircleOutlined, EyeFilled, EyeOutlined } from "@ant-design/icons";
+import { useTranslation } from "react-i18next";
+import { forwardRef, useEffect, useRef } from "react";
+import { DateTimeFormat } from "../../utils/DateFormatter.jsx";
+import day from "../../utils/day.js";
+import "./task-center.styles.scss"; // You can clone this from notification styles for now
+
+const { Text, Title } = Typography;
+
+const TaskCenterComponent = forwardRef(
+ ({ visible, onClose, tasks, loading, showIncompleteOnly, toggleIncomplete, markAllComplete, onTaskClick }, ref) => {
+ const { t } = useTranslation();
+ const virtuosoRef = useRef(null);
+
+ useEffect(() => {
+ if (virtuosoRef.current) {
+ virtuosoRef.current.scrollToIndex({ index: 0, behavior: "smooth" });
+ }
+ }, [showIncompleteOnly]);
+
+ const renderTask = (index, task) => {
+ const handleClick = () => {
+ onTaskClick(task.id);
+ };
+
+ return (
+
+
+
+
+
+ {t("notifications.labels.ro-number", {
+ ro_number: task.job?.ro_number || t("general.labels.na")
+ })}
+
+
+ {day(task.created_at).fromNow()}
+
+
+
+ {task.title}
+
+
+
+
+ );
+ };
+
+ return (
+
+
+
+ {t("tasks.labels.my_tasks_center")}
+ {loading && }
+
+
+
+
+ {showIncompleteOnly ? : }
+ toggleIncomplete(checked)} size="small" />
+
+
+
+
+
+
+
+
+ );
+ }
+);
+
+TaskCenterComponent.displayName = "TaskCenterComponent";
+
+export default TaskCenterComponent;
diff --git a/client/src/components/task-center/task-center.container.jsx b/client/src/components/task-center/task-center.container.jsx
new file mode 100644
index 000000000..43fe077ef
--- /dev/null
+++ b/client/src/components/task-center/task-center.container.jsx
@@ -0,0 +1,116 @@
+// client/src/components/task-center/task-center.container.jsx
+import { useCallback, useEffect, useMemo, useState } from "react";
+import { useMutation, useQuery } from "@apollo/client";
+import { connect } from "react-redux";
+import { createStructuredSelector } from "reselect";
+import { selectBodyshop, selectCurrentUser } from "../../redux/user/user.selectors";
+import { useSocket } from "../../contexts/SocketIO/useSocket";
+import { useIsEmployee } from "../../utils/useIsEmployee";
+import { useNotification } from "../../contexts/Notifications/notificationContext";
+import { MUTATION_TOGGLE_TASK_COMPLETED, QUERY_MY_TASKS_PAGINATED } from "../../graphql/tasks.queries";
+import TaskCenterComponent from "./task-center.component";
+import dayjs from "../../utils/day";
+
+const POLL_INTERVAL = 60; // seconds
+
+const mapStateToProps = createStructuredSelector({
+ bodyshop: selectBodyshop,
+ currentUser: selectCurrentUser
+});
+
+const TaskCenterContainer = ({ visible, onClose, bodyshop, currentUser }) => {
+ const [tasks, setTasks] = useState([]);
+ const [showIncompleteOnly, setShowIncompleteOnly] = useState(true);
+ const [loading, setLoading] = useState(false);
+ const { isConnected } = useSocket();
+ const isEmployee = useIsEmployee(bodyshop, currentUser);
+ const notification = useNotification();
+ const assignedToId = bodyshop?.employees?.find((e) => e.user_email === currentUser.email)?.id;
+
+ const where = useMemo(() => {
+ return {
+ assigned_to: { _eq: assignedToId },
+ deleted: { _eq: false },
+ ...(showIncompleteOnly ? { completed: { _eq: false } } : {})
+ };
+ }, [assignedToId, showIncompleteOnly]);
+
+ const {
+ data,
+ loading: queryLoading,
+ refetch
+ } = useQuery(QUERY_MY_TASKS_PAGINATED, {
+ variables: {
+ bodyshop: bodyshop?.id,
+ assigned_to: assignedToId,
+ where,
+ offset: 0,
+ limit: 50,
+ order: [{ created_at: "desc" }]
+ },
+ skip: !bodyshop?.id || !assignedToId || !isEmployee,
+ fetchPolicy: "cache-and-network",
+ pollInterval: isConnected ? 0 : dayjs.duration(POLL_INTERVAL, "seconds").asMilliseconds()
+ });
+
+ const [toggleTaskCompleted] = useMutation(MUTATION_TOGGLE_TASK_COMPLETED);
+
+ useEffect(() => {
+ if (data?.tasks) {
+ setTasks(data.tasks);
+ }
+ }, [data]);
+
+ const handleToggleIncomplete = (val) => {
+ setShowIncompleteOnly(val);
+ };
+
+ const handleMarkAllComplete = async () => {
+ setLoading(true);
+ try {
+ const incompleteTasks = tasks.filter((t) => !t.completed);
+ await Promise.all(
+ incompleteTasks.map((task) =>
+ toggleTaskCompleted({
+ variables: {
+ id: task.id,
+ completed: true,
+ completed_at: dayjs().toISOString()
+ }
+ })
+ )
+ );
+ notification.success({ message: "Tasks marked complete" });
+ refetch();
+ } catch (err) {
+ notification.error({ message: "Failed to mark tasks complete" });
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleTaskClick = useCallback(
+ (id) => {
+ const task = tasks.find((t) => t.id === id);
+ if (task) {
+ window.location.href = `/manage/jobs/${task.jobid}`;
+ }
+ },
+ [tasks]
+ );
+
+ return (
+
+ );
+};
+
+export default connect(mapStateToProps)(TaskCenterContainer);
diff --git a/client/src/components/task-center/task-center.styles.scss b/client/src/components/task-center/task-center.styles.scss
new file mode 100644
index 000000000..e7b85a353
--- /dev/null
+++ b/client/src/components/task-center/task-center.styles.scss
@@ -0,0 +1,66 @@
+.task-center {
+ position: absolute;
+ top: 64px;
+ right: 0;
+ z-index: 1000;
+ width: 400px;
+ max-height: 500px;
+ background: #fff;
+ border-left: 1px solid #ccc;
+ box-shadow: -2px 2px 8px rgba(0, 0, 0, 0.1);
+ overflow: hidden;
+ display: none;
+
+ &.visible {
+ display: block;
+ }
+
+ .task-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 8px 12px;
+ background-color: #f5f5f5;
+ border-bottom: 1px solid #ddd;
+ }
+
+ .task-toggle {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+
+ .anticon {
+ font-size: 14px;
+ }
+ }
+
+ .task-item {
+ padding: 12px;
+ border-bottom: 1px solid #f0f0f0;
+ cursor: pointer;
+ transition: background-color 0.2s;
+
+ &:hover {
+ background-color: #fafafa;
+ }
+
+ &.task-completed {
+ opacity: 0.6;
+ }
+
+ .ro-number {
+ font-weight: bold;
+ margin-right: 8px;
+ }
+
+ .task-body {
+ display: block;
+ margin-top: 4px;
+ }
+
+ .relative-time {
+ float: right;
+ font-size: 0.8em;
+ }
+ }
+}
diff --git a/client/src/graphql/tasks.queries.js b/client/src/graphql/tasks.queries.js
index 90d5606c7..554c21a18 100644
--- a/client/src/graphql/tasks.queries.js
+++ b/client/src/graphql/tasks.queries.js
@@ -381,3 +381,24 @@ export const MUTATION_UPDATE_TASK = gql`
}
}
`;
+
+/**
+ * Query to get the count of my tasks
+ * @type {DocumentNode}
+ */
+export const QUERY_MY_TASKS_COUNT = gql`
+ query QUERY_MY_TASKS_COUNT($assigned_to: uuid!, $bodyshopid: uuid!) {
+ tasks_aggregate(
+ where: {
+ assigned_to: { _eq: $assigned_to }
+ bodyshopid: { _eq: $bodyshopid }
+ completed: { _eq: false }
+ deleted: { _eq: false }
+ }
+ ) {
+ aggregate {
+ count
+ }
+ }
+ }
+`;