diff --git a/app/controllers/api/v1/accounts/assignment_policies_controller.rb b/app/controllers/api/v1/accounts/assignment_policies_controller.rb index 1807d6afb..0150cb677 100644 --- a/app/controllers/api/v1/accounts/assignment_policies_controller.rb +++ b/app/controllers/api/v1/accounts/assignment_policies_controller.rb @@ -30,7 +30,8 @@ class Api::V1::Accounts::AssignmentPoliciesController < Api::V1::Accounts::BaseC def assignment_policy_params params.require(:assignment_policy).permit( :name, :description, :assignment_order, :conversation_priority, - :fair_distribution_limit, :fair_distribution_window, :enabled + :fair_distribution_limit, :fair_distribution_window, :enabled, + :exclude_older_than_hours ) end end diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 5e2543698..0a87b4cbe 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -798,10 +798,14 @@ }, "FAIR_DISTRIBUTION": { "LABEL": "Fair distribution policy", - "DESCRIPTION": "Set the maximum number of conversations that can be assigned per agent within a time window to avoid overloading any one agent. This required field defaults to 100 conversations per hour.", + "DESCRIPTION": "Cap conversations per agent within a time window to avoid overload. Defaults to 100 per hour.", "INPUT_MAX": "Assign max", "DURATION": "Conversations per agent in every" }, + "EXCLUDE_OLDER_THAN": { + "LABEL": "Skip stale conversations", + "DESCRIPTION": "Skip unassigned conversations older than this. Defaults to 7 days; clear to disable." + }, "INBOXES": { "LABEL": "Added inboxes", "DESCRIPTION": "Add inboxes for which this policy will be applicable.", diff --git a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/constants.js b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/constants.js index 350faa60c..028688c75 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/constants.js +++ b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/constants.js @@ -10,6 +10,9 @@ export const LONGEST_WAITING = 'longest_waiting'; export const DEFAULT_FAIR_DISTRIBUTION_LIMIT = 100; export const DEFAULT_FAIR_DISTRIBUTION_WINDOW = 3600; +// Default age threshold for excluding stale unassigned conversations (7 days) +export const DEFAULT_EXCLUDE_OLDER_THAN_HOURS = 168; + // Options groupings export const OPTIONS = { ORDER: [ROUND_ROBIN, BALANCED], diff --git a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentAssignmentEditPage.vue b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentAssignmentEditPage.vue index dfae60350..0550e7364 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentAssignmentEditPage.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/AgentAssignmentEditPage.vue @@ -106,6 +106,7 @@ const formData = computed(() => ({ selectedPolicy.value?.conversationPriority || EARLIEST_CREATED, fairDistributionLimit: selectedPolicy.value?.fairDistributionLimit || 100, fairDistributionWindow: selectedPolicy.value?.fairDistributionWindow || 3600, + excludeOlderThanHours: selectedPolicy.value?.excludeOlderThanHours ?? null, })); const handleDeleteInbox = async inboxId => { diff --git a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue index cbf22b4d2..36c131ee2 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/assignmentPolicy/pages/components/AgentAssignmentPolicyForm.vue @@ -8,6 +8,8 @@ import RadioCard from 'dashboard/components-next/radioCard/RadioCard.vue'; import FairDistribution from 'dashboard/components-next/AssignmentPolicy/components/FairDistribution.vue'; import DataTable from 'dashboard/components-next/AssignmentPolicy/components/DataTable.vue'; import AddDataDropdown from 'dashboard/components-next/AssignmentPolicy/components/AddDataDropdown.vue'; +import DurationInput from 'dashboard/components-next/input/DurationInput.vue'; +import { DURATION_UNITS } from 'dashboard/components-next/input/constants'; import WithLabel from 'v3/components/Form/WithLabel.vue'; import Button from 'dashboard/components-next/button/Button.vue'; import { @@ -16,6 +18,7 @@ import { EARLIEST_CREATED, DEFAULT_FAIR_DISTRIBUTION_LIMIT, DEFAULT_FAIR_DISTRIBUTION_WINDOW, + DEFAULT_EXCLUDE_OLDER_THAN_HOURS, } from 'dashboard/routes/dashboard/settings/assignmentPolicy/constants'; const props = defineProps({ @@ -28,6 +31,7 @@ const props = defineProps({ conversationPriority: EARLIEST_CREATED, fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT, fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW, + excludeOlderThanHours: DEFAULT_EXCLUDE_OLDER_THAN_HOURS, }), }, mode: { @@ -56,7 +60,6 @@ const props = defineProps({ default: false, }, }); - const emit = defineEmits([ 'submit', 'addInbox', @@ -64,6 +67,9 @@ const emit = defineEmits([ 'navigateToInbox', 'validationChange', ]); +// Duration limits for the stale-conversation threshold: 1 hour to 999 days (in minutes) +const MIN_EXCLUSION_MINUTES = 60; +const MAX_EXCLUSION_MINUTES = 1438560; const { t } = useI18n(); const route = useRoute(); @@ -83,12 +89,28 @@ const state = reactive({ conversationPriority: EARLIEST_CREATED, fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT, fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW, + excludeOlderThanHours: DEFAULT_EXCLUDE_OLDER_THAN_HOURS, }); const validationState = ref({ isValid: false, }); +const exclusionUnit = ref(DURATION_UNITS.DAYS); + +// DurationInput works in minutes; the policy stores hours, so bridge the two +const excludeOlderThanMinutes = computed({ + get() { + return state.excludeOlderThanHours == null + ? null + : state.excludeOlderThanHours * 60; + }, + set(minutes) { + state.excludeOlderThanHours = + minutes == null ? null : Math.round(minutes / 60); + }, +}); + const createOption = ( type, key, @@ -170,6 +192,7 @@ const resetForm = () => { conversationPriority: EARLIEST_CREATED, fairDistributionLimit: DEFAULT_FAIR_DISTRIBUTION_LIMIT, fairDistributionWindow: DEFAULT_FAIR_DISTRIBUTION_WINDOW, + excludeOlderThanHours: DEFAULT_EXCLUDE_OLDER_THAN_HOURS, }); }; @@ -177,10 +200,17 @@ const handleSubmit = () => { emit('submit', { ...state }); }; +// Pick the display unit from the stored value so non-day thresholds (e.g. 25h) don't get floored +const detectExclusionUnit = hours => { + exclusionUnit.value = + hours && hours % 24 !== 0 ? DURATION_UNITS.HOURS : DURATION_UNITS.DAYS; +}; + watch( () => props.initialData, newData => { Object.assign(state, newData); + detectExclusionUnit(newData.excludeOlderThanHours); }, { immediate: true, deep: true } ); @@ -247,6 +277,27 @@ defineExpose({ v-model:window-unit="state.windowUnit" /> + +
+
+ +

+ {{ t(`${BASE_KEY}.FORM.EXCLUDE_OLDER_THAN.DESCRIPTION`) }} +

+
+
+ +
+