315 lines
14 KiB
Svelte
315 lines
14 KiB
Svelte
<script lang="ts">
|
|
import BrainCircuitIcon from '@lucide/svelte/icons/brain-circuit';
|
|
import CalendarDaysIcon from '@lucide/svelte/icons/calendar-days';
|
|
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down';
|
|
import RefreshCcwIcon from '@lucide/svelte/icons/refresh-ccw';
|
|
|
|
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';
|
|
import { Skeleton } from '$lib/components/ui/skeleton';
|
|
import { useDashboardContext } from '$lib/dashboard-context.svelte';
|
|
import { formatDate } from '$lib/format';
|
|
|
|
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;
|
|
let activeScope = '';
|
|
|
|
const confidenceLabel = $derived(
|
|
(
|
|
{
|
|
HIGH: 'اطمینان بالا',
|
|
MEDIUM: 'اطمینان متوسط',
|
|
LOW: 'اطمینان پایین',
|
|
INSUFFICIENT_DATA: 'داده ناکافی',
|
|
} as const
|
|
)[insight?.confidence ?? 'INSUFFICIENT_DATA'],
|
|
);
|
|
|
|
async function load(
|
|
requestedModule: AIInsightModule,
|
|
requestedTerminalId?: string,
|
|
requestedPeriodId?: string,
|
|
) {
|
|
const current = ++sequence;
|
|
error = '';
|
|
try {
|
|
const next = await api.latestAIInsight(requestedModule, requestedTerminalId, requestedPeriodId);
|
|
if (current === sequence) {
|
|
insight = next;
|
|
loading = false;
|
|
}
|
|
} catch (cause) {
|
|
if (current !== sequence) return;
|
|
error =
|
|
cause instanceof ApiError && cause.status === 404
|
|
? 'هنوز تحلیلی برای این محدوده آماده نشده است.'
|
|
: 'بخش تحلیل هوشمند در دسترس نیست؛ سایر اطلاعات صفحه همچنان قابل استفادهاند.';
|
|
} finally {
|
|
if (current === sequence) loading = false;
|
|
}
|
|
}
|
|
|
|
async function refresh() {
|
|
if (!dashboard.session || refreshing) return;
|
|
refreshing = true;
|
|
error = '';
|
|
try {
|
|
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(module, terminalId, periodId);
|
|
return;
|
|
}
|
|
if (current.state === 'FAILED') throw new Error('job failed');
|
|
}
|
|
error = 'ساخت نسخه تازه ادامه دارد؛ آخرین تحلیل معتبر همچنان نمایش داده میشود.';
|
|
} catch (cause) {
|
|
error =
|
|
cause instanceof ApiError && cause.status === 429
|
|
? 'سهم بهروزرسانی امروز استفاده شده است؛ نسخه ذخیرهشده همچنان نمایش داده میشود.'
|
|
: 'نسخه تازه آماده نشد؛ سایر بخشهای صفحه تحتتأثیر قرار نگرفتند.';
|
|
} finally {
|
|
refreshing = false;
|
|
}
|
|
}
|
|
|
|
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(() => {
|
|
const requestedModule = module;
|
|
const requestedTerminalId = terminalId;
|
|
const requestedPeriodId = periodId;
|
|
const scope = `${requestedModule}\u0000${requestedTerminalId ?? ''}\u0000${requestedPeriodId ?? ''}`;
|
|
if (scope === activeScope) return;
|
|
activeScope = scope;
|
|
insight = null;
|
|
loading = true;
|
|
void load(requestedModule, requestedTerminalId, requestedPeriodId);
|
|
});
|
|
|
|
function eventTone(tone: string) {
|
|
return tone === 'MOURNING' || tone === 'MEMORIAL'
|
|
? 'border-slate-300 bg-slate-50 text-slate-700 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-300'
|
|
: 'border-primary/20 bg-primary/5 text-foreground';
|
|
}
|
|
</script>
|
|
|
|
<section class="my-4" aria-label="تحلیل هوشمند" aria-busy={loading || refreshing}>
|
|
{#if loading && !insight}
|
|
<Card class="rounded-lg"
|
|
><CardContent class="space-y-3 p-5" role="status" aria-label="در حال دریافت تحلیل هوشمند"
|
|
><Skeleton class="h-6 w-44" /><Skeleton class="h-20 w-full" /></CardContent
|
|
></Card
|
|
>
|
|
{:else if !insight}
|
|
<Card class="rounded-lg border-dashed">
|
|
<CardContent class="flex flex-col gap-4 p-5 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<p class="font-bold">تحلیل هوشمند</p>
|
|
<p class="text-muted-foreground mt-1 text-sm" role="status">{error}</p>
|
|
</div>
|
|
<Button variant="outline" onclick={refresh} disabled={refreshing}
|
|
><RefreshCcwIcon class={refreshing ? 'animate-spin' : ''} /> ساخت تحلیل</Button
|
|
>
|
|
</CardContent>
|
|
</Card>
|
|
{:else}
|
|
<Card class="border-primary/20 overflow-hidden rounded-lg shadow-xs">
|
|
<CardHeader class="bg-primary/[.035] border-b">
|
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
<div>
|
|
<CardTitle class="flex items-center gap-2 text-base"
|
|
><BrainCircuitIcon class="text-primary size-5" /> تحلیل هوشمند این صفحه</CardTitle
|
|
>
|
|
<p class="text-muted-foreground mt-2 text-xs leading-6">
|
|
مبنای تحلیل تا {formatDate(insight.dataThroughDate)} است؛ تاریخ واقعی تحلیل {formatDate(
|
|
insight.asOfDate,
|
|
)} و شروع چشمانداز {formatDate(insight.forecastStartDate)} است.
|
|
</p>
|
|
</div>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<Badge variant={insight.freshness === 'STALE' ? 'destructive' : 'secondary'}
|
|
>{insight.freshness === 'FRESH'
|
|
? 'داده تازه'
|
|
: insight.freshness === 'LAGGING'
|
|
? `${insight.dataLagDays} روز فاصله`
|
|
: `داده قدیمی؛ ${insight.dataLagDays} روز فاصله`}</Badge
|
|
>
|
|
<Badge variant="outline">{confidenceLabel}</Badge>
|
|
{#if insight.status === 'PARTIAL'}<Badge variant="outline">نسخه قطعی و بدون متن مولد</Badge>{/if}
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent class="space-y-5 p-5">
|
|
<div class="grid gap-3 lg:grid-cols-2 xl:grid-cols-4">
|
|
{#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">
|
|
<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">
|
|
<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}
|
|
{#if insight.calendarContext.length}
|
|
<div>
|
|
<h3 class="flex items-center gap-2 text-sm font-bold">
|
|
<CalendarDaysIcon class="size-4" /> رویدادهای مرتبط
|
|
</h3>
|
|
<div class="mt-3 flex flex-wrap gap-2">
|
|
{#each insight.calendarContext as event}<span
|
|
class={`rounded-md border px-3 py-2 text-xs ${eventTone(event.tone)}`}
|
|
>{event.titleFa} · {formatDate(event.startDate)}</span
|
|
>{/each}
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
<details class="group rounded-md border p-4">
|
|
<summary class="flex cursor-pointer list-none items-center justify-between text-sm font-bold"
|
|
>شواهد و گزینههای بررسی <ChevronDownIcon
|
|
class="size-4 transition-transform group-open:rotate-180"
|
|
/></summary
|
|
>
|
|
<div class="mt-4 grid gap-3 md:grid-cols-2">
|
|
<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">
|
|
<p class="text-muted-foreground text-xs" aria-live="polite">
|
|
{error ||
|
|
(refreshing
|
|
? 'نسخه تازه در پسزمینه ساخته میشود؛ تحلیل فعلی قابل استفاده است.'
|
|
: 'این تحلیل پیشنهادی است و تصمیم خودکار ایجاد نمیکند.')}
|
|
</p>
|
|
<Button variant="outline" size="sm" onclick={refresh} disabled={refreshing}
|
|
><RefreshCcwIcon class={refreshing ? 'animate-spin' : ''} /> بهروزرسانی تحلیل</Button
|
|
>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
{/if}
|
|
</section>
|