Jelajahi Sumber

manual pushs

Gustavo Zanatta 4 hari lalu
induk
melakukan
5a0ee6e267

+ 25 - 0
src/api/pushNotification.js

@@ -0,0 +1,25 @@
+import api from "src/api";
+
+export const getPushRecipients = async ({ target, search } = {}) => {
+  const { data } = await api.get("/push-notifications/recipients", {
+    params: { target, search: search || undefined },
+  });
+  return data.payload;
+};
+
+export const sendManualPush = async ({ target, userIds, title, body }) => {
+  const { data } = await api.post("/push-notifications/send", {
+    target,
+    user_ids: userIds,
+    title,
+    body,
+  });
+  return data.payload;
+};
+
+export const getPushHistory = async ({ page = 1, perPage = 10, filter } = {}) => {
+  const response = await api.get("/push-notifications/history", {
+    params: { page, per_page: perPage, filter: filter || undefined },
+  });
+  return { data: { result: response.data.payload } };
+};

+ 162 - 0
src/components/pushNotification/PushRecipientsSelect.vue

@@ -0,0 +1,162 @@
+<template>
+  <q-select
+    v-model="selectedUsers"
+    v-bind="$attrs"
+    multiple
+    use-chips
+    use-input
+    clearable
+    :options="userOptions"
+    :label
+    :rules
+    :loading
+    :disable="!target"
+    :placeholder="
+      $t('common.actions.search') + ' ' + $t('common.terms.user')
+    "
+    :error
+    :error-message
+    @filter="filterFn"
+  >
+    <template #option="{ itemProps, opt, selected, toggleOption }">
+      <q-item v-bind="itemProps">
+        <q-item-section side>
+          <q-checkbox
+            :model-value="selected"
+            @update:model-value="toggleOption(opt)"
+          />
+        </q-item-section>
+        <q-item-section>
+          <q-item-label>{{ opt.label }}</q-item-label>
+          <q-item-label caption>{{ opt.email }}</q-item-label>
+        </q-item-section>
+        <q-item-section v-if="!opt.hasDeviceToken || !opt.pushEnabled" side>
+          <q-icon
+            :name="opt.pushEnabled ? 'mdi-cellphone-off' : 'mdi-bell-off-outline'"
+            color="orange"
+          >
+            <q-tooltip>
+              {{
+                opt.pushEnabled
+                  ? $t("push_notification.messages.no_device_token")
+                  : $t("push_notification.messages.push_disabled")
+              }}
+            </q-tooltip>
+          </q-icon>
+        </q-item-section>
+      </q-item>
+    </template>
+
+    <template #before-options>
+      <q-item v-if="userOptions.length" clickable @click="selectAllFiltered">
+        <q-item-section class="text-primary text-weight-medium">
+          {{
+            $t("push_notification.actions.select_all", {
+              count: userOptions.length,
+            })
+          }}
+        </q-item-section>
+      </q-item>
+    </template>
+
+    <template #no-option>
+      <q-item>
+        <q-item-section class="text-grey">
+          {{
+            target
+              ? $t("http.errors.no_records_found")
+              : $t("push_notification.messages.select_origin_first")
+          }}
+        </q-item-section>
+      </q-item>
+    </template>
+  </q-select>
+</template>
+
+<script setup>
+import { getPushRecipients } from "src/api/pushNotification";
+import { ref, watch } from "vue";
+import { normalizeString } from "src/helpers/utils";
+import { useI18n } from "vue-i18n";
+
+const { target, label, rules } = defineProps({
+  target: {
+    type: String,
+    default: null,
+  },
+  label: {
+    type: String,
+    default: () => useI18n().t("push_notification.fields.recipients"),
+  },
+  rules: {
+    type: Array,
+    default: () => [],
+  },
+  error: {
+    type: Boolean,
+    default: false,
+  },
+  errorMessage: {
+    type: String,
+    default: "",
+  },
+});
+
+const selectedUsers = defineModel({ type: Array, default: () => [] });
+
+const loading = ref(false);
+const baseOptions = ref([]);
+const userOptions = ref([]);
+
+const filterFn = (val, update) => {
+  const needle = normalizeString(val);
+  update(() => {
+    userOptions.value = baseOptions.value.filter(
+      (v) =>
+        normalizeString(v.label).includes(needle) ||
+        normalizeString(v.email).includes(needle),
+    );
+  });
+};
+
+const selectAllFiltered = () => {
+  const alreadySelected = new Set(selectedUsers.value.map((u) => u.value));
+  selectedUsers.value = [
+    ...selectedUsers.value,
+    ...userOptions.value.filter((u) => !alreadySelected.has(u.value)),
+  ];
+};
+
+const loadRecipients = async () => {
+  baseOptions.value = [];
+  userOptions.value = [];
+
+  if (!target) return;
+
+  try {
+    loading.value = true;
+    const recipients = await getPushRecipients({ target });
+    baseOptions.value = recipients.map((user) => ({
+      label: user.name,
+      value: user.id,
+      email: user.email,
+      hasDeviceToken: user.has_device_token,
+      pushEnabled: user.push_enabled,
+    }));
+    userOptions.value = baseOptions.value;
+  } catch (e) {
+    console.error(e);
+  } finally {
+    loading.value = false;
+  }
+};
+
+watch(
+  () => target,
+  () => {
+    selectedUsers.value = [];
+    loadRecipients();
+  },
+  { immediate: true },
+);
+</script>

+ 34 - 1
src/i18n/locales/en.json

@@ -711,7 +711,8 @@
       "speciality": "Speciality",
       "reviews": "Reviews",
       "payments": "Payments",
-      "support_requests": "Support requests"
+      "support_requests": "Support requests",
+      "pushs": "Pushes"
     }
   },
   "charts": {
@@ -862,5 +863,37 @@
       "checking": "Checking",
       "savings": "Savings"
     }
+  },
+  "push_notification": {
+    "tabs": {
+      "send": "Send",
+      "history": "History"
+    },
+    "targets": {
+      "cliente": "Client",
+      "prestador": "Provider"
+    },
+    "fields": {
+      "title": "Title",
+      "body": "Description",
+      "recipients": "Recipients",
+      "recipient": "Recipient",
+      "target": "Origin",
+      "sent_at": "Sent at"
+    },
+    "actions": {
+      "send": "Send push",
+      "select_all": "Select all ({count})"
+    },
+    "messages": {
+      "preview": "Preview",
+      "confirm_send": "Send this push to {count} recipient(s)?",
+      "select_origin_first": "Select the origin first",
+      "no_device_token": "No registered device — will not receive it",
+      "push_disabled": "Notifications disabled by the user — will not receive it",
+      "warn_no_device_token": "{count} selected recipient(s) have no registered device and will not receive it.",
+      "warn_push_disabled": "{count} selected recipient(s) disabled notifications and will not receive it.",
+      "sent_content": "Sent content"
+    }
   }
 }

+ 34 - 1
src/i18n/locales/es.json

@@ -711,7 +711,8 @@
       "speciality": "Especialidad",
       "reviews": "Evaluaciones",
       "payments": "Pagos",
-      "support_requests": "Atenciones"
+      "support_requests": "Atenciones",
+      "pushs": "Pushs"
     }
   },
   "charts": {
@@ -862,5 +863,37 @@
       "checking": "Corriente",
       "savings": "Ahorro"
     }
+  },
+  "push_notification": {
+    "tabs": {
+      "send": "Enviar",
+      "history": "Historial"
+    },
+    "targets": {
+      "cliente": "Cliente",
+      "prestador": "Prestador"
+    },
+    "fields": {
+      "title": "Título",
+      "body": "Descripción",
+      "recipients": "Destinatarios",
+      "recipient": "Destinatario",
+      "target": "Origen",
+      "sent_at": "Enviada el"
+    },
+    "actions": {
+      "send": "Enviar push",
+      "select_all": "Seleccionar todos ({count})"
+    },
+    "messages": {
+      "preview": "Vista previa",
+      "confirm_send": "¿Enviar esta push a {count} destinatario(s)?",
+      "select_origin_first": "Selecciona primero el origen",
+      "no_device_token": "Sin dispositivo registrado — no la recibirá",
+      "push_disabled": "Notificaciones desactivadas por el usuario — no la recibirá",
+      "warn_no_device_token": "{count} seleccionado(s) sin dispositivo registrado no la recibirán.",
+      "warn_push_disabled": "{count} seleccionado(s) desactivaron las notificaciones y no la recibirán.",
+      "sent_content": "Contenido enviado"
+    }
   }
 }

+ 34 - 1
src/i18n/locales/pt.json

@@ -713,7 +713,8 @@
       "speciality": "Especialidade",
       "reviews": "Avaliações",
       "payments": "Pagamentos",
-      "support_requests": "Atendimentos"
+      "support_requests": "Atendimentos",
+      "pushs": "Pushs"
     }
   },
   "charts": {
@@ -864,5 +865,37 @@
       "checking": "Corrente",
       "savings": "Poupança"
     }
+  },
+  "push_notification": {
+    "tabs": {
+      "send": "Enviar",
+      "history": "Histórico"
+    },
+    "targets": {
+      "cliente": "Cliente",
+      "prestador": "Prestador"
+    },
+    "fields": {
+      "title": "Título",
+      "body": "Descrição",
+      "recipients": "Destinatários",
+      "recipient": "Destinatário",
+      "target": "Origem",
+      "sent_at": "Enviada em"
+    },
+    "actions": {
+      "send": "Enviar push",
+      "select_all": "Selecionar todos ({count})"
+    },
+    "messages": {
+      "preview": "Pré-visualização",
+      "confirm_send": "Enviar esta push para {count} destinatário(s)?",
+      "select_origin_first": "Selecione a origem primeiro",
+      "no_device_token": "Sem aparelho registrado — não vai receber",
+      "push_disabled": "Notificações desativadas pelo usuário — não vai receber",
+      "warn_no_device_token": "{count} selecionado(s) sem aparelho registrado não vão receber.",
+      "warn_push_disabled": "{count} selecionado(s) desativaram as notificações e não vão receber.",
+      "sent_content": "Conteúdo enviado"
+    }
   }
 }

+ 18 - 10
src/pages/payment/PaymentsPage.vue

@@ -58,22 +58,22 @@ const ViewPaymentDialog = defineAsyncComponent(
 const { t } = useI18n();
 const $q = useQuasar();
 const tableRef = ref(null);
-const idLabel = "ID";
+// const idLabel = "ID";
 
 const columns = computed(() => [
-  {
-    name: "id",
-    label: idLabel,
-    align: "left",
-    field: "id",
-    required: true,
-    sortable: true,
-  },
+  // {
+  //   name: "id",
+  //   label: idLabel,
+  //   align: "left",
+  //   field: "id",
+  //   required: true,
+  //   sortable: true,
+  // },
   {
     name: "schedule_id",
     label: t("payments.schedule_id"),
     align: "left",
-    field: "schedule_id",
+    field: (row) => formatByType(row),
     sortable: true,
   },
   {
@@ -157,4 +157,12 @@ const onRowClick = ({ row }) => {
     },
   });
 };
+
+const formatByType = (row) => {
+  if (row.schedules[0]?.schedule_type === 'custom') {
+      return t('ui.navigation.opportunities') + ' - ' + row.schedules[0]?.id
+  } else if (row.schedules[0]?.schedule_type === 'default') {
+      return t('ui.navigation.schedules') + ' - ' + row.schedules[0]?.id
+  }  
+}
 </script>

+ 280 - 0
src/pages/pushNotification/PushNotificationsPage.vue

@@ -0,0 +1,280 @@
+<template>
+  <div>
+    <DefaultHeaderPage />
+
+    <q-tabs v-model="tab" align="left" class="text-primary q-mb-md" no-caps>
+      <q-tab name="send" icon="mdi-send" :label="$t('push_notification.tabs.send')" />
+      <q-tab name="history" icon="mdi-history" :label="$t('push_notification.tabs.history')" />
+    </q-tabs>
+
+    <q-tab-panels v-model="tab" animated class="bg-transparent">
+      <q-tab-panel name="send" class="q-pa-none">
+        <div class="row q-col-gutter-md">
+          <div class="col-12 col-md-7">
+            <q-form ref="formRef" class="q-gutter-md">
+              <q-btn-toggle
+                v-model="form.target"
+                spread
+                no-caps
+                unelevated
+                toggle-color="primary"
+                color="white"
+                text-color="primary"
+                :options="targetOptions"
+              />
+
+              <PushRecipientsSelect
+                v-model="form.recipients"
+                outlined
+                dense
+                :target="form.target"
+                :error="!!serverErrors?.user_ids"
+                :error-message="serverErrors?.user_ids"
+              />
+
+              <q-input
+                v-model="form.title"
+                outlined
+                dense
+                counter
+                maxlength="65"
+                :label="$t('push_notification.fields.title')"
+                :rules="[inputRules.required]"
+                :error="!!serverErrors?.title"
+                :error-message="serverErrors?.title"
+              />
+
+              <q-input
+                v-model="form.body"
+                outlined
+                dense
+                counter
+                type="textarea"
+                maxlength="240"
+                autogrow
+                :label="$t('push_notification.fields.body')"
+                :rules="[inputRules.required]"
+                :error="!!serverErrors?.body"
+                :error-message="serverErrors?.body"
+              />
+
+              <div class="flex justify-end">
+                <q-btn
+                  color="primary"
+                  padding="12px 24px"
+                  unelevated
+                  no-caps
+                  :loading="loading"
+                  :disable="!canSend"
+                  :label="$t('push_notification.actions.send')"
+                  @click="onSendClick"
+                />
+              </div>
+            </q-form>
+          </div>
+
+          <div class="col-12 col-md-5">
+            <div class="text-caption text-grey-7 q-mb-sm">
+              {{ $t("push_notification.messages.preview") }}
+            </div>
+            <q-card flat bordered class="q-pa-md">
+              <div class="flex items-center q-mb-sm" style="gap: 8px">
+                <q-icon name="mdi-bell" size="18px" color="grey-7" />
+                <span class="text-caption text-grey-7">{{ appName }}</span>
+              </div>
+              <div class="text-weight-bold">
+                {{ form.title || $t("push_notification.fields.title") }}
+              </div>
+              <div class="text-body2 text-grey-8" style="white-space: pre-wrap">
+                {{ form.body || $t("push_notification.fields.body") }}
+              </div>
+            </q-card>
+
+            <q-banner v-if="warnings.length" dense class="bg-orange-1 text-orange-9 q-mt-md">
+              <template #avatar>
+                <q-icon name="mdi-alert-outline" />
+              </template>
+              <div v-for="warning in warnings" :key="warning">{{ warning }}</div>
+            </q-banner>
+          </div>
+        </div>
+      </q-tab-panel>
+
+      <q-tab-panel name="history" class="q-pa-none">
+        <DefaultTableServerSide
+          ref="historyTableRef"
+          :columns="historyColumns"
+          :api-call="getPushHistory"
+          :add-item="false"
+          :rows-per-page="10"
+          open-item
+          @on-row-click="onHistoryRowClick"
+        />
+      </q-tab-panel>
+    </q-tab-panels>
+  </div>
+</template>
+
+<script setup>
+import {
+  computed,
+  defineAsyncComponent,
+  nextTick,
+  reactive,
+  ref,
+  useTemplateRef,
+  watch,
+} from "vue";
+import { useQuasar } from "quasar";
+import { useI18n } from "vue-i18n";
+
+import { getPushHistory, sendManualPush } from "src/api/pushNotification";
+import { useInputRules } from "src/composables/useInputRules";
+import { useSubmitHandler } from "src/composables/useSubmitHandler";
+import { permissionStore } from "src/stores/permission";
+
+import DefaultHeaderPage from "src/components/layout/DefaultHeaderPage.vue";
+import DefaultTableServerSide from "src/components/defaults/DefaultTableServerSide.vue";
+import PushRecipientsSelect from "src/components/pushNotification/PushRecipientsSelect.vue";
+
+const PushHistoryDetailDialog = defineAsyncComponent(() =>
+  import("src/pages/pushNotification/components/PushHistoryDetailDialog.vue"),
+);
+
+const $q = useQuasar();
+const { t } = useI18n();
+const inputRules = useInputRules();
+const permission_store = permissionStore();
+
+const appName = "Diária";
+
+const tab = ref("send");
+const formRef = useTemplateRef("formRef");
+const historyTableRef = useTemplateRef("historyTableRef");
+
+watch(tab, async (value) => {
+  if (value !== "history") return;
+
+  await nextTick();
+  historyTableRef.value?.refresh();
+});
+
+const form = reactive({
+  target: "cliente",
+  recipients: [],
+  title: "",
+  body: "",
+});
+
+const targetOptions = computed(() => [
+  { label: t("push_notification.targets.cliente"), value: "cliente" },
+  { label: t("push_notification.targets.prestador"), value: "prestador" },
+]);
+
+const canSend = computed(
+  () => form.recipients.length > 0 && !!form.title.trim() && !!form.body.trim(),
+);
+
+const warnings = computed(() => {
+  const list = [];
+  const noToken = form.recipients.filter((r) => !r.hasDeviceToken).length;
+  const disabled = form.recipients.filter((r) => !r.pushEnabled).length;
+
+  if (noToken) {
+    list.push(t("push_notification.messages.warn_no_device_token", { count: noToken }));
+  }
+  if (disabled) {
+    list.push(t("push_notification.messages.warn_push_disabled", { count: disabled }));
+  }
+  return list;
+});
+
+const resetForm = () => {
+  form.recipients = [];
+  form.title = "";
+  form.body = "";
+  formRef.value?.resetValidation();
+};
+
+const { loading, serverErrors, execute: submitForm } = useSubmitHandler({
+  formRef,
+  onSuccess: () => {
+    resetForm();
+    historyTableRef.value?.refresh();
+  },
+});
+
+const onSendClick = () => {
+  if (permission_store.getAccess("push.notification", "add") === false) {
+    $q.notify({ type: "negative", message: t("validation.permissions.add") });
+    return;
+  }
+
+  $q.dialog({
+    title: t("push_notification.actions.send"),
+    message: t("push_notification.messages.confirm_send", {
+      count: form.recipients.length,
+    }),
+    cancel: true,
+    persistent: true,
+  }).onOk(async () => {
+    try {
+      await submitForm(() =>
+        sendManualPush({
+          target: form.target,
+          userIds: form.recipients.map((r) => r.value),
+          title: form.title,
+          body: form.body,
+        }),
+      );
+    } catch {
+      // erros de validação já são exibidos pelo useSubmitHandler
+    }
+  });
+};
+
+const onHistoryRowClick = ({ row }) => {
+  $q.dialog({
+    component: PushHistoryDetailDialog,
+    componentProps: { log: row },
+  });
+};
+
+const historyColumns = computed(() => [
+  {
+    name: "title",
+    label: t("push_notification.fields.title"),
+    field: "title",
+    align: "left",
+    sortable: false,
+  },
+  {
+    name: "user_name",
+    label: t("push_notification.fields.recipient"),
+    field: "user_name",
+    align: "left",
+    sortable: false,
+  },
+  {
+    name: "user_email",
+    label: t("common.terms.email"),
+    field: "user_email",
+    align: "left",
+    sortable: false,
+  },
+  {
+    name: "target",
+    label: t("push_notification.fields.target"),
+    field: (row) => t(`push_notification.targets.${row.target}`, row.target),
+    align: "left",
+    sortable: false,
+  },
+  {
+    name: "sent_at",
+    label: t("push_notification.fields.sent_at"),
+    field: "sent_at",
+    align: "left",
+    sortable: false,
+  },
+]);
+</script>

+ 98 - 0
src/pages/pushNotification/components/PushHistoryDetailDialog.vue

@@ -0,0 +1,98 @@
+<template>
+  <q-dialog ref="dialogRef" @hide="onDialogHide">
+    <q-card class="q-dialog-plugin overflow-hidden" style="width: 620px; max-width: 92vw">
+      <DefaultDialogHeader :title="dialogTitle" @close="onDialogCancel" />
+
+      <q-card-section class="q-gutter-y-md">
+        <q-list dense>
+          <q-item>
+            <q-item-section>
+              <q-item-label caption>
+                {{ $t("push_notification.fields.recipient") }}
+              </q-item-label>
+              <q-item-label>{{ log.user_name || emptyLabel }}</q-item-label>
+            </q-item-section>
+            <q-item-section>
+              <q-item-label caption>{{ $t("common.terms.email") }}</q-item-label>
+              <q-item-label>{{ log.user_email || emptyLabel }}</q-item-label>
+            </q-item-section>
+          </q-item>
+
+          <q-item>
+            <q-item-section>
+              <q-item-label caption>
+                {{ $t("push_notification.fields.target") }}
+              </q-item-label>
+              <q-item-label>{{ targetLabel }}</q-item-label>
+            </q-item-section>
+            <q-item-section>
+              <q-item-label caption>
+                {{ $t("push_notification.fields.sent_at") }}
+              </q-item-label>
+              <q-item-label>{{ log.sent_at || emptyLabel }}</q-item-label>
+            </q-item-section>
+          </q-item>
+        </q-list>
+
+        <div>
+          <div class="text-caption text-grey-7 q-mb-xs">
+            {{ $t("push_notification.messages.sent_content") }}
+          </div>
+          <q-card flat bordered class="q-pa-md">
+            <div class="flex items-center q-mb-sm" style="gap: 8px">
+              <q-icon name="mdi-bell" size="18px" color="grey-7" />
+              <span class="text-caption text-grey-7">{{ appName }}</span>
+            </div>
+            <div class="text-weight-bold">{{ log.title || emptyLabel }}</div>
+            <div class="text-body2 text-grey-8 push-body">
+              {{ log.body || emptyLabel }}
+            </div>
+          </q-card>
+        </div>
+      </q-card-section>
+
+      <q-card-actions align="right" class="q-px-md q-pb-md">
+        <q-btn
+          flat
+          color="primary"
+          :label="$t('common.actions.close')"
+          @click="onDialogCancel"
+        />
+      </q-card-actions>
+    </q-card>
+  </q-dialog>
+</template>
+
+<script setup>
+import { computed } from "vue";
+import { useDialogPluginComponent } from "quasar";
+import { useI18n } from "vue-i18n";
+import DefaultDialogHeader from "src/components/defaults/DefaultDialogHeader.vue";
+
+defineEmits([...useDialogPluginComponent.emits]);
+
+const { log } = defineProps({
+  log: {
+    type: Object,
+    required: true,
+  },
+});
+
+const { t } = useI18n();
+const { dialogRef, onDialogHide, onDialogCancel } = useDialogPluginComponent();
+
+const emptyLabel = "—";
+const appName = "Diária";
+
+const dialogTitle = () => t("push_notification.tabs.history");
+const targetLabel = computed(() =>
+  log.target ? t(`push_notification.targets.${log.target}`, log.target) : emptyLabel,
+);
+</script>
+
+<style scoped lang="scss">
+.push-body {
+  white-space: pre-wrap;
+  word-break: break-word;
+}
+</style>

+ 22 - 0
src/router/routes/pushNotification.route.js

@@ -0,0 +1,22 @@
+export default [
+  {
+    path: "/pushs",
+    name: "PushNotificationsPage",
+    component: () => import("pages/pushNotification/PushNotificationsPage.vue"),
+    meta: {
+      title: "ui.navigation.pushs",
+      requireAuth: true,
+      requiredPermission: "push.notification",
+      breadcrumbs: [
+        {
+          name: "DashboardPage",
+          title: "ui.navigation.dashboard",
+        },
+        {
+          name: "PushNotificationsPage",
+          title: "ui.navigation.pushs",
+        },
+      ],
+    },
+  },
+];

+ 9 - 0
src/stores/navigation.js

@@ -58,6 +58,15 @@ export const navigationStore = defineStore("navigation", () => {
       permission: false,
       permissionScope: "support.request",
     },
+    {
+      type: "single",
+      title: "ui.navigation.pushs",
+      name: "PushNotificationsPage",
+      icon: "mdi-bell-ring-outline",
+      disable: false,
+      permission: false,
+      permissionScope: "push.notification",
+    },
     {
       type: "expansive",
       title: "ui.navigation.registration",