510 lines
14 KiB
Vue
510 lines
14 KiB
Vue
<template>
|
|
<div>
|
|
<LayoutsBreadcrumb />
|
|
|
|
<!-- Header Section -->
|
|
<rs-card class="mb-6">
|
|
<template #header>
|
|
<div class="flex items-center">
|
|
<Icon class="mr-2 text-primary" name="ic:outline-batch-prediction"></Icon>
|
|
<h1 class="text-xl font-bold text-primary">Batch Processing</h1>
|
|
</div>
|
|
</template>
|
|
<template #body>
|
|
<p class="text-gray-600">
|
|
Process notifications in batches for better efficiency.
|
|
</p>
|
|
</template>
|
|
</rs-card>
|
|
|
|
<!-- Quick Actions -->
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
|
<rs-card
|
|
class="cursor-pointer hover:shadow-md transition-shadow"
|
|
@click="showCreateModal = true"
|
|
>
|
|
<div class="p-4 flex items-center gap-4">
|
|
<div class="p-3 bg-blue-100 rounded-lg">
|
|
<Icon class="text-blue-600 text-xl" name="ic:outline-add"></Icon>
|
|
</div>
|
|
<div>
|
|
<h3 class="font-semibold text-blue-600">Create New Batch</h3>
|
|
<p class="text-sm text-gray-600">Start a new batch processing job</p>
|
|
</div>
|
|
</div>
|
|
</rs-card>
|
|
|
|
<rs-card class="cursor-pointer hover:shadow-md transition-shadow">
|
|
<div class="p-4 flex items-center gap-4">
|
|
<div class="p-3 bg-green-100 rounded-lg">
|
|
<Icon class="text-green-600 text-xl" name="ic:outline-schedule"></Icon>
|
|
</div>
|
|
<div>
|
|
<h3 class="font-semibold text-green-600">Schedule Batch</h3>
|
|
<p class="text-sm text-gray-600">Schedule for later execution</p>
|
|
</div>
|
|
</div>
|
|
</rs-card>
|
|
</div>
|
|
|
|
<!-- Batch Statistics -->
|
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
|
<rs-card>
|
|
<div class="p-4 text-center">
|
|
<h3 class="text-2xl font-bold text-blue-600">{{ stats.pending }}</h3>
|
|
<p class="text-sm text-gray-600">Pending</p>
|
|
</div>
|
|
</rs-card>
|
|
<rs-card>
|
|
<div class="p-4 text-center">
|
|
<h3 class="text-2xl font-bold text-yellow-600">
|
|
{{ stats.processing }}
|
|
</h3>
|
|
<p class="text-sm text-gray-600">Processing</p>
|
|
</div>
|
|
</rs-card>
|
|
<rs-card>
|
|
<div class="p-4 text-center">
|
|
<h3 class="text-2xl font-bold text-green-600">
|
|
{{ stats.completed }}
|
|
</h3>
|
|
<p class="text-sm text-gray-600">Completed</p>
|
|
</div>
|
|
</rs-card>
|
|
<rs-card>
|
|
<div class="p-4 text-center">
|
|
<h3 class="text-2xl font-bold text-red-600">{{ stats.failed }}</h3>
|
|
<p class="text-sm text-gray-600">Failed</p>
|
|
</div>
|
|
</rs-card>
|
|
</div>
|
|
|
|
<!-- Active Batches -->
|
|
<rs-card>
|
|
<template #header>
|
|
<div class="flex items-center justify-between">
|
|
<h3 class="text-lg font-semibold text-primary">Active Batches</h3>
|
|
<rs-button
|
|
variant="outline"
|
|
size="sm"
|
|
@click="refreshBatches"
|
|
:loading="isLoading"
|
|
>
|
|
<Icon class="mr-1" name="ic:outline-refresh"></Icon>
|
|
Refresh
|
|
</rs-button>
|
|
</div>
|
|
</template>
|
|
<template #body>
|
|
<div class="space-y-4">
|
|
<!-- Loading State -->
|
|
<div v-if="isLoading" class="flex justify-center items-center py-8">
|
|
<rs-spinner size="lg" />
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<div v-else-if="batches.length === 0" class="text-center py-8">
|
|
<Icon name="ic:outline-inbox" class="text-4xl text-gray-400 mb-2" />
|
|
<p class="text-gray-600">No batches found</p>
|
|
</div>
|
|
|
|
<!-- Batches List -->
|
|
<div v-else class="space-y-4">
|
|
<div
|
|
v-for="batch in batches"
|
|
:key="batch.id"
|
|
class="border border-gray-200 rounded-lg p-4"
|
|
>
|
|
<div class="flex items-center justify-between mb-3">
|
|
<div class="flex items-center">
|
|
<div
|
|
class="w-3 h-3 rounded-full mr-3"
|
|
:class="getStatusColor(batch.status)"
|
|
></div>
|
|
<div>
|
|
<h4 class="font-semibold">{{ batch.name }}</h4>
|
|
<p class="text-sm text-gray-600">{{ batch.description }}</p>
|
|
</div>
|
|
</div>
|
|
<div class="text-right">
|
|
<span class="text-sm font-medium capitalize">{{ batch.status }}</span>
|
|
<p class="text-xs text-gray-500">
|
|
{{ formatDate(batch.time) }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Progress Info -->
|
|
<div class="space-y-2">
|
|
<div class="text-sm text-gray-600">
|
|
Progress: {{ batch.processed }}/{{ batch.total }} ({{
|
|
Math.round((batch.processed / batch.total) * 100)
|
|
}}%)
|
|
</div>
|
|
<div class="w-full bg-gray-200 rounded-full h-2">
|
|
<div
|
|
class="bg-blue-600 h-2 rounded-full"
|
|
:style="{
|
|
width: `${Math.round((batch.processed / batch.total) * 100)}%`,
|
|
}"
|
|
></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Pagination -->
|
|
<div
|
|
v-if="pagination.totalPages > 1"
|
|
class="flex justify-center items-center space-x-2 mt-4"
|
|
>
|
|
<rs-button
|
|
variant="outline"
|
|
size="sm"
|
|
:disabled="pagination.page === 1"
|
|
@click="pagination.page--"
|
|
>
|
|
Previous
|
|
</rs-button>
|
|
<span class="text-sm text-gray-600">
|
|
Page {{ pagination.page }} of {{ pagination.totalPages }}
|
|
</span>
|
|
<rs-button
|
|
variant="outline"
|
|
size="sm"
|
|
:disabled="pagination.page === pagination.totalPages"
|
|
@click="pagination.page++"
|
|
>
|
|
Next
|
|
</rs-button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</rs-card>
|
|
|
|
<!-- Create Batch Modal -->
|
|
<rs-modal v-model="showCreateModal" title="Create New Batch">
|
|
<div class="space-y-4">
|
|
<!-- Basic Information -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">Batch Name</label>
|
|
<input
|
|
v-model="newBatch.name"
|
|
type="text"
|
|
class="w-full p-2 border border-gray-300 rounded-md"
|
|
placeholder="Enter batch name"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Message Type -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">Message Type</label>
|
|
<select
|
|
v-model="newBatch.type"
|
|
class="w-full p-2 border border-gray-300 rounded-md"
|
|
>
|
|
<option value="">Select type</option>
|
|
<option value="email">Email</option>
|
|
<option value="push">Push Notification</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Priority -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">Priority</label>
|
|
<select
|
|
v-model="newBatch.priority"
|
|
class="w-full p-2 border border-gray-300 rounded-md"
|
|
>
|
|
<option value="low">Low</option>
|
|
<option value="medium">Medium</option>
|
|
<option value="high">High</option>
|
|
<option value="critical">Critical</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Template Selection -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2"
|
|
>Template (Optional)</label
|
|
>
|
|
<select
|
|
v-model="newBatch.template"
|
|
class="w-full p-2 border border-gray-300 rounded-md"
|
|
>
|
|
<option value="">No template</option>
|
|
<option v-for="template in templates" :key="template.id" :value="template.id">
|
|
{{ template.name }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- User Segment -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2"
|
|
>User Segment (Optional)</label
|
|
>
|
|
<select
|
|
v-model="newBatch.segment"
|
|
class="w-full p-2 border border-gray-300 rounded-md"
|
|
>
|
|
<option value="">All Users</option>
|
|
<option v-for="segment in segments" :key="segment.id" :value="segment.value">
|
|
{{ segment.name }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
|
|
<!-- Scheduled Time -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2"
|
|
>Schedule For (Optional)</label
|
|
>
|
|
<input
|
|
v-model="newBatch.scheduledAt"
|
|
type="datetime-local"
|
|
class="w-full p-2 border border-gray-300 rounded-md"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Description -->
|
|
<div>
|
|
<label class="block text-sm font-medium text-gray-700 mb-2">Description</label>
|
|
<textarea
|
|
v-model="newBatch.description"
|
|
class="w-full p-2 border border-gray-300 rounded-md"
|
|
rows="3"
|
|
placeholder="Describe this batch"
|
|
></textarea>
|
|
</div>
|
|
</div>
|
|
|
|
<template #footer>
|
|
<div class="flex justify-end gap-2">
|
|
<rs-button
|
|
variant="outline"
|
|
@click="showCreateModal = false"
|
|
:disabled="isCreating"
|
|
>Cancel</rs-button
|
|
>
|
|
<rs-button @click="createBatch" :loading="isCreating" :disabled="isCreating">
|
|
{{ isCreating ? "Creating..." : "Create Batch" }}
|
|
</rs-button>
|
|
</div>
|
|
</template>
|
|
</rs-modal>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { onMounted, watch } from "vue";
|
|
import { useToast } from "vue-toastification";
|
|
|
|
definePageMeta({
|
|
title: "Batch Processing",
|
|
middleware: ["auth"],
|
|
requiresAuth: true,
|
|
breadcrumb: [
|
|
{
|
|
name: "Notification",
|
|
path: "/notification",
|
|
},
|
|
{
|
|
name: "Queue & Scheduler",
|
|
path: "/notification/queue-scheduler",
|
|
},
|
|
{
|
|
name: "Batch Processing",
|
|
path: "/notification/queue-scheduler/batch",
|
|
},
|
|
],
|
|
});
|
|
|
|
// Basic stats
|
|
const stats = ref({
|
|
pending: 0,
|
|
processing: 0,
|
|
completed: 0,
|
|
failed: 0,
|
|
});
|
|
|
|
// Pagination
|
|
const pagination = ref({
|
|
page: 1,
|
|
limit: 10,
|
|
total: 0,
|
|
totalPages: 0,
|
|
});
|
|
|
|
// Batches list
|
|
const batches = ref([]);
|
|
|
|
// Loading states
|
|
const isLoading = ref(false);
|
|
const isCreating = ref(false);
|
|
|
|
// Modal state
|
|
const showCreateModal = ref(false);
|
|
|
|
// Form data
|
|
const newBatch = ref({
|
|
name: "",
|
|
type: "",
|
|
description: "",
|
|
scheduledAt: "",
|
|
template: "",
|
|
segment: "",
|
|
priority: "medium",
|
|
});
|
|
|
|
// Templates and segments (will be fetched)
|
|
const templates = ref([]);
|
|
const segments = ref([]);
|
|
|
|
// Fetch data on mount
|
|
onMounted(async () => {
|
|
await Promise.all([fetchStats(), fetchBatches(), fetchTemplates(), fetchSegments()]);
|
|
});
|
|
|
|
// Watch for page changes
|
|
watch(() => pagination.value.page, fetchBatches);
|
|
|
|
// Fetch functions
|
|
async function fetchStats() {
|
|
try {
|
|
const response = await $fetch("/api/notifications/batch/stats");
|
|
stats.value = response;
|
|
} catch (error) {
|
|
console.error("Error fetching stats:", error);
|
|
// Show error notification
|
|
useToast().error("Failed to fetch statistics");
|
|
}
|
|
}
|
|
|
|
async function fetchBatches() {
|
|
try {
|
|
isLoading.value = true;
|
|
const response = await $fetch("/api/notifications/batch", {
|
|
params: {
|
|
page: pagination.value.page,
|
|
limit: pagination.value.limit,
|
|
},
|
|
});
|
|
batches.value = response.batches;
|
|
pagination.value = {
|
|
...pagination.value,
|
|
total: response.pagination.total,
|
|
totalPages: response.pagination.totalPages,
|
|
};
|
|
} catch (error) {
|
|
console.error("Error fetching batches:", error);
|
|
useToast().error("Failed to fetch batches");
|
|
} finally {
|
|
isLoading.value = false;
|
|
}
|
|
}
|
|
|
|
async function fetchTemplates() {
|
|
try {
|
|
const response = await $fetch("/api/notifications/templates");
|
|
templates.value = response;
|
|
} catch (error) {
|
|
console.error("Error fetching templates:", error);
|
|
}
|
|
}
|
|
|
|
async function fetchSegments() {
|
|
try {
|
|
const response = await $fetch("/api/notifications/segments");
|
|
segments.value = response;
|
|
} catch (error) {
|
|
console.error("Error fetching segments:", error);
|
|
}
|
|
}
|
|
|
|
// Create batch
|
|
async function createBatch() {
|
|
try {
|
|
isCreating.value = true;
|
|
|
|
// Validate form
|
|
if (!newBatch.value.name || !newBatch.value.type) {
|
|
useToast().error("Please fill in all required fields");
|
|
return;
|
|
}
|
|
|
|
// Create a copy of the batch data for submission
|
|
const batchData = { ...newBatch.value };
|
|
|
|
// Format scheduledAt if it exists
|
|
if (batchData.scheduledAt) {
|
|
try {
|
|
const date = new Date(batchData.scheduledAt);
|
|
if (isNaN(date.getTime())) {
|
|
useToast().error("Invalid scheduled date");
|
|
return;
|
|
}
|
|
batchData.scheduledAt = date.toISOString();
|
|
} catch (error) {
|
|
useToast().error("Invalid scheduled date");
|
|
return;
|
|
}
|
|
}
|
|
|
|
const response = await $fetch("/api/notifications/batch", {
|
|
method: "POST",
|
|
body: batchData,
|
|
});
|
|
|
|
// Show success message
|
|
useToast().success("Batch created successfully");
|
|
|
|
// Reset form
|
|
newBatch.value = {
|
|
name: "",
|
|
type: "",
|
|
description: "",
|
|
scheduledAt: "",
|
|
template: "",
|
|
segment: "",
|
|
priority: "medium",
|
|
};
|
|
|
|
// Close modal
|
|
showCreateModal.value = false;
|
|
|
|
// Refresh data
|
|
await Promise.all([fetchStats(), fetchBatches()]);
|
|
} catch (error) {
|
|
console.error("Error creating batch:", error);
|
|
useToast().error(error.data?.statusMessage || "Failed to create batch");
|
|
} finally {
|
|
isCreating.value = false;
|
|
}
|
|
}
|
|
|
|
// Utility function for status color
|
|
const getStatusColor = (status) => {
|
|
const colors = {
|
|
draft: "bg-gray-500",
|
|
scheduled: "bg-blue-500",
|
|
sending: "bg-yellow-500",
|
|
sent: "bg-green-500",
|
|
failed: "bg-red-500",
|
|
};
|
|
return colors[status] || "bg-gray-500";
|
|
};
|
|
|
|
// Format date
|
|
const formatDate = (date) => {
|
|
return new Date(date).toLocaleString();
|
|
};
|
|
|
|
// Refresh data
|
|
const refreshBatches = async () => {
|
|
await Promise.all([fetchStats(), fetchBatches()]);
|
|
};
|
|
</script>
|
|
|
|
<style lang="scss" scoped></style>
|