Просмотр исходного кода

CSV/Excel calendar export — download posts and content plans as CSV

New GET /analytics/export endpoint in the gateway returns all published posts
(scheduled + immediate) as a UTF-8 CSV with BOM for Excel compatibility.
Accepts ?account= and ?month=YYYY-MM filters matching the existing analytics
filter pattern. Columns: Date, Time (UTC), Type, Platforms, Status, Content.

Export CSV button added to the Analytics header — triggers a browser download
of the current month scoped to the active account filter. CalendarPlan view
gets its own client-side export that converts the generated calendar posts to
CSV (Platform, Week, Suggested Day, Post Type, Content, Hashtags) and saves as
content-plan-YYYY-MM.csv without any server round-trip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Benjamin Harris 3 недель назад
Родитель
Сommit
a624ba21c6
5 измененных файлов с 130 добавлено и 8 удалено
  1. 73 0
      services/gateway/server.js
  2. 2 0
      ui/src/locales/en.ts
  3. 2 0
      ui/src/locales/tr.ts
  4. 19 0
      ui/src/views/Analytics.vue
  5. 34 8
      ui/src/views/CalendarPlan.vue

+ 73 - 0
services/gateway/server.js

@@ -2173,6 +2173,79 @@ app.get('/analytics/posts', async (request) => {
   return { posts: normalised, total: schedTotal + immTotal };
 });
 
+// ─── Analytics Export ─────────────────────────────────────────────────────────
+
+// GET /analytics/export?format=csv&account=&month=YYYY-MM
+// Exports scheduled jobs + immediate posts as a downloadable CSV.
+app.get('/analytics/export', async (request, reply) => {
+  const { format = 'csv', account, month } = request.query;
+  const filter = parseAccountFilter(account);
+  const db = await getDb();
+
+  let dateFilter = {};
+  if (month) {
+    const start = new Date(`${month}-01T00:00:00.000Z`);
+    const end   = new Date(start);
+    end.setMonth(end.getMonth() + 1);
+    dateFilter = { $gte: start, $lt: end };
+  }
+
+  const sjMatch = {
+    status: { $in: ['completed', 'failed'] },
+    ...sjFilter(filter),
+    ...(month ? { completedAt: dateFilter } : {}),
+  };
+  const ipMatch = {
+    type: 'immediate',
+    ...ipFilter(filter),
+    ...(month ? { publishedAt: dateFilter } : {}),
+  };
+
+  const [scheduledJobs, immediatePosts] = await Promise.all([
+    db.collection('scheduled_jobs')
+      .find(sjMatch)
+      .sort({ completedAt: -1 })
+      .project({ content: 1, destinations: 1, status: 1, completedAt: 1, scheduledAt: 1 })
+      .toArray(),
+    db.collection('posts')
+      .find(ipMatch)
+      .sort({ publishedAt: -1 })
+      .project({ content: 1, destinations: 1, platformResults: 1, status: 1, publishedAt: 1 })
+      .toArray(),
+  ]);
+
+  const rows = [
+    ...scheduledJobs.map((j) => ({
+      type: 'scheduled',
+      date: (j.completedAt || j.scheduledAt)?.toISOString()?.slice(0, 10) ?? '',
+      time: (j.completedAt || j.scheduledAt)?.toISOString()?.slice(11, 16) ?? '',
+      platforms: (j.destinations || []).map((d) => d.platform).join(' | '),
+      status: j.status === 'completed' ? 'published' : 'failed',
+      content: j.content || '',
+    })),
+    ...immediatePosts.map((p) => ({
+      type: 'immediate',
+      date: p.publishedAt?.toISOString()?.slice(0, 10) ?? '',
+      time: p.publishedAt?.toISOString()?.slice(11, 16) ?? '',
+      platforms: (p.destinations || []).map((d) => d.platform).join(' | '),
+      status: p.status,
+      content: p.content || '',
+    })),
+  ].sort((a, b) => b.date.localeCompare(a.date) || b.time.localeCompare(a.time));
+
+  const escape = (v) => `"${String(v).replace(/"/g, '""')}"`;
+  const header = ['Date', 'Time (UTC)', 'Type', 'Platforms', 'Status', 'Content'];
+  const lines  = [
+    header.join(','),
+    ...rows.map((r) => [r.date, r.time, r.type, r.platforms, r.status, escape(r.content)].join(',')),
+  ];
+
+  const filename = `posts-${month || 'all'}.csv`;
+  reply.header('Content-Type', 'text/csv; charset=utf-8');
+  reply.header('Content-Disposition', `attachment; filename="${filename}"`);
+  return reply.send('' + lines.join('\r\n'));
+});
+
 // ─── Brand / Account Audit ────────────────────────────────────────────────────
 
 app.post('/analytics/audit', async (request, reply) => {

+ 2 - 0
ui/src/locales/en.ts

@@ -55,6 +55,7 @@ export default {
     crawlMetrics: 'Crawl Metrics',
     crawling: 'Crawling…',
     crawlDone: 'Crawled {count} posts',
+    exportCsv: 'Export CSV',
 
     runAudit: 'Run Brand Audit',
     runningAudit: 'Auditing…',
@@ -112,6 +113,7 @@ export default {
     savingAll: 'Saving drafts…',
     draft: 'Draft',
     week: 'Week {n}',
+    exportCsv: 'Export CSV',
   },
 
   media: {

+ 2 - 0
ui/src/locales/tr.ts

@@ -55,6 +55,7 @@ export default {
     crawlMetrics: 'Metrikleri Getir',
     crawling: 'Getiriliyor…',
     crawlDone: '{count} gönderi getirildi',
+    exportCsv: 'CSV Dışa Aktar',
 
     runAudit: 'Marka Denetimi Yap',
     runningAudit: 'Denetleniyor…',
@@ -112,6 +113,7 @@ export default {
     savingAll: 'Taslaklar kaydediliyor…',
     draft: 'Taslağa al',
     week: '{n}. hafta',
+    exportCsv: 'CSV Dışa Aktar',
   },
 
   media: {

+ 19 - 0
ui/src/views/Analytics.vue

@@ -17,6 +17,13 @@
             <i class="fa-solid fa-clipboard-check text-xs" :class="{ 'animate-pulse': auditLoading }"></i>
             {{ auditLoading ? $t('analytics.runningAudit') : $t('analytics.runAudit') }}
           </button>
+          <button
+            @click="exportCsv"
+            class="flex items-center gap-2 px-4 py-2 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-xl text-sm font-medium transition-colors"
+          >
+            <i class="fa-solid fa-file-csv text-xs"></i>
+            {{ $t('analytics.exportCsv') }}
+          </button>
           <button
             @click="crawlMetrics"
             :disabled="crawling"
@@ -627,6 +634,18 @@ async function loadInsights() {
   }
 }
 
+function exportCsv() {
+  const params = new URLSearchParams()
+  if (selectedAccount.value) params.set('account', selectedAccount.value)
+  // Export current month by default
+  params.set('month', new Date().toISOString().slice(0, 7))
+  const url = `/api/analytics/export?${params.toString()}`
+  const a = document.createElement('a')
+  a.href = url
+  a.download = ''
+  a.click()
+}
+
 async function runAudit() {
   auditLoading.value = true
   auditError.value = false

+ 34 - 8
ui/src/views/CalendarPlan.vue

@@ -81,14 +81,23 @@
               <h2 class="font-semibold text-white">{{ $t('calendarPlan.briefTitle') }}</h2>
               <p class="text-xs text-gray-500 mt-0.5">{{ calendar.monthName }}</p>
             </div>
-            <button
-              @click="saveAllDrafts"
-              :disabled="savingAll"
-              class="flex items-center gap-1.5 text-sm px-4 py-2 bg-emerald-700 hover:bg-emerald-600 disabled:opacity-40 rounded-lg text-white transition-colors"
-            >
-              <i class="fa-solid fa-floppy-disk text-xs"></i>
-              {{ savingAll ? $t('calendarPlan.savingAll') : $t('calendarPlan.saveAllDrafts', { count: calendar.posts.length }) }}
-            </button>
+            <div class="flex gap-2">
+              <button
+                @click="exportCalendarCsv"
+                class="flex items-center gap-1.5 text-sm px-3 py-2 bg-gray-700 hover:bg-gray-600 rounded-lg text-gray-200 transition-colors"
+              >
+                <i class="fa-solid fa-file-csv text-xs"></i>
+                {{ $t('calendarPlan.exportCsv') }}
+              </button>
+              <button
+                @click="saveAllDrafts"
+                :disabled="savingAll"
+                class="flex items-center gap-1.5 text-sm px-4 py-2 bg-emerald-700 hover:bg-emerald-600 disabled:opacity-40 rounded-lg text-white transition-colors"
+              >
+                <i class="fa-solid fa-floppy-disk text-xs"></i>
+                {{ savingAll ? $t('calendarPlan.savingAll') : $t('calendarPlan.saveAllDrafts', { count: calendar.posts.length }) }}
+              </button>
+            </div>
           </div>
 
           <div class="p-4 bg-gray-800/50 rounded-xl mb-4">
@@ -250,6 +259,23 @@ async function generate() {
   }
 }
 
+function exportCalendarCsv() {
+  if (!calendar.value?.posts?.length) return
+  const escape = (v: string) => `"${String(v).replace(/"/g, '""')}"`
+  const header = ['Platform', 'Week', 'Suggested Day', 'Post Type', 'Content', 'Hashtags']
+  const rows = calendar.value.posts.map((p: any) => [
+    p.platform, p.week, p.suggestedDay || '', p.postType || '', escape(p.content), (p.hashtags || []).join(' '),
+  ].join(','))
+  const csv = '' + [header.join(','), ...rows].join('\r\n')
+  const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
+  const url = URL.createObjectURL(blob)
+  const a = document.createElement('a')
+  a.href = url
+  a.download = `content-plan-${calendar.value.month}.csv`
+  a.click()
+  URL.revokeObjectURL(url)
+}
+
 function draftPost(content: string) {
   composeStore.content = content
   router.push('/compose')