Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 37 additions & 11 deletions app/src/pages/superadmin/SuperAdminDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,15 @@ interface PostsPage {
hasMore: boolean;
nextOffset: number;
total: number;
/** Whole-table counts keyed by status, from the API. */
statusCounts: Record<string, number>;
/** Whole-table counts keyed by type_of_post, from the API. */
typeCounts: Record<string, number>;
}

const asCounts = (v: unknown): Record<string, number> =>
v && typeof v === 'object' ? (v as Record<string, number>) : {};

async function fetchPosts(endpoint: string, offset = 0): Promise<PostsPage> {
const res = await fetch(`${endpoint}?offset=${offset}`, { credentials: 'include' });
if (!res.ok) {
Expand All @@ -153,6 +160,8 @@ async function fetchPosts(endpoint: string, offset = 0): Promise<PostsPage> {
hasMore: Boolean(json.has_more),
nextOffset: typeof json.next_offset === 'number' ? json.next_offset : offset + posts.length,
total: typeof json.total_posts === 'number' ? json.total_posts : offset + posts.length,
statusCounts: asCounts(json.status_counts),
typeCounts: asCounts(json.type_counts),
};
}

Expand Down Expand Up @@ -349,9 +358,23 @@ interface InsightsProps {
function Insights({ data, activeSource, onSourceSelect, activeStage, onStageSelect }: InsightsProps) {
const loaded = [...data.faculty.posts, ...data.warden.posts, ...data.centrehead.posts];
const grandTotal = SECTIONS.reduce((a, s) => a + data[s.key].total, 0);
const resolved = loaded.filter(p => p.status.toLowerCase() === 'resolved_all').length;
const comments = loaded.reduce((a, p) => a + (p.comments?.length ?? 0), 0);

// Whole-table counts, summed across the three sources.
const sumCounts = (pick: (page: PostsPage) => Record<string, number>) => {
const out: Record<string, number> = {};
for (const s of SECTIONS) {
for (const [k, v] of Object.entries(pick(data[s.key]))) {
const key = k.toLowerCase();
out[key] = (out[key] ?? 0) + v;
}
}
return out;
};
const statusTotals = sumCounts(p => p.statusCounts);
const typeTotals = sumCounts(p => p.typeCounts);
const resolved = statusTotals['resolved_all'] ?? 0;

const sourceSegments: DonutSegment[] = SECTIONS.map(s => ({
key: s.key,
label: s.label.replace(' Posts', ''),
Expand All @@ -361,21 +384,22 @@ function Insights({ data, activeSource, onSourceSelect, activeStage, onStageSele
}));

const stageSegments: DonutSegment[] = STAGES.map(st => {
const inStage = loaded.filter(p => stageOf(p.status) === st.key);
const pending = inStage.filter(p => p.status.toLowerCase().startsWith('pending')).length;
const detail = st.statuses.length > 1 ? `${pending} pending · ${inStage.length - pending} resolved` : undefined;
return { key: st.key, label: st.label, value: inStage.length, color: st.color, detail };
const count = (status: string) => statusTotals[status] ?? 0;
const value = st.statuses.reduce((a, status) => a + count(status), 0);
const pending = st.statuses.filter(x => x.startsWith('pending')).reduce((a, status) => a + count(status), 0);
const detail = st.statuses.length > 1 ? `${pending} pending · ${value - pending} resolved` : undefined;
return { key: st.key, label: st.label, value, color: st.color, detail };
});

const civil = loaded.filter(p => p.type_of_post.toLowerCase() === 'civil').length;
const electrical = loaded.length - civil;
const civil = typeTotals['civil'] ?? 0;
const electrical = typeTotals['electrical'] ?? 0;

return (
<div className="mb-8 flex flex-col gap-4">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<StatTile label="Total complaints" value={grandTotal} hint="across all sources" />
<StatTile label="Loaded on this page" value={loaded.length} hint={`${Math.round(grandTotal ? (loaded.length / grandTotal) * 100 : 0)}% of total`} />
<StatTile label="Fully resolved" value={resolved} hint={`${loaded.length - resolved} still in pipeline`} />
<StatTile label="Fully resolved" value={resolved} hint={`${grandTotal - resolved} still in pipeline`} />
<StatTile label="Comments" value={comments} hint="on loaded complaints" />
</div>

Expand All @@ -390,15 +414,15 @@ function Insights({ data, activeSource, onSourceSelect, activeStage, onStageSele
/>
<DonutChart
title="Where complaints sit in the pipeline"
caption="Loaded complaints, by the desk currently holding them."
caption="Every complaint on record, by the desk currently holding it."
segments={stageSegments}
centreLabel="loaded"
centreLabel="complaints"
selected={activeStage}
onSelect={key => onStageSelect(key as Stage | null)}
/>
<Meter
title="Civil vs Electrical"
caption="Loaded complaints, by works category."
caption="Every complaint on record, by works category."
a={{ label: 'Civil', value: civil, color: TYPE_FILL }}
b={{ label: 'Electrical', value: electrical, color: TYPE_TRACK }}
/>
Expand Down Expand Up @@ -448,6 +472,8 @@ export function SuperAdminDashboard() {
hasMore: page.hasMore,
nextOffset: page.nextOffset,
total: page.total,
statusCounts: page.statusCounts,
typeCounts: page.typeCounts,
},
} : prev);
} catch (err) {
Expand Down
62 changes: 62 additions & 0 deletions handlers/superadmin.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,26 @@ func pageOffset(c *gin.Context) int {
return offset
}

// countBy groups the rows of model by column and returns value -> count.
func countBy(db *gorm.DB, model any, column string) (map[string]int64, error) {
var rows []struct {
Key string
Count int64
}
err := db.Model(model).
Select(column + " AS key, COUNT(*) AS count").
Group(column).
Scan(&rows).Error
if err != nil {
return nil, err
}
out := make(map[string]int64, len(rows))
for _, r := range rows {
out[r.Key] = r.Count
}
return out, nil
}

// SuperAdminGetFacultyPosts fetches faculty authored posts with a limit
// of 25 latest ones.
func (h *SuperAdminHandler) SuperAdminGetFacultyPosts(c *gin.Context) {
Expand Down Expand Up @@ -55,6 +75,18 @@ func (h *SuperAdminHandler) SuperAdminGetFacultyPosts(c *gin.Context) {
return
}

// whole-table breakdowns, so the client can chart every post, not just the loaded page.
statusCounts, err := countBy(h.DB, &models.FacultyPost{}, "status")
if err != nil {
c.JSON(500, gin.H{"error": "failed to count posts at the moment."})
return
}
typeCounts, err := countBy(h.DB, &models.FacultyPost{}, "type_of_post")
if err != nil {
c.JSON(500, gin.H{"error": "failed to count posts at the moment."})
return
}

result = h.DB.Order("created_at DESC").
Offset(offset).
Limit(superAdminPageSize + 1).
Expand Down Expand Up @@ -85,6 +117,8 @@ func (h *SuperAdminHandler) SuperAdminGetFacultyPosts(c *gin.Context) {
"has_more": hasMore,
"next_offset": offset + len(posts),
"total_posts": total,
"status_counts": statusCounts,
"type_counts": typeCounts,
})
}

Expand Down Expand Up @@ -121,6 +155,18 @@ func (h *SuperAdminHandler) SuperAdminGetWardenPosts(c *gin.Context) {
return
}

// whole-table breakdowns, so the client can chart every post, not just the loaded page.
statusCounts, err := countBy(h.DB, &models.WardenPost{}, "status")
if err != nil {
c.JSON(500, gin.H{"error": "failed to count posts at the moment."})
return
}
typeCounts, err := countBy(h.DB, &models.WardenPost{}, "type_of_post")
if err != nil {
c.JSON(500, gin.H{"error": "failed to count posts at the moment."})
return
}

result = h.DB.Order("created_at DESC").
Offset(offset).
Limit(superAdminPageSize + 1).
Expand Down Expand Up @@ -151,6 +197,8 @@ func (h *SuperAdminHandler) SuperAdminGetWardenPosts(c *gin.Context) {
"has_more": hasMore,
"next_offset": offset + len(posts),
"total_posts": total,
"status_counts": statusCounts,
"type_counts": typeCounts,
})
}

Expand Down Expand Up @@ -186,6 +234,18 @@ func (h *SuperAdminHandler) SuperAdminGetCentreheadPosts(c *gin.Context) {
return
}

// whole-table breakdowns, so the client can chart every post, not just the loaded page.
statusCounts, err := countBy(h.DB, &models.CentreheadPost{}, "status")
if err != nil {
c.JSON(500, gin.H{"error": "failed to count posts at the moment."})
return
}
typeCounts, err := countBy(h.DB, &models.CentreheadPost{}, "type_of_post")
if err != nil {
c.JSON(500, gin.H{"error": "failed to count posts at the moment."})
return
}

result = h.DB.Order("created_at DESC").
Offset(offset).
Limit(superAdminPageSize + 1).
Expand Down Expand Up @@ -216,5 +276,7 @@ func (h *SuperAdminHandler) SuperAdminGetCentreheadPosts(c *gin.Context) {
"has_more": hasMore,
"next_offset": offset + len(posts),
"total_posts": total,
"status_counts": statusCounts,
"type_counts": typeCounts,
})
}
Loading