feat: complete AI analytics experience
This commit is contained in:
@@ -4,7 +4,13 @@
|
||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
||||
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
|
||||
|
||||
import { ApiError, api, type AIInsight, type AIInsightModule } from '$lib/api/client';
|
||||
import {
|
||||
ApiError,
|
||||
api,
|
||||
type AIInsight,
|
||||
type AIInsightModule,
|
||||
type AIInsightSection,
|
||||
} from '$lib/api/client';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card';
|
||||
@@ -12,11 +18,14 @@
|
||||
import { useDashboardContext } from '$lib/dashboard-context.svelte';
|
||||
import { formatDate } from '$lib/format';
|
||||
|
||||
let { module, terminalId }: { module: AIInsightModule; terminalId?: string } = $props();
|
||||
let { module, terminalId, periodId }: { module: AIInsightModule; terminalId?: string; periodId?: string } =
|
||||
$props();
|
||||
const dashboard = useDashboardContext();
|
||||
let insight = $state<AIInsight | null>(null);
|
||||
let loading = $state(true);
|
||||
let refreshing = $state(false);
|
||||
let refreshingSections = $state<Partial<Record<AIInsightSection, boolean>>>({});
|
||||
let sectionErrors = $state<Partial<Record<AIInsightSection, string>>>({});
|
||||
let error = $state('');
|
||||
let sequence = 0;
|
||||
|
||||
@@ -31,12 +40,15 @@
|
||||
)[insight?.confidence ?? 'INSUFFICIENT_DATA'],
|
||||
);
|
||||
|
||||
async function load() {
|
||||
async function load(
|
||||
requestedModule: AIInsightModule,
|
||||
requestedTerminalId?: string,
|
||||
requestedPeriodId?: string,
|
||||
) {
|
||||
const current = ++sequence;
|
||||
loading = !insight;
|
||||
error = '';
|
||||
try {
|
||||
const next = await api.latestAIInsight(module, terminalId);
|
||||
const next = await api.latestAIInsight(requestedModule, requestedTerminalId, requestedPeriodId);
|
||||
if (current === sequence) insight = next;
|
||||
} catch (cause) {
|
||||
if (current !== sequence) return;
|
||||
@@ -54,12 +66,12 @@
|
||||
refreshing = true;
|
||||
error = '';
|
||||
try {
|
||||
const job = await api.refreshAIInsight(module, dashboard.session.csrfToken, terminalId);
|
||||
const job = await api.refreshAIInsight(module, dashboard.session.csrfToken, terminalId, periodId);
|
||||
for (let attempt = 0; attempt < 45; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
const current = await api.aiInsightJob(job.jobId);
|
||||
if (current.state === 'COMPLETED') {
|
||||
await load();
|
||||
await load(module, terminalId, periodId);
|
||||
return;
|
||||
}
|
||||
if (current.state === 'FAILED') throw new Error('job failed');
|
||||
@@ -75,11 +87,50 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshOne(section: AIInsightSection) {
|
||||
if (!dashboard.session || refreshingSections[section]) return;
|
||||
refreshingSections[section] = true;
|
||||
sectionErrors[section] = '';
|
||||
try {
|
||||
const job = await api.refreshAIInsightSection(
|
||||
module,
|
||||
section,
|
||||
dashboard.session.csrfToken,
|
||||
terminalId,
|
||||
periodId,
|
||||
);
|
||||
for (let attempt = 0; attempt < 45; attempt += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
const current = await api.aiInsightJob(job.jobId);
|
||||
if (current.state === 'COMPLETED') {
|
||||
await load(module, terminalId, periodId);
|
||||
return;
|
||||
}
|
||||
if (current.state === 'FAILED') throw new Error('job failed');
|
||||
}
|
||||
sectionErrors[section] = 'تازهسازی ادامه دارد؛ متن معتبر قبلی حفظ شده است.';
|
||||
} catch (cause) {
|
||||
sectionErrors[section] =
|
||||
cause instanceof ApiError && cause.status === 429
|
||||
? 'سهم این بخش فعلاً تمام شده است؛ متن قبلی حفظ شد.'
|
||||
: 'این بخش تازه نشد؛ متن قبلی بدون تغییر باقی ماند.';
|
||||
} finally {
|
||||
refreshingSections[section] = false;
|
||||
}
|
||||
}
|
||||
|
||||
function sectionBusy(section: AIInsightSection) {
|
||||
const state = insight?.sectionRefreshState?.[section]?.state;
|
||||
return Boolean(refreshingSections[section]) || ['QUEUED', 'REFRESHING', 'RUNNING'].includes(state ?? '');
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
module;
|
||||
terminalId;
|
||||
const requestedModule = module;
|
||||
const requestedTerminalId = terminalId;
|
||||
const requestedPeriodId = periodId;
|
||||
insight = null;
|
||||
void load();
|
||||
loading = true;
|
||||
void load(requestedModule, requestedTerminalId, requestedPeriodId);
|
||||
});
|
||||
|
||||
function eventTone(tone: string) {
|
||||
@@ -137,16 +188,44 @@
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-5 p-5">
|
||||
<div class="grid gap-3 lg:grid-cols-2 xl:grid-cols-4">
|
||||
{#each [insight.copy.past, insight.copy.latestObserved, insight.copy.future7Days, insight.copy.future30Days] as section}
|
||||
{#each [{ id: 'past' as const, copy: insight.copy.past }, { id: 'latest-observed' as const, copy: insight.copy.latestObserved }, { id: 'future-7-days' as const, copy: insight.copy.future7Days }, { id: 'future-30-days' as const, copy: insight.copy.future30Days }] as section}
|
||||
<article class="bg-muted/35 rounded-md border p-4">
|
||||
<h3 class="text-sm font-bold">{section.headline}</h3>
|
||||
<p class="text-muted-foreground mt-2 text-sm leading-7">{section.body}</p>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<h3 class="text-sm font-bold">{section.copy.headline}</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="تازهسازی همین بخش"
|
||||
title="تازهسازی همین بخش"
|
||||
onclick={() => refreshOne(section.id)}
|
||||
disabled={sectionBusy(section.id)}
|
||||
><RefreshCcwIcon
|
||||
class={`size-4 ${sectionBusy(section.id) ? 'animate-spin' : ''}`}
|
||||
/></Button
|
||||
>
|
||||
</div>
|
||||
<p class="text-muted-foreground mt-2 text-sm leading-7">{section.copy.body}</p>
|
||||
{#if sectionErrors[section.id]}<p class="text-destructive mt-2 text-xs" role="status">
|
||||
{sectionErrors[section.id]}
|
||||
</p>{/if}
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{#if insight.copy.future90Days}
|
||||
<article class="border-primary/15 bg-primary/[.025] rounded-md border p-4">
|
||||
<h3 class="text-sm font-bold">{insight.copy.future90Days.headline}</h3>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<h3 class="text-sm font-bold">{insight.copy.future90Days.headline}</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="تازهسازی چشمانداز ۹۰ روزه"
|
||||
onclick={() => refreshOne('future-90-days')}
|
||||
disabled={sectionBusy('future-90-days')}
|
||||
><RefreshCcwIcon
|
||||
class={`size-4 ${sectionBusy('future-90-days') ? 'animate-spin' : ''}`}
|
||||
/></Button
|
||||
>
|
||||
</div>
|
||||
<p class="text-muted-foreground mt-2 text-sm leading-7">{insight.copy.future90Days.body}</p>
|
||||
</article>
|
||||
{/if}
|
||||
@@ -170,16 +249,45 @@
|
||||
/></summary
|
||||
>
|
||||
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{#each insight.copy.findings as finding}<div>
|
||||
<p class="text-sm font-bold">{finding.title}</p>
|
||||
<p class="text-muted-foreground mt-1 text-sm leading-6">{finding.summary}</p>
|
||||
<code class="text-muted-foreground mt-1 block text-[11px]">{finding.evidenceId}</code>
|
||||
</div>{/each}{#each insight.copy.options as option}<div
|
||||
class="border-primary/15 bg-primary/[.025] rounded-md border p-3"
|
||||
>
|
||||
<p class="text-sm font-bold">{option.title}</p>
|
||||
<p class="text-muted-foreground mt-1 text-sm leading-6">{option.description}</p>
|
||||
</div>{/each}
|
||||
<section>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="font-bold">یافتهها</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="تازهسازی یافتهها"
|
||||
onclick={() => refreshOne('findings')}
|
||||
disabled={sectionBusy('findings')}
|
||||
><RefreshCcwIcon
|
||||
class={`size-4 ${sectionBusy('findings') ? 'animate-spin' : ''}`}
|
||||
/></Button
|
||||
>
|
||||
</div>
|
||||
{#each insight.copy.findings as finding}<div class="mb-3">
|
||||
<p class="text-sm font-bold">{finding.title}</p>
|
||||
<p class="text-muted-foreground mt-1 text-sm leading-6">{finding.summary}</p>
|
||||
<code class="text-muted-foreground mt-1 block text-[11px]">{finding.evidenceId}</code>
|
||||
</div>{/each}
|
||||
</section>
|
||||
<section>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="font-bold">گزینهها</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="تازهسازی گزینهها"
|
||||
onclick={() => refreshOne('options')}
|
||||
disabled={sectionBusy('options')}
|
||||
><RefreshCcwIcon class={`size-4 ${sectionBusy('options') ? 'animate-spin' : ''}`} /></Button
|
||||
>
|
||||
</div>
|
||||
{#each insight.copy.options as option}<div
|
||||
class="border-primary/15 bg-primary/[.025] rounded-md border p-3"
|
||||
>
|
||||
<p class="text-sm font-bold">{option.title}</p>
|
||||
<p class="text-muted-foreground mt-1 text-sm leading-6">{option.description}</p>
|
||||
</div>{/each}
|
||||
</section>
|
||||
</div>
|
||||
</details>
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
|
||||
Reference in New Issue
Block a user