main
vue 306 lines 9.9 KB
Raw
1 <template>
2 <div class="flex min-h-40 flex-col gap-4">
3 <n-spin :show="loading" class="grow">
4 <div v-if="!data && !loading" class="flex flex-col items-center gap-3 pt-6">
5 <n-empty description="No digest yet" />
6 </div>
7
8 <div v-else-if="data" class="@container flex flex-col gap-4">
9 <!-- Summary tiles -->
10 <div class="grid gap-3 @sm:grid-cols-2 @xl:grid-cols-4">
11 <CardLink
12 v-for="tile of summaryTiles"
13 :key="tile.title"
14 embedded
15 size="small"
16 :title="tile.title"
17 :value="tile.value"
18 :subtitle="tile.subtitle"
19 :color="tile.color"
20 />
21 </div>
22
23 <!-- Upcoming expirations -->
24 <CardEntity v-if="data.upcoming_expirations.length" size="small" embedded highlighted>
25 <template #headerMain>
26 <Icon :name="WarningIcon" :size="14" class="text-warning relative top-0.5 mr-1" />
27 Expiring soon ({{ data.upcoming_expirations.length }})
28 </template>
29 <template #default>
30 <div class="flex flex-col gap-2">
31 <CardEntity v-for="ls of data.upcoming_expirations" :key="ls.id" size="small">
32 <template #header>
33 <div class="text-default flex flex-wrap items-center gap-2">
34 <Badge type="splitted" bright size="small">
35 <template #label>ID</template>
36 <template #value>{{ ls.id }}</template>
37 </Badge>
38 <Badge type="splitted" color="warning" size="small">
39 <template #label>{{ ls.lesson_type }}</template>
40 <template #value>{{ expiryLabel(ls.days_until_expiry) }}</template>
41 </Badge>
42 </div>
43 </template>
44 <template #default>
45 <div class="text-sm">
46 {{ ls.lesson_text }}
47 </div>
48 </template>
49 </CardEntity>
50 </div>
51 </template>
52 </CardEntity>
53
54 <!-- Near-duplicate pairs -->
55 <CardEntity v-if="data.duplicate_candidates.length" size="small" embedded>
56 <template #headerMain>Near-duplicate candidates ({{ data.duplicate_candidates.length }})</template>
57 <template #default>
58 <div class="flex flex-col gap-2">
59 <CardEntity
60 v-for="pair of data.duplicate_candidates"
61 :key="`${pair.lesson_a_id}-${pair.lesson_b_id}`"
62 size="small"
63 >
64 <template #header>
65 <div class="text-default flex flex-wrap items-center gap-2">
66 <Badge type="splitted" bright :color="simColor(pair.similarity)" size="small">
67 <template #label>{{ pair.room }}</template>
68 <template #value>{{ Math.round(pair.similarity * 100) }}%</template>
69 </Badge>
70 </div>
71 </template>
72 <template #default>
73 <dl class="mt-1 flex flex-col gap-2">
74 <div class="flex flex-col gap-1">
75 <dt class="text-secondary font-mono text-[10px] tracking-wider uppercase">
76 Lesson {{ pair.lesson_a_id }}
77 </dt>
78 <dd
79 class="text-default font-mono text-xs leading-snug wrap-break-word whitespace-pre-wrap"
80 >
81 {{ pair.lesson_a_text }}
82 </dd>
83 </div>
84 <div class="border-border flex flex-col gap-1 border-t pt-3">
85 <dt class="text-secondary font-mono text-[10px] tracking-wider uppercase">
86 Lesson {{ pair.lesson_b_id }}
87 </dt>
88 <dd
89 class="text-default font-mono text-xs leading-snug wrap-break-word whitespace-pre-wrap"
90 >
91 {{ pair.lesson_b_text }}
92 </dd>
93 </div>
94 </dl>
95 </template>
96 </CardEntity>
97 </div>
98 </template>
99 </CardEntity>
100
101 <!-- Per-room breakdown -->
102 <CardEntity size="small" embedded>
103 <template #headerMain>By room</template>
104 <template #default>
105 <n-empty
106 v-if="!data.rooms.length"
107 description="No active lessons"
108 class="min-h-20 justify-center"
109 />
110 <n-collapse v-else>
111 <n-collapse-item v-for="group of data.rooms" :key="group.room" :name="group.room">
112 <template #header>
113 <div class="flex w-full flex-wrap items-center justify-between gap-2">
114 <span class="font-medium">{{ group.room }}</span>
115 <div class="ml-auto flex flex-wrap items-center gap-2">
116 <Badge type="splitted" size="small">
117 <template #label>Total</template>
118 <template #value>{{ group.total }}</template>
119 </Badge>
120 <Badge type="splitted" color="success" size="small">
121 <template #label>Durable</template>
122 <template #value>{{ group.durable }}</template>
123 </Badge>
124 <Badge type="splitted" color="warning" size="small">
125 <template #label>One-off</template>
126 <template #value>{{ group.one_off }}</template>
127 </Badge>
128 </div>
129 </div>
130 </template>
131 <div class="flex flex-col gap-2">
132 <CardEntity v-for="ls of group.lessons" :key="ls.id" size="small">
133 <template #default>
134 <div class="flex flex-wrap items-center gap-2">
135 <Badge
136 type="splitted"
137 size="small"
138 :color="ls.durability === 'durable' ? 'success' : 'warning'"
139 >
140 <template #label>{{ ls.durability }}</template>
141 <template #value>
142 {{
143 ls.durability === "one_off"
144 ? expiryLabel(ls.days_until_expiry)
145 : ""
146 }}
147 </template>
148 </Badge>
149 <Badge type="splitted" size="small" :color="statusColor(ls.status)">
150 <template #label>Status</template>
151 <template #value>{{ ls.status }}</template>
152 </Badge>
153 <Badge type="splitted" size="small">
154 <template #label>ID</template>
155 <template #value>{{ ls.id }}</template>
156 </Badge>
157 <Badge type="splitted" size="small">
158 <template #label>Created at</template>
159 <template #value>
160 {{ formatDate(ls.created_at, "MMM D") }}
161 </template>
162 </Badge>
163 </div>
164 </template>
165 <template #mainExtra>
166 <div class="text-sm">
167 {{ ls.lesson_text }}
168 </div>
169 </template>
170 </CardEntity>
171 </div>
172 </n-collapse-item>
173 </n-collapse>
174 </template>
175 </CardEntity>
176
177 <n-collapse>
178 <n-collapse-item title="Markdown" name="markdown">
179 <CodeSource :code="data.markdown" lang="markdown" :max-height="500" />
180 </n-collapse-item>
181 </n-collapse>
182
183 <p class="text-xs">Generated {{ formatDate(data.generated_at, "MMM D, YYYY HH:mm") }} UTC</p>
184 </div>
185 </n-spin>
186
187 <div class="border-border flex w-full items-center justify-between gap-2 border-t pt-4">
188 <p class="text-sm">
189 <template v-if="data">{{ data.total_lessons }} lesson(s) · {{ data.rooms.length }} room(s)</template>
190 </p>
191 <div class="flex items-center gap-2">
192 <n-button size="small" :loading @click="load()">
193 <template #icon>
194 <Icon :name="RefreshIcon" :size="14" />
195 </template>
196 Refresh
197 </n-button>
198 </div>
199 </div>
200 </div>
201 </template>
202
203 <script setup lang="ts">
204 import type { CardLinkColor } from "@/components/common/cards/CardLink.vue"
205 import type { PalaceConsolidation } from "@/types/aiAnalyst.d"
206 import { NButton, NCollapse, NCollapseItem, NEmpty, NSpin, useMessage } from "naive-ui"
207 import { computed, ref, watch } from "vue"
208 import Api from "@/api"
209 import Badge from "@/components/common/Badge.vue"
210 import CardEntity from "@/components/common/cards/CardEntity.vue"
211 import CardLink from "@/components/common/cards/CardLink.vue"
212 import CodeSource from "@/components/common/CodeSource.vue"
213 import Icon from "@/components/common/Icon.vue"
214 import { getApiErrorMessage } from "@/utils"
215 import { formatDate } from "@/utils/format"
216
217 const props = defineProps<{
218 customerCode: string
219 }>()
220
221 const RefreshIcon = "carbon:renew"
222 const WarningIcon = "carbon:warning"
223
224 const message = useMessage()
225
226 const loading = ref(false)
227 const data = ref<PalaceConsolidation | null>(null)
228
229 interface SummaryTile {
230 title: string
231 value: number
232 subtitle: string
233 color?: CardLinkColor
234 }
235
236 const summaryTiles = computed<SummaryTile[]>(() => {
237 const d = data.value
238 if (!d) return []
239
240 return [
241 { title: "Active", value: d.total_lessons, subtitle: `${d.total_pending} pending` },
242 { title: "Durable", value: d.total_durable, subtitle: "never expire", color: "success" },
243 { title: "One-off", value: d.total_one_off, subtitle: "7-day TTL", color: "warning" },
244 {
245 title: "Duplicates",
246 value: d.duplicate_candidates.length,
247 subtitle: "near-dupe pairs",
248 color: d.duplicate_candidates.length ? "warning" : undefined
249 }
250 ]
251 })
252
253 function simColor(sim: number): "success" | "warning" | "danger" {
254 // Similarity is always >= threshold (0.7) when flagged — calibrate bands
255 // for "probably rewrite" vs "review manually" at a glance.
256 if (sim >= 0.9) return "danger"
257 if (sim >= 0.8) return "warning"
258 return "success"
259 }
260
261 function statusColor(status: string): "success" | "warning" | "danger" | undefined {
262 if (status === "ingested") return "success"
263 if (status === "pending") return "warning"
264 if (status === "failed") return "danger"
265 return undefined
266 }
267
268 function expiryLabel(days: number | null): string {
269 if (days == null) return ""
270 if (days <= 0) return "due"
271 if (days === 1) return "1d"
272 return `${days}d`
273 }
274
275 async function load() {
276 if (!props.customerCode) {
277 data.value = null
278 return
279 }
280 loading.value = true
281 try {
282 const res = await Api.aiAnalyst.getPalaceConsolidation(props.customerCode)
283 if (res.data.success) {
284 data.value = res.data
285 } else {
286 message.warning(res.data.message || "Failed to build consolidation")
287 data.value = null
288 }
289 } catch (err: unknown) {
290 message.error(getApiErrorMessage(err as never) || "Failed to build consolidation")
291 data.value = null
292 } finally {
293 loading.value = false
294 }
295 }
296
297 watch(
298 () => props.customerCode,
299 (code, prevCode) => {
300 if (code && code !== prevCode) {
301 load()
302 }
303 },
304 { immediate: true }
305 )
306 </script>