| <template> |
| <div class="graph-view"> |
| <div class="graph-header"> |
| <h1>Knowledge Graph</h1> |
| <p>Explore relationships between auditees, findings, recommendations, and legal frameworks.</p> |
| </div> |
| |
| |
| <div class="query-panel"> |
| <n-card title="Graph Queries" :bordered="false"> |
| <div class="query-grid"> |
| <n-button |
| v-for="query in presetQueries" |
| :key="query.id" |
| quaternary |
| class="query-btn" |
| @click="runQuery(query.cypher)" |
| > |
| <template #icon> |
| <n-icon><search-outline /></n-icon> |
| </template> |
| {{ query.label }} |
| </n-button> |
| </div> |
| <n-divider /> |
| <n-input |
| v-model:value="customQuery" |
| type="textarea" |
| placeholder="Enter Cypher query..." |
| :autosize="{ minRows: 3, maxRows: 6 }" |
| class="cypher-input" |
| /> |
| <div class="query-actions"> |
| <n-button type="primary" @click="runQuery(customQuery)" :loading="isLoading"> |
| <template #icon><n-icon><play-outline /></n-icon></template> |
| Run Query |
| </n-button> |
| <n-button quaternary @click="customQuery = ''"> |
| Clear |
| </n-button> |
| </div> |
| </n-card> |
| </div> |
| |
| |
| <div class="graph-visualization"> |
| <n-card :bordered="false" class="graph-card"> |
| <div v-if="isLoading" class="graph-loading"> |
| <n-spin size="large" /> |
| <p>Loading graph data...</p> |
| </div> |
| <div v-else-if="graphData.nodes.length === 0" class="graph-empty"> |
| <n-empty description="Run a query to visualize the knowledge graph"> |
| <template #icon> |
| <n-icon size="48"><share-social-outline /></n-icon> |
| </template> |
| </n-empty> |
| </div> |
| <div v-else ref="graphContainer" class="graph-container"> |
| <v-chart class="chart" :option="chartOption" autoresize /> |
| </div> |
| </n-card> |
| </div> |
| |
| |
| <div v-if="queryResults.length > 0" class="results-panel"> |
| <n-card title="Query Results" :bordered="false"> |
| <n-data-table |
| :columns="resultColumns" |
| :data="queryResults" |
| :pagination="{ pageSize: 10 }" |
| size="small" |
| /> |
| </n-card> |
| </div> |
| </div> |
| </template> |
| |
| <script setup lang="ts"> |
| import { ref, computed } from 'vue' |
| import { use } from 'echarts/core' |
| import { CanvasRenderer } from 'echarts/renderers' |
| import { GraphChart } from 'echarts/charts' |
| import { TooltipComponent, LegendComponent } from 'echarts/components' |
| import VChart from 'vue-echarts' |
| import { api } from '@/api/client' |
| import type { GraphData, GraphNode, GraphEdge } from '@/types' |
| import { SearchOutline, PlayOutline, ShareSocialOutline } from '@vicons/ionicons5' |
| |
| use([CanvasRenderer, GraphChart, TooltipComponent, LegendComponent]) |
| |
| const isLoading = ref(false) |
| const customQuery = ref('') |
| const graphData = ref<GraphData>({ nodes: [], edges: [] }) |
| const queryResults = ref<any[]>([]) |
| |
| const presetQueries = [ |
| { |
| id: 'pending-recs', |
| label: 'Pending Recommendations by Ministry', |
| cypher: 'MATCH (a:Auditee)<-[:AUDITS]-(r:AuditReport)-[:CONTAINS]->(f:Finding)-[:RECOMMENDS]->(rec:Recommendation) WHERE rec.status = "Pending" RETURN a.name, rec.description, rec.deadline ORDER BY rec.deadline', |
| }, |
| { |
| id: 'county-findings', |
| label: 'Findings by County (2023/24)', |
| cypher: 'MATCH (a:Auditee)-[:AUDITS]-(r:AuditReport)-[:CONTAINS]->(f:Finding) WHERE r.fiscal_year = "2023/24" AND a.type = "county" RETURN a.name, count(f) as findings ORDER BY findings DESC', |
| }, |
| { |
| id: 'legal-refs', |
| label: 'Most Cited Legal Provisions', |
| cypher: 'MATCH (f:Finding)-[:CITES_LAW]->(l:LegalProvision) RETURN l.section, count(f) as citations ORDER BY citations DESC LIMIT 10', |
| }, |
| { |
| id: 'adverse-opinions', |
| label: 'Adverse/Disclaimer Opinions', |
| cypher: 'MATCH (r:AuditReport) WHERE r.audit_opinion IN ["adverse", "disclaimer"] RETURN r.title, r.auditee, r.fiscal_year, r.audit_opinion', |
| }, |
| { |
| id: 'amount-entities', |
| label: 'Largest Financial Irregularities', |
| cypher: 'MATCH (f:Finding)-[:INVOLVES_AMOUNT]->(a:FinancialAmount) RETURN f.description, a.value, a.currency ORDER BY a.value DESC LIMIT 20', |
| }, |
| ] |
| |
| const chartOption = computed(() => ({ |
| tooltip: { |
| trigger: 'item', |
| formatter: (params: any) => { |
| if (params.dataType === 'node') { |
| return `<strong>${params.data.label}</strong><br/>Type: ${params.data.type}` |
| } |
| return `${params.data.source} -> ${params.data.target}` |
| }, |
| }, |
| legend: { |
| data: ['Auditee', 'Report', 'Finding', 'Legal', 'Amount'], |
| bottom: 0, |
| }, |
| series: [ |
| { |
| type: 'graph', |
| layout: 'force', |
| data: graphData.value.nodes.map((n: GraphNode) => ({ |
| id: n.id, |
| name: n.label, |
| label: n.label, |
| type: n.type, |
| symbolSize: n.type === 'auditee' ? 40 : n.type === 'report' ? 30 : 20, |
| itemStyle: { |
| color: getNodeColor(n.type), |
| }, |
| category: n.type, |
| })), |
| links: graphData.value.edges.map((e: GraphEdge) => ({ |
| source: e.source, |
| target: e.target, |
| label: { |
| show: true, |
| formatter: e.type, |
| }, |
| })), |
| categories: [ |
| { name: 'Auditee' }, |
| { name: 'Report' }, |
| { name: 'Finding' }, |
| { name: 'Legal' }, |
| { name: 'Amount' }, |
| ], |
| roam: true, |
| label: { |
| show: true, |
| position: 'right', |
| fontSize: 12, |
| }, |
| force: { |
| repulsion: 300, |
| edgeLength: 100, |
| }, |
| emphasis: { |
| focus: 'adjacency', |
| lineStyle: { |
| width: 4, |
| }, |
| }, |
| }, |
| ], |
| })) |
| |
| const resultColumns = [ |
| { title: 'Property', key: 'property' }, |
| { title: 'Value', key: 'value' }, |
| ] |
| |
| const getNodeColor = (type: string) => { |
| const colors: Record<string, string> = { |
| auditee: '#1a5fb4', |
| report: '#26a269', |
| finding: '#e5a50a', |
| legal: '#c01c28', |
| amount: '#9b59b6', |
| } |
| return colors[type] || '#7f8c8d' |
| } |
| |
| const runQuery = async (cypher: string) => { |
| if (!cypher.trim()) return |
| |
| isLoading.value = true |
| try { |
| const response = await api.graphQuery(cypher) |
| graphData.value = response |
| queryResults.value = response.nodes.map((n: GraphNode) => ({ |
| property: n.label, |
| value: JSON.stringify(n.properties), |
| })) |
| } catch (error) { |
| console.error('Graph query failed:', error) |
| } finally { |
| isLoading.value = false |
| } |
| } |
| </script> |
| |
| <style scoped lang="scss"> |
| .graph-view { |
| padding-bottom: 48px; |
| } |
| |
| .graph-header { |
| margin-bottom: 32px; |
| |
| h1 { |
| font-size: 28px; |
| font-weight: 700; |
| margin-bottom: 8px; |
| } |
| |
| p { |
| color: var(--text-secondary); |
| font-size: 15px; |
| } |
| } |
| |
| .query-panel { |
| margin-bottom: 24px; |
| } |
| |
| .query-grid { |
| display: grid; |
| grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); |
| gap: 8px; |
| margin-bottom: 16px; |
| } |
| |
| .query-btn { |
| justify-content: flex-start; |
| text-align: left; |
| height: auto; |
| padding: 8px 12px; |
| font-size: 13px; |
| } |
| |
| .cypher-input { |
| font-family: 'SF Mono', Monaco, monospace; |
| font-size: 13px; |
| } |
| |
| .query-actions { |
| display: flex; |
| gap: 12px; |
| margin-top: 12px; |
| } |
| |
| .graph-visualization { |
| margin-bottom: 24px; |
| } |
| |
| .graph-card { |
| min-height: 500px; |
| } |
| |
| .graph-loading, |
| .graph-empty { |
| display: flex; |
| flex-direction: column; |
| align-items: center; |
| justify-content: center; |
| height: 500px; |
| gap: 16px; |
| } |
| |
| .graph-container { |
| height: 500px; |
| } |
| |
| .chart { |
| width: 100%; |
| height: 100%; |
| } |
| |
| .results-panel { |
| margin-top: 24px; |
| } |
| |
| @media (max-width: 768px) { |
| .query-grid { |
| grid-template-columns: 1fr; |
| } |
| } |
| </style> |
| |