diff --git a/app/controllers/api/v1/accounts/conversations/recurring_scheduled_messages_controller.rb b/app/controllers/api/v1/accounts/conversations/recurring_scheduled_messages_controller.rb new file mode 100644 index 000000000..1d325361c --- /dev/null +++ b/app/controllers/api/v1/accounts/conversations/recurring_scheduled_messages_controller.rb @@ -0,0 +1,176 @@ +class Api::V1::Accounts::Conversations::RecurringScheduledMessagesController < Api::V1::Accounts::Conversations::BaseController + include Events::Types + + before_action :set_recurring_scheduled_message, only: [:update, :destroy] + + MAX_LIMIT = 50 + + def index + authorize build_recurring_scheduled_message + @recurring_scheduled_messages = @conversation.recurring_scheduled_messages + .includes(:scheduled_messages, :author) + .order(Arel.sql('CASE status WHEN 1 THEN 0 WHEN 0 THEN 1 ELSE 2 END, created_at DESC')) + .limit(MAX_LIMIT) + end + + def create + @recurring_scheduled_message = build_recurring_scheduled_message + authorize @recurring_scheduled_message + @recurring_scheduled_message.assign_attributes(recurring_scheduled_message_params) + + ActiveRecord::Base.transaction do + @recurring_scheduled_message.save! + create_first_occurrence if @recurring_scheduled_message.active? + end + + dispatch_event(RECURRING_SCHEDULED_MESSAGE_CREATED) + end + + def update + @recurring_scheduled_message.assign_attributes(recurring_scheduled_message_params) + + ActiveRecord::Base.transaction do + @recurring_scheduled_message.save! + @recurring_scheduled_message.attachment.purge if params[:remove_attachment].present? && @recurring_scheduled_message.attachment.attached? + + if @recurring_scheduled_message.active? + reschedule_pending_occurrence + else + @recurring_scheduled_message.scheduled_messages.pending.destroy_all + end + end + + dispatch_event(RECURRING_SCHEDULED_MESSAGE_UPDATED) + end + + def destroy + cancel_recurring_message + dispatch_event(RECURRING_SCHEDULED_MESSAGE_UPDATED) + end + + private + + def set_recurring_scheduled_message + @recurring_scheduled_message = @conversation.recurring_scheduled_messages.find(params[:id]) + authorize @recurring_scheduled_message + end + + def build_recurring_scheduled_message + @conversation.recurring_scheduled_messages.new(account: Current.account, inbox: @conversation.inbox, author: Current.user) + end + + def recurring_scheduled_message_params + permitted = params.permit( + :content, + :status, + :attachment, + template_params: {}, + recurrence_rule: [:frequency, :interval, :end_type, :end_date, :end_count, + :monthly_type, :monthly_week, :monthly_weekday, :month_day, + :year_day, :year_month, { week_days: [] }] + ) + + permitted[:recurrence_rule] = cast_recurrence_rule(permitted[:recurrence_rule].to_h) if permitted[:recurrence_rule].present? + + permitted + end + + def cast_recurrence_rule(rule) + integer_keys = %w[interval end_count monthly_week monthly_weekday month_day year_day year_month] + rule.each_with_object({}) do |(key, value), hash| + hash[key] = if key == 'week_days' && value.is_a?(Array) + value.map(&:to_i) + elsif integer_keys.include?(key) + value.to_i + else + value + end + end + end + + def create_first_occurrence + scheduled_at = params[:scheduled_at] + return if scheduled_at.blank? + + sm = @recurring_scheduled_message.scheduled_messages.create!( + content: @recurring_scheduled_message.content, + template_params: @recurring_scheduled_message.template_params, + scheduled_at: scheduled_at, + status: :pending, + account: @recurring_scheduled_message.account, + conversation: @recurring_scheduled_message.conversation, + inbox: @recurring_scheduled_message.inbox, + author: @recurring_scheduled_message.author + ) + copy_attachment(sm) if @recurring_scheduled_message.attachment.attached? + end + + def reschedule_pending_occurrence + @recurring_scheduled_message.scheduled_messages.pending.destroy_all + + next_scheduled_at = compute_next_valid_date + return if next_scheduled_at.blank? + + sm = @recurring_scheduled_message.scheduled_messages.create!( + content: @recurring_scheduled_message.content, + template_params: @recurring_scheduled_message.template_params, + scheduled_at: next_scheduled_at, + status: :pending, + account: @recurring_scheduled_message.account, + conversation: @recurring_scheduled_message.conversation, + inbox: @recurring_scheduled_message.inbox, + author: @recurring_scheduled_message.author + ) + copy_attachment(sm) if @recurring_scheduled_message.attachment.attached? + end + + def compute_next_valid_date + user_date = params[:scheduled_at].present? ? Time.zone.parse(params[:scheduled_at].to_s) : nil + rule = @recurring_scheduled_message.recurrence_rule + + return user_date if user_date.present? && date_matches_rule?(user_date, rule) + + base = [user_date, Time.current].compact.max + RecurringScheduledMessages::RecurrenceCalculatorService + .new(recurrence_rule: rule, last_date: base) + .next_date + end + + def date_matches_rule?(date, rule) + return true unless rule.is_a?(Hash) + + rule = rule.with_indifferent_access + return true unless rule[:frequency] == 'weekly' && rule[:week_days].present? + + rule[:week_days].map(&:to_i).include?(date.wday) + end + + def cancel_recurring_message + @recurring_scheduled_message.scheduled_messages.pending.destroy_all + @recurring_scheduled_message.update!(status: :cancelled) + + I18n.with_locale(@recurring_scheduled_message.account.locale) do + @recurring_scheduled_message.conversation.messages.create!( + account: @recurring_scheduled_message.account, + inbox: @recurring_scheduled_message.inbox, + message_type: :activity, + content: I18n.t( + 'conversations.activity.recurring_message_cancelled', + agent: @recurring_scheduled_message.author&.name || I18n.t('conversations.activity.unknown_agent') + ) + ) + end + end + + def copy_attachment(scheduled_message) + scheduled_message.attachment.attach(@recurring_scheduled_message.attachment.blob) + end + + def dispatch_event(event_name) + Rails.configuration.dispatcher.dispatch(event_name, Time.zone.now, recurring_scheduled_message: @recurring_scheduled_message) + end +end + +Api::V1::Accounts::Conversations::RecurringScheduledMessagesController.prepend_mod_with( + 'Api::V1::Accounts::Conversations::RecurringScheduledMessagesController' +) diff --git a/app/controllers/api/v1/accounts/conversations/scheduled_messages_controller.rb b/app/controllers/api/v1/accounts/conversations/scheduled_messages_controller.rb index f50d327d6..9ae9ac6bc 100644 --- a/app/controllers/api/v1/accounts/conversations/scheduled_messages_controller.rb +++ b/app/controllers/api/v1/accounts/conversations/scheduled_messages_controller.rb @@ -8,6 +8,7 @@ class Api::V1::Accounts::Conversations::ScheduledMessagesController < Api::V1::A def index authorize build_scheduled_message @scheduled_messages = @conversation.scheduled_messages + .includes(:recurring_scheduled_message) .order(scheduled_at: :desc) .limit(MAX_LIMIT) end diff --git a/app/javascript/dashboard/api/recurringScheduledMessages.js b/app/javascript/dashboard/api/recurringScheduledMessages.js new file mode 100644 index 000000000..c0af4b82d --- /dev/null +++ b/app/javascript/dashboard/api/recurringScheduledMessages.js @@ -0,0 +1,81 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +export const buildRecurringScheduledMessagePayload = ({ + content, + status, + scheduledAt, + templateParams, + attachment, + removeAttachment, + recurrenceRule, +} = {}) => { + if (!attachment) { + return { + content, + status, + scheduled_at: scheduledAt, + template_params: templateParams, + remove_attachment: removeAttachment || undefined, + recurrence_rule: recurrenceRule, + }; + } + + const payload = new FormData(); + if (content) payload.append('content', content); + if (scheduledAt) payload.append('scheduled_at', scheduledAt); + if (status) payload.append('status', status); + payload.append('attachment', attachment); + if (templateParams) { + payload.append('template_params', JSON.stringify(templateParams)); + } + if (recurrenceRule) { + Object.entries(recurrenceRule).forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach(v => + payload.append(`recurrence_rule[${key}][]`, String(v)) + ); + } else { + payload.append(`recurrence_rule[${key}]`, String(value)); + } + }); + } + + return payload; +}; + +class RecurringScheduledMessagesAPI extends ApiClient { + constructor() { + super('conversations', { accountScoped: true }); + } + + get(conversationId) { + return axios.get( + `${this.baseUrl()}/conversations/${conversationId}/recurring_scheduled_messages` + ); + } + + create(conversationId, payload) { + return axios({ + method: 'post', + url: `${this.baseUrl()}/conversations/${conversationId}/recurring_scheduled_messages`, + data: buildRecurringScheduledMessagePayload(payload), + }); + } + + update(conversationId, recurringScheduledMessageId, payload) { + return axios({ + method: 'patch', + url: `${this.baseUrl()}/conversations/${conversationId}/recurring_scheduled_messages/${recurringScheduledMessageId}`, + data: buildRecurringScheduledMessagePayload(payload), + }); + } + + delete(conversationId, recurringScheduledMessageId) { + return axios.delete( + `${this.baseUrl()}/conversations/${conversationId}/recurring_scheduled_messages/${recurringScheduledMessageId}` + ); + } +} + +export default new RecurringScheduledMessagesAPI(); diff --git a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ScheduledMessageItem.vue b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ScheduledMessageItem.vue index 2d1df490a..f9ce8680e 100644 --- a/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ScheduledMessageItem.vue +++ b/app/javascript/dashboard/components-next/Contacts/ContactsSidebar/components/ScheduledMessageItem.vue @@ -9,6 +9,7 @@ import { useMessageFormatter } from 'shared/composables/useMessageFormatter'; import Avatar from 'dashboard/components-next/avatar/Avatar.vue'; import Button from 'dashboard/components-next/button/Button.vue'; import Icon from 'dashboard/components-next/icon/Icon.vue'; +import { buildRecurrenceDescription } from 'dashboard/helper/recurrenceHelpers'; const props = defineProps({ scheduledMessage: { @@ -33,15 +34,23 @@ const props = defineProps({ }, }); -const emit = defineEmits(['edit', 'delete']); +const emit = defineEmits(['edit', 'delete', 'stop']); const noteContentRef = useTemplateRef('noteContentRef'); const [isExpanded, toggleExpanded] = useToggle(); const showToggle = ref(false); +const showHistory = ref(false); +const showStopConfirm = ref(false); const { t, locale } = useI18n(); const { formatMessage } = useMessageFormatter(); const route = useRoute(); const router = useRouter(); +const normalizedLocale = computed(() => locale.value.replace('_', '-')); + +const isRecurring = computed(() => + Boolean(props.scheduledMessage?.recurrence_rule) +); + const statusConfig = { draft: { labelKey: 'SCHEDULED_MESSAGES.STATUS.DRAFT', @@ -59,6 +68,18 @@ const statusConfig = { labelKey: 'SCHEDULED_MESSAGES.STATUS.FAILED', class: 'bg-n-ruby-9/10 text-n-ruby-11', }, + active: { + labelKey: 'SCHEDULED_MESSAGES.RECURRENCE.STATUS_ACTIVE', + class: 'bg-n-brand/10 text-n-blue-text', + }, + completed: { + labelKey: 'SCHEDULED_MESSAGES.RECURRENCE.STATUS_COMPLETED', + class: 'bg-n-slate-3 text-n-slate-11', + }, + cancelled: { + labelKey: 'SCHEDULED_MESSAGES.RECURRENCE.STATUS_CANCELLED', + class: 'bg-n-ruby-3 text-n-ruby-11', + }, }; const author = computed(() => props.scheduledMessage?.author || null); @@ -87,7 +108,28 @@ const statusBadge = computed(() => { label: t(config.labelKey), }; }); -const scheduledAt = computed(() => props.scheduledMessage?.scheduled_at); + +const recurrenceDescription = computed(() => { + if (!isRecurring.value) return ''; + return buildRecurrenceDescription( + props.scheduledMessage.recurrence_rule, + t, + normalizedLocale.value + ); +}); + +const scheduledAt = computed(() => { + if (isRecurring.value) { + const pending = + props.scheduledMessage.pending_scheduled_message || + props.scheduledMessage.scheduled_messages?.find( + sm => sm.status === 'pending' + ); + return pending?.scheduled_at || null; + } + return props.scheduledMessage?.scheduled_at; +}); + const formattedScheduledTime = computed(() => { if (!scheduledAt.value) return ''; const date = fromUnixTime(scheduledAt.value); @@ -104,7 +146,7 @@ const formattedScheduledTime = computed(() => { options.year = 'numeric'; } - return date.toLocaleString(locale.value.replace('_', '-'), options); + return date.toLocaleString(normalizedLocale.value, options); }); const templateName = computed(() => { @@ -138,6 +180,44 @@ const hasPreviewContent = computed(() => Boolean(previewContent.value)); const formattedContent = computed(() => formatMessage(previewContent.value)); +// Recurring: completed children history +const completedChildren = computed(() => { + if (!isRecurring.value) return []; + const children = props.scheduledMessage.scheduled_messages || []; + return children + .filter(m => ['sent', 'failed'].includes(m.status)) + .sort((a, b) => (b.scheduled_at || 0) - (a.scheduled_at || 0)); +}); + +const hasCompletedChildren = computed(() => completedChildren.value.length > 0); + +const formatChildTime = childScheduledAt => { + if (!childScheduledAt) return ''; + const date = new Date(childScheduledAt * 1000); + const now = new Date(); + const options = { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }; + if (date.getFullYear() !== now.getFullYear()) { + options.year = 'numeric'; + } + return date.toLocaleString(normalizedLocale.value, options); +}; + +const canNavigateToChild = child => + child.status === 'sent' && Boolean(child.message_id); + +const scrollToChildMessage = child => { + if (!canNavigateToChild(child)) return; + router.replace({ + ...route, + query: { ...route.query, messageId: child.message_id }, + }); +}; + const checkOverflow = () => { if (!props.collapsible) { showToggle.value = false; @@ -151,10 +231,21 @@ const checkOverflow = () => { }; const onEdit = () => emit('edit', props.scheduledMessage); -const onDelete = () => emit('delete', props.scheduledMessage); +const onDelete = () => { + if (isRecurring.value) { + showStopConfirm.value = true; + } else { + emit('delete', props.scheduledMessage); + } +}; +const confirmStop = () => { + emit('stop', props.scheduledMessage); + showStopConfirm.value = false; +}; const canScrollToMessage = computed( () => + !isRecurring.value && props.scheduledMessage?.status === 'sent' && Boolean(props.scheduledMessage?.message_id) ); @@ -190,6 +281,15 @@ watch(previewContent, () => { " @click="scrollToMessage" > + +
+ + {{ recurrenceDescription }} +
+
{ class="flex items-center gap-1 text-xs text-n-slate-11 mb-0" > - {{ formattedScheduledTime }} + {{ + isRecurring + ? t('SCHEDULED_MESSAGES.RECURRENCE.NEXT_SEND', { + time: formattedScheduledTime, + }) + : formattedScheduledTime + }}

{{ t('SCHEDULED_MESSAGES.ITEM.NO_SCHEDULE') }} @@ -235,15 +341,15 @@ watch(previewContent, () => { color="slate" size="xs" icon="i-lucide-pencil" - @click="onEdit" + @click.stop="onEdit" />

@@ -316,5 +422,105 @@ watch(previewContent, () => { }} + + +
+ +
+ + +
+
+
+ + + {{ formatChildTime(child.scheduled_at) }} + +
+ + {{ + t( + child.status === 'sent' + ? 'SCHEDULED_MESSAGES.STATUS.SENT' + : 'SCHEDULED_MESSAGES.STATUS.FAILED' + ) + }} + +
+
+ + + +
+

+ {{ t('SCHEDULED_MESSAGES.RECURRENCE.STOP_CONFIRM.TITLE') }} +

+

+ {{ t('SCHEDULED_MESSAGES.RECURRENCE.STOP_CONFIRM.MESSAGE') }} +

+
+
+
+
diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js index 3c10853d8..60cbc42d2 100644 --- a/app/javascript/dashboard/helper/actionCable.js +++ b/app/javascript/dashboard/helper/actionCable.js @@ -39,6 +39,12 @@ class ActionCableConnector extends BaseActionCableConnector { 'scheduled_message.created': this.onScheduledMessageCreated, 'scheduled_message.updated': this.onScheduledMessageUpdated, 'scheduled_message.deleted': this.onScheduledMessageDeleted, + 'recurring_scheduled_message.created': + this.onRecurringScheduledMessageCreated, + 'recurring_scheduled_message.updated': + this.onRecurringScheduledMessageUpdated, + 'recurring_scheduled_message.deleted': + this.onRecurringScheduledMessageDeleted, }; } @@ -143,6 +149,18 @@ class ActionCableConnector extends BaseActionCableConnector { this.app.$store.dispatch('handleScheduledMessageDeleted', data); }; + onRecurringScheduledMessageCreated = data => { + this.app.$store.dispatch('handleRecurringScheduledMessageCreated', data); + }; + + onRecurringScheduledMessageUpdated = data => { + this.app.$store.dispatch('handleRecurringScheduledMessageUpdated', data); + }; + + onRecurringScheduledMessageDeleted = data => { + this.app.$store.dispatch('handleRecurringScheduledMessageDeleted', data); + }; + onTypingOn = ({ conversation, user }) => { const conversationId = conversation.id; diff --git a/app/javascript/dashboard/helper/recurrenceHelpers.js b/app/javascript/dashboard/helper/recurrenceHelpers.js new file mode 100644 index 000000000..5106fe6d8 --- /dev/null +++ b/app/javascript/dashboard/helper/recurrenceHelpers.js @@ -0,0 +1,311 @@ +const WEEKDAY_NAMES_EN = [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', +]; + +const WEEKDAY_NAMES_PT = [ + 'domingo', + 'segunda-feira', + 'terça-feira', + 'quarta-feira', + 'quinta-feira', + 'sexta-feira', + 'sábado', +]; + +const WEEKDAY_NAMES_SHORT_EN = [ + 'Sun', + 'Mon', + 'Tue', + 'Wed', + 'Thu', + 'Fri', + 'Sat', +]; + +const WEEKDAY_NAMES_SHORT_PT = [ + 'dom', + 'seg', + 'ter', + 'qua', + 'qui', + 'sex', + 'sáb', +]; + +const ORDINALS_EN = { + 1: 'first', + 2: 'second', + 3: 'third', + 4: 'fourth', + 5: 'fifth', + '-1': 'last', +}; + +const ORDINALS_PT = { + 1: '1º', + 2: '2º', + 3: '3º', + 4: '4º', + 5: '5º', + '-1': 'último(a)', +}; + +const MONTH_NAMES_EN = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; + +const MONTH_NAMES_PT = [ + 'janeiro', + 'fevereiro', + 'março', + 'abril', + 'maio', + 'junho', + 'julho', + 'agosto', + 'setembro', + 'outubro', + 'novembro', + 'dezembro', +]; + +export const FREQUENCY_OPTIONS = ['daily', 'weekly', 'monthly', 'yearly']; + +/** + * Get which week occurrence of a weekday a date falls on in its month, + * and whether it is the last occurrence of that weekday in the month. + */ +export function getWeekOccurrence(date) { + const dayOfMonth = date.getDate(); + const week = Math.ceil(dayOfMonth / 7); + + const nextWeek = new Date(date); + nextWeek.setDate(dayOfMonth + 7); + const isLast = nextWeek.getMonth() !== date.getMonth(); + + return { week, isLast }; +} + +/** + * Generate contextual recurrence shortcut options based on a selected date. + */ +export function getRecurrenceShortcuts(date) { + if (!date) return []; + + const dayOfWeek = date.getDay(); + const { week, isLast } = getWeekOccurrence(date); + + return [ + { + label: 'NO_REPEAT', + value: null, + }, + { + label: 'DAILY', + value: { + frequency: 'daily', + interval: 1, + end_type: 'never', + }, + }, + { + label: 'WEEKLY', + labelParams: { day: dayOfWeek }, + value: { + frequency: 'weekly', + interval: 1, + week_days: [dayOfWeek], + end_type: 'never', + }, + }, + { + label: isLast ? 'MONTHLY_LAST' : 'MONTHLY_NTH', + labelParams: { + nth: isLast ? -1 : week, + day: dayOfWeek, + }, + value: { + frequency: 'monthly', + interval: 1, + monthly_type: 'day_of_week', + monthly_week: isLast ? -1 : week, + monthly_weekday: dayOfWeek, + end_type: 'never', + }, + }, + { + label: 'YEARLY', + labelParams: { + month: date.getMonth(), + dayNum: date.getDate(), + }, + value: { + frequency: 'yearly', + interval: 1, + year_day: date.getDate(), + year_month: date.getMonth() + 1, + end_type: 'never', + }, + }, + { + label: 'WEEKDAYS', + value: { + frequency: 'weekly', + interval: 1, + week_days: [1, 2, 3, 4, 5], + end_type: 'never', + }, + }, + { + label: 'CUSTOM', + value: 'custom', + }, + ]; +} + +function getWeekdayNames(locale) { + return locale?.startsWith('pt') ? WEEKDAY_NAMES_PT : WEEKDAY_NAMES_EN; +} + +function getWeekdayShortNames(locale) { + return locale?.startsWith('pt') + ? WEEKDAY_NAMES_SHORT_PT + : WEEKDAY_NAMES_SHORT_EN; +} + +function getOrdinals(locale) { + return locale?.startsWith('pt') ? ORDINALS_PT : ORDINALS_EN; +} + +function getMonthNames(locale) { + return locale?.startsWith('pt') ? MONTH_NAMES_PT : MONTH_NAMES_EN; +} + +/** + * Build a human-readable description of a recurrence rule. + */ +export function buildRecurrenceDescription(rule, t, locale = 'en') { + if (!rule || !rule.frequency) return ''; + + const weekdayShortNames = getWeekdayShortNames(locale); + const weekdayNames = getWeekdayNames(locale); + const ordinals = getOrdinals(locale); + const descKey = key => `SCHEDULED_MESSAGES.RECURRENCE.DESCRIPTION.${key}`; + + const intervalDesc = (interval, oneKey, otherKey) => { + if (interval === 1) return t(descKey(oneKey)); + return t(descKey(otherKey), { count: interval }); + }; + + let description = ''; + + switch (rule.frequency) { + case 'daily': + description = intervalDesc( + rule.interval || 1, + 'DAILY_ONE', + 'DAILY_OTHER' + ); + break; + + case 'weekly': { + const days = (rule.week_days || []) + .sort((a, b) => a - b) + .map(d => weekdayShortNames[d]); + const prefix = intervalDesc( + rule.interval || 1, + 'WEEKLY_ONE', + 'WEEKLY_OTHER' + ); + description = days.length + ? t(descKey('WEEKLY_ON'), { prefix, days: days.join(', ') }) + : prefix; + break; + } + + case 'monthly': { + const prefix = intervalDesc( + rule.interval || 1, + 'MONTHLY_ONE', + 'MONTHLY_OTHER' + ); + if (rule.monthly_type === 'day_of_week') { + const ordinal = + ordinals[String(rule.monthly_week)] || rule.monthly_week; + const weekday = weekdayNames[rule.monthly_weekday] || ''; + description = t(descKey('MONTHLY_ON_WEEKDAY'), { + prefix, + ordinal, + weekday, + }); + } else { + description = prefix; + } + break; + } + + case 'yearly': + description = intervalDesc( + rule.interval || 1, + 'YEARLY_ONE', + 'YEARLY_OTHER' + ); + break; + + default: + return ''; + } + + if (rule.end_type === 'on_date' && rule.end_date) { + description += ` · ${t(descKey('UNTIL_DATE'), { date: rule.end_date })}`; + } else if (rule.end_type === 'after_count' && rule.end_count) { + description += ` · ${t(descKey('AFTER_COUNT'), { count: rule.end_count })}`; + } + + return description; +} + +/** + * Format a shortcut label with its parameters for display. + */ +export function formatShortcutLabel(shortcut, t, locale = 'en') { + const { label, labelParams } = shortcut; + const weekdayNames = getWeekdayNames(locale); + const ordinals = getOrdinals(locale); + const monthNames = getMonthNames(locale); + + if (!labelParams) return t(`SCHEDULED_MESSAGES.RECURRENCE.${label}`); + + const params = {}; + if (labelParams.day !== undefined) { + params.day = weekdayNames[labelParams.day]; + } + if (labelParams.nth !== undefined) { + params.nth = ordinals[String(labelParams.nth)] || labelParams.nth; + } + if (labelParams.month !== undefined) { + params.month = monthNames[labelParams.month]; + } + if (labelParams.dayNum !== undefined) { + params.dayNum = labelParams.dayNum; + } + + return t(`SCHEDULED_MESSAGES.RECURRENCE.${label}`, params); +} diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 433676385..92fe4b5ed 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -494,6 +494,88 @@ "SCHEDULE_IN_PAST": "Scheduled time must be in the future.", "SAVE_FAILED": "Unable to save scheduled message. Please try again.", "DELETE_FAILED": "Unable to delete scheduled message. Please try again." + }, + "RECURRENCE": { + "SECTION_TITLE": "Recurrences", + "NO_REPEAT": "Does not repeat", + "DAILY": "Every day", + "WEEKLY": "Weekly on {day}", + "MONTHLY_NTH": "Monthly on the {nth} {day}", + "MONTHLY_LAST": "Monthly on the last {day}", + "YEARLY": "Yearly on {month} {dayNum}", + "WEEKDAYS": "Every weekday (Mon-Fri)", + "CUSTOM": "Custom...", + "CUSTOM_MODAL": { + "TITLE": "Custom recurrence", + "REPEAT_EVERY": "Repeat every", + "FREQ_DAILY": "day(s)", + "FREQ_WEEKLY": "week(s)", + "FREQ_MONTHLY": "month(s)", + "FREQ_YEARLY": "year(s)", + "UNIT_DAY": "day | days", + "UNIT_WEEK": "week | weeks", + "UNIT_MONTH": "month | months", + "UNIT_YEAR": "year | years", + "REPEAT_ON": "Repeat on", + "MONTHLY_ON_DAY": "Day of month", + "MONTHLY_ON_WEEKDAY": "Day of week", + "ENDS": "Ends", + "ENDS_NEVER": "Never", + "ENDS_ON_DATE": "On date", + "ENDS_AFTER": "After", + "ENDS_OCCURRENCES": "occurrences", + "DONE": "Done", + "CANCEL": "Cancel" + }, + "STATUS_ACTIVE": "Active", + "STATUS_DRAFT": "Draft", + "STATUS_COMPLETED": "Completed", + "STATUS_CANCELLED": "Cancelled", + "OCCURRENCES_SENT": "{count} sent", + "NEXT_SEND": "Next: {time}", + "EXPAND": "Expand history", + "COLLAPSE": "Collapse history", + "STOP": "Stop recurrence", + "STOP_CONFIRM": { + "TITLE": "Stop recurrence", + "MESSAGE": "The pending message will be removed and the recurrence will be permanently stopped.", + "CONFIRM": "Stop", + "CANCEL": "Cancel" + }, + "EDIT": "Edit recurrence", + "EDIT_WARNING": "Next send: {oldDate} → {newDate}", + "WEEKDAYS_SHORT": { + "SUN": "S", + "MON": "M", + "TUE": "T", + "WED": "W", + "THU": "T", + "FRI": "F", + "SAT": "S" + }, + "ORDINALS": { + "FIRST": "first", + "SECOND": "second", + "THIRD": "third", + "FOURTH": "fourth", + "FIFTH": "fifth", + "LAST": "last" + }, + "DESCRIPTION": { + "DAILY_ONE": "Every day", + "DAILY_OTHER": "Every {count} days", + "WEEKLY_ONE": "Every week", + "WEEKLY_OTHER": "Every {count} weeks", + "WEEKLY_ON": "{prefix}: {days}", + "MONTHLY_ONE": "Monthly", + "MONTHLY_OTHER": "Every {count} months", + "MONTHLY_ON_WEEKDAY": "{prefix} on the {ordinal} {weekday}", + "YEARLY_ONE": "Every year", + "YEARLY_OTHER": "Every {count} years", + "UNTIL_DATE": "until {date}", + "AFTER_COUNT": "{count} occurrences" + }, + "RESOLVED_WARNING": "Scheduled messages will not be sent while the conversation is resolved. Reopen the conversation to resume sending." } }, "CONVERSATION_CUSTOM_ATTRIBUTES": { diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/conversation.json b/app/javascript/dashboard/i18n/locale/pt_BR/conversation.json index f6e7b07d4..784f797ef 100644 --- a/app/javascript/dashboard/i18n/locale/pt_BR/conversation.json +++ b/app/javascript/dashboard/i18n/locale/pt_BR/conversation.json @@ -483,6 +483,88 @@ "AUTHOR_YOU": "{name} (Você)", "AUTOMATION": "Automação", "UNKNOWN_AUTHOR": "Desconhecido" + }, + "RECURRENCE": { + "SECTION_TITLE": "Recorrências", + "NO_REPEAT": "Não se repete", + "DAILY": "Todos os dias", + "WEEKLY": "Semanal: cada {day}", + "MONTHLY_NTH": "Mensal no(a) {nth} {day}", + "MONTHLY_LAST": "Mensal no(a) último(a) {day}", + "YEARLY": "Anual em {dayNum} de {month}", + "WEEKDAYS": "Todos os dias úteis (seg-sex)", + "CUSTOM": "Personalizado...", + "CUSTOM_MODAL": { + "TITLE": "Recorrência personalizada", + "REPEAT_EVERY": "Repetir a cada", + "FREQ_DAILY": "dia(s)", + "FREQ_WEEKLY": "semana(s)", + "FREQ_MONTHLY": "mês(es)", + "FREQ_YEARLY": "ano(s)", + "UNIT_DAY": "dia | dias", + "UNIT_WEEK": "semana | semanas", + "UNIT_MONTH": "mês | meses", + "UNIT_YEAR": "ano | anos", + "REPEAT_ON": "Repetir em", + "MONTHLY_ON_DAY": "Dia do mês", + "MONTHLY_ON_WEEKDAY": "Dia da semana", + "ENDS": "Termina", + "ENDS_NEVER": "Nunca", + "ENDS_ON_DATE": "Em data", + "ENDS_AFTER": "Após", + "ENDS_OCCURRENCES": "ocorrências", + "DONE": "Concluir", + "CANCEL": "Cancelar" + }, + "STATUS_ACTIVE": "Ativa", + "STATUS_DRAFT": "Rascunho", + "STATUS_COMPLETED": "Concluída", + "STATUS_CANCELLED": "Cancelada", + "OCCURRENCES_SENT": "{count} enviadas", + "NEXT_SEND": "Próxima: {time}", + "EXPAND": "Expandir histórico", + "COLLAPSE": "Recolher histórico", + "STOP": "Parar recorrência", + "STOP_CONFIRM": { + "TITLE": "Parar recorrência", + "MESSAGE": "A mensagem pendente será removida e a recorrência será encerrada permanentemente.", + "CONFIRM": "Parar", + "CANCEL": "Cancelar" + }, + "EDIT": "Editar recorrência", + "EDIT_WARNING": "Próximo envio: {oldDate} → {newDate}", + "WEEKDAYS_SHORT": { + "SUN": "D", + "MON": "S", + "TUE": "T", + "WED": "Q", + "THU": "Q", + "FRI": "S", + "SAT": "S" + }, + "ORDINALS": { + "FIRST": "1º", + "SECOND": "2º", + "THIRD": "3º", + "FOURTH": "4º", + "FIFTH": "5º", + "LAST": "último(a)" + }, + "DESCRIPTION": { + "DAILY_ONE": "Todos os dias", + "DAILY_OTHER": "A cada {count} dias", + "WEEKLY_ONE": "Semanal", + "WEEKLY_OTHER": "A cada {count} semanas", + "WEEKLY_ON": "{prefix}: {days}", + "MONTHLY_ONE": "Mensal", + "MONTHLY_OTHER": "A cada {count} meses", + "MONTHLY_ON_WEEKDAY": "{prefix} no(a) {ordinal} {weekday}", + "YEARLY_ONE": "Anual", + "YEARLY_OTHER": "A cada {count} anos", + "UNTIL_DATE": "até {date}", + "AFTER_COUNT": "{count} ocorrências" + }, + "RESOLVED_WARNING": "Mensagens agendadas não serão enviadas enquanto a conversa estiver resolvida. Reabra a conversa para retomar o envio." } }, "CONVERSATION_CUSTOM_ATTRIBUTES": { diff --git a/app/javascript/dashboard/routes/dashboard/conversation/scheduledMessages/RecurrenceCustomModal.vue b/app/javascript/dashboard/routes/dashboard/conversation/scheduledMessages/RecurrenceCustomModal.vue new file mode 100644 index 000000000..63d235fed --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/conversation/scheduledMessages/RecurrenceCustomModal.vue @@ -0,0 +1,317 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/conversation/scheduledMessages/RecurrenceDropdown.vue b/app/javascript/dashboard/routes/dashboard/conversation/scheduledMessages/RecurrenceDropdown.vue new file mode 100644 index 000000000..d79d5958c --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/conversation/scheduledMessages/RecurrenceDropdown.vue @@ -0,0 +1,149 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/conversation/scheduledMessages/ScheduledMessageModal.vue b/app/javascript/dashboard/routes/dashboard/conversation/scheduledMessages/ScheduledMessageModal.vue index 920609c1d..3855dbc65 100644 --- a/app/javascript/dashboard/routes/dashboard/conversation/scheduledMessages/ScheduledMessageModal.vue +++ b/app/javascript/dashboard/routes/dashboard/conversation/scheduledMessages/ScheduledMessageModal.vue @@ -18,6 +18,8 @@ import DropdownSection from 'next/dropdown-menu/base/DropdownSection.vue'; import DropdownItem from 'next/dropdown-menu/base/DropdownItem.vue'; import WhatsappTemplates from 'dashboard/components/widgets/conversation/WhatsappTemplates/Modal.vue'; import ScheduleDateShortcuts from './ScheduleDateShortcuts.vue'; +import RecurrenceDropdown from './RecurrenceDropdown.vue'; +import RecurrenceCustomModal from './RecurrenceCustomModal.vue'; const props = defineProps({ show: { @@ -55,6 +57,11 @@ const inboxGetter = useMapGetter('inboxes/getInbox'); const uiFlags = useMapGetter('scheduledMessages/getUIFlags'); const isEditing = computed(() => !!props.scheduledMessage?.id); +const isEditingRecurring = computed( + () => + isEditing.value && + String(props.scheduledMessage?.id).startsWith('recurring-') +); const isCreating = computed(() => uiFlags.value.isCreating); const isUpdating = computed(() => uiFlags.value.isUpdating); const isSubmitting = computed(() => isCreating.value || isUpdating.value); @@ -78,6 +85,8 @@ const showWhatsAppTemplatesModal = ref(false); const contentError = ref(false); const contentLengthError = ref(false); const dateTimeError = ref(''); +const recurrenceRule = ref(null); +const showRecurrenceCustomModal = ref(false); // Original values for change detection const originalContent = ref(''); @@ -98,6 +107,7 @@ const resetForm = () => { templateParams.value = null; contentError.value = false; dateTimeError.value = ''; + recurrenceRule.value = null; // Reset original values originalContent.value = ''; originalScheduledAt.value = null; @@ -114,6 +124,7 @@ const setFormFromMessage = scheduledMessage => { templateParams.value = scheduledMessage.template_params || null; existingAttachment.value = scheduledMessage.attachment || null; attachments.value = []; + recurrenceRule.value = scheduledMessage.recurrence_rule || null; if (scheduledMessage.scheduled_at) { const dateValue = new Date(scheduledMessage.scheduled_at * 1000); @@ -161,7 +172,7 @@ const scheduledAt = computed(() => { const hasContent = computed(() => Boolean(messageContent.value?.trim())); const hasNewAttachment = computed(() => attachments.value.length > 0); const hasTemplate = computed( - () => templateParams.value && Object.keys(templateParams.value).length + () => !!(templateParams.value && Object.keys(templateParams.value).length) ); const hasExistingAttachment = computed(() => !!existingAttachment.value); const showAttachmentUpload = computed( @@ -394,7 +405,54 @@ const submit = async status => { if (!validatePayload(status)) return; try { - if (isEditing.value) { + const hasRecurrence = !!recurrenceRule.value; + const existingRecurringId = + props.scheduledMessage?.recurring_scheduled_message_id; + + if (hasRecurrence && status === 'pending') { + const recurringPayload = { + content: messageContent.value, + scheduledAt: scheduledAt.value ? scheduledAt.value.toISOString() : null, + recurrenceRule: recurrenceRule.value, + attachment: resolveAttachmentPayload(), + templateParams: templateParams.value, + status: 'active', + }; + + if (isEditing.value && existingRecurringId) { + // Update existing recurring series + await store.dispatch('recurringScheduledMessages/update', { + conversationId: props.conversationId, + recurringScheduledMessageId: existingRecurringId, + payload: recurringPayload, + }); + } else { + // Create new recurring series (new message or standalone gaining recurrence) + await store.dispatch('recurringScheduledMessages/create', { + conversationId: props.conversationId, + payload: recurringPayload, + }); + // If converting a standalone message, delete the old one + if (isEditing.value) { + await store.dispatch('scheduledMessages/delete', { + conversationId: props.conversationId, + scheduledMessageId: props.scheduledMessage.id, + }); + } + } + } else if (isEditing.value) { + // Editing without recurrence - if it had a recurring parent and user removed it, cancel the series + if (existingRecurringId && !hasRecurrence) { + await store.dispatch('recurringScheduledMessages/delete', { + conversationId: props.conversationId, + recurringScheduledMessageId: existingRecurringId, + }); + // If this was a direct recurring message edit, just close — no standalone to update + if (isEditingRecurring.value) { + closeModal(); + return; + } + } await store.dispatch('scheduledMessages/update', { conversationId: props.conversationId, scheduledMessageId: props.scheduledMessage.id, @@ -473,10 +531,10 @@ watch(