@@ -613,15 +619,22 @@ export function CertsView({ initial }: Props) {
{c.yswsReturned ? (
-
+ showReturnedByAdmin ? (
+
+
+ RETURNED BY {c.yswsReturnedBy ?? 'admin'}
+
+ {c.yswsReturnReason && (
+
+ {c.yswsReturnReason}
+
+ )}
+
+ ) : (
- RETURNED BY ADMIN
+ RETURNED
-
- {c.yswsReturnReason}
-
- by {c.yswsReturnedBy}
-
+ )
) : (
}
+
+export default async function Ships({ searchParams }: PageProps) {
const user = await getUser()
if (!user) redirect('/')
if (!can(user.role, PERMS.certs_view)) redirect('/admin')
- const data = await getCerts({ status: 'pending', lbMode: 'weekly', sortBy: 'oldest' })
+ const params = await searchParams
+ const returnedOnly = params.returned === '1'
+ if (returnedOnly && !can(user.role, PERMS.captain_dashboard)) {
+ redirect('/admin/captain')
+ }
+
+ const data = await getCerts({
+ status: 'pending',
+ lbMode: 'weekly',
+ sortBy: 'oldest',
+ returnedOnly: returnedOnly || undefined,
+ })
return (
← back
@@ -28,6 +41,8 @@ export default async function Ships() {
leaderboard: data.leaderboard,
types: data.typeCounts,
}}
+ showReturnedByAdmin={can(user.role, PERMS.captain_dashboard)}
+ isReturnedView={returnedOnly}
/>
diff --git a/sw-dash/src/app/admin/spot_checks/page.tsx b/sw-dash/src/app/admin/spot_checks/page.tsx
index e348736..f2c1f77 100644
--- a/sw-dash/src/app/admin/spot_checks/page.tsx
+++ b/sw-dash/src/app/admin/spot_checks/page.tsx
@@ -1,6 +1,7 @@
import { Suspense } from 'react'
import Link from 'next/link'
import Stats from './stats'
+import SpotCheckLeaderboard from './spot-check-leaderboard'
import List from './list'
export default function Page() {
@@ -24,6 +25,19 @@ export default function Page() {
+
+
+ Spot check leaderboard
+
+
+ }
+ >
+
+
+
+
shipwrights
diff --git a/sw-dash/src/app/admin/spot_checks/spot-check-leaderboard.tsx b/sw-dash/src/app/admin/spot_checks/spot-check-leaderboard.tsx
new file mode 100644
index 0000000..a4f92da
--- /dev/null
+++ b/sw-dash/src/app/admin/spot_checks/spot-check-leaderboard.tsx
@@ -0,0 +1,82 @@
+'use client'
+
+import { useEffect, useState } from 'react'
+import Image from 'next/image'
+
+type Checker = {
+ id: number
+ username: string
+ avatar: string | null
+ total: number
+ approved: number
+ rejected: number
+}
+
+export default function SpotCheckLeaderboard() {
+ const [data, setData] = useState<{ topCheckers: Checker[] } | null>(null)
+ const [error, setError] = useState(false)
+
+ useEffect(() => {
+ fetch('/api/admin/spot_checks/stats')
+ .then((r) => r.json())
+ .then(setData)
+ .catch(() => setError(true))
+ }, [])
+
+ if (error)
+ return (
+
+ load failed
+
+ )
+ if (!data)
+ return (
+
+ )
+
+ const { topCheckers } = data
+ if (topCheckers.length === 0)
+ return (
+
+ No spot checks yet — leaderboard will appear here.
+
+ )
+
+ return (
+
+
+ Top spot checkers
+
+
+ {topCheckers.map((c, i) => (
+ -
+ #{i + 1}
+ {c.avatar ? (
+
+ ) : (
+
+ )}
+
+ {c.username}
+
+
+ {c.total}
+
+
+ ({c.approved} pass · {c.rejected} fail)
+
+
+ ))}
+
+
+ )
+}
diff --git a/sw-dash/src/app/api/admin/assignments/route.ts b/sw-dash/src/app/api/admin/assignments/route.ts
index aae0c02..682d50d 100644
--- a/sw-dash/src/app/api/admin/assignments/route.ts
+++ b/sw-dash/src/app/api/admin/assignments/route.ts
@@ -72,7 +72,7 @@ export const POST = api()(async ({ user, req }) => {
})
let reviewers = allUsers.filter((u) => {
- const userSkills = (u.skills as string[]) || []
+ const userSkills = JSON.parse(u.skills || '[]') as string[]
return types.some((t: string) => userSkills.includes(t))
})
@@ -89,7 +89,7 @@ export const POST = api()(async ({ user, req }) => {
})
if (selfUser) {
- const selfSkills = (selfUser.skills as string[]) || []
+ const selfSkills = JSON.parse(selfUser.skills || '[]') as string[]
if (types.some((t: string) => selfSkills.includes(t))) {
reviewers = [selfUser]
}
diff --git a/sw-dash/src/app/api/admin/captain/dashboard/route.ts b/sw-dash/src/app/api/admin/captain/dashboard/route.ts
index 25e7f38..66b23a0 100644
--- a/sw-dash/src/app/api/admin/captain/dashboard/route.ts
+++ b/sw-dash/src/app/api/admin/captain/dashboard/route.ts
@@ -67,6 +67,14 @@ export const GET = api(PERMS.captain_dashboard)(async ({ user, req }) => {
where: {
status: { notIn: ['approved', 'rejected'] },
createdAt: { lt: backlogCutoff },
+ yswsReturnedAt: null,
+ },
+ })
+
+ const returnedCount = await prisma.shipCert.count({
+ where: {
+ status: 'pending',
+ yswsReturnedAt: { not: null },
},
})
@@ -91,5 +99,6 @@ export const GET = api(PERMS.captain_dashboard)(async ({ user, req }) => {
byReviewer: byReviewerArray,
},
backlogCount,
+ returnedCount,
})
})
diff --git a/sw-dash/src/app/api/admin/captain/team/[userId]/route.ts b/sw-dash/src/app/api/admin/captain/team/[userId]/route.ts
new file mode 100644
index 0000000..d861e8d
--- /dev/null
+++ b/sw-dash/src/app/api/admin/captain/team/[userId]/route.ts
@@ -0,0 +1,23 @@
+import { NextResponse } from 'next/server'
+import { withParams } from '@/lib/api'
+import { PERMS } from '@/lib/perms'
+import { parseId, idErr } from '@/lib/utils'
+import { cache, genKey } from '@/lib/cache'
+import { getMemberActivity } from '@/lib/captain'
+
+const CACHE_TTL = 90
+
+export const GET = withParams<{ userId: string }>(PERMS.captain_dashboard)(async ({ params }) => {
+ const userId = parseId(params.userId, 'user')
+ if (!userId) return idErr('user')
+
+ const cacheKey = genKey('captain-team-member', { userId: String(userId) })
+ const data = await cache(cacheKey, CACHE_TTL, () => getMemberActivity(userId))
+
+ if (!data) return NextResponse.json({ error: 'user not found' }, { status: 404 })
+
+ const headers = new Headers()
+ headers.set('Cache-Control', 'private, max-age=60')
+
+ return NextResponse.json(data, { headers })
+})
diff --git a/sw-dash/src/app/api/admin/captain/team/route.ts b/sw-dash/src/app/api/admin/captain/team/route.ts
new file mode 100644
index 0000000..2f04d5c
--- /dev/null
+++ b/sw-dash/src/app/api/admin/captain/team/route.ts
@@ -0,0 +1,17 @@
+import { NextResponse } from 'next/server'
+import { api } from '@/lib/api'
+import { PERMS } from '@/lib/perms'
+import { cache, genKey } from '@/lib/cache'
+import { getTeamList } from '@/lib/captain'
+
+const CACHE_TTL = 90
+
+export const GET = api(PERMS.captain_dashboard)(async () => {
+ const cacheKey = genKey('captain-team-list', {})
+ const members = await cache(cacheKey, CACHE_TTL, getTeamList)
+
+ const headers = new Headers()
+ headers.set('Cache-Control', 'private, max-age=60')
+
+ return NextResponse.json({ members }, { headers })
+})
diff --git a/sw-dash/src/app/api/admin/ship_certifications/[id]/route.ts b/sw-dash/src/app/api/admin/ship_certifications/[id]/route.ts
index a8c5d2e..d1ed2b5 100644
--- a/sw-dash/src/app/api/admin/ship_certifications/[id]/route.ts
+++ b/sw-dash/src/app/api/admin/ship_certifications/[id]/route.ts
@@ -53,8 +53,6 @@ export const GET = withParams(PERMS.certs_view)(async ({ user, params }) => {
},
},
},
- orderBy: { createdAt: 'desc' },
- take: 1,
},
},
})
@@ -127,12 +125,12 @@ export const GET = withParams(PERMS.certs_view)(async ({ user, params }) => {
}
: null,
syncedToFt: cert.syncedToFt,
- assignment: cert.assignments[0]
+ assignment: cert.assignments
? {
- id: cert.assignments[0].id,
- status: cert.assignments[0].status,
- assignee: cert.assignments[0].assignee?.username || null,
- createdAt: cert.assignments[0].createdAt.toISOString(),
+ id: cert.assignments.id,
+ status: cert.assignments.status,
+ assignee: cert.assignments.assignee?.username || null,
+ createdAt: cert.assignments.createdAt.toISOString(),
}
: null,
notes: internalNotes.map((note) => ({
diff --git a/sw-dash/src/app/api/admin/ship_certifications/route.ts b/sw-dash/src/app/api/admin/ship_certifications/route.ts
index d48bca4..da47e78 100644
--- a/sw-dash/src/app/api/admin/ship_certifications/route.ts
+++ b/sw-dash/src/app/api/admin/ship_certifications/route.ts
@@ -1,22 +1,40 @@
import { NextResponse } from 'next/server'
-import { PERMS } from '@/lib/perms'
+import { can, PERMS } from '@/lib/perms'
import { getCerts } from '@/lib/certs'
import { api } from '@/lib/api'
-export const GET = api(PERMS.certs_view)(async ({ req }) => {
+export const GET = api(PERMS.certs_view)(async ({ req, user }) => {
try {
const { searchParams } = new URL(req.url)
+ const returnedOnly = searchParams.get('returned') === '1'
+ if (returnedOnly && !can(user.role, PERMS.captain_dashboard)) {
+ return NextResponse.json(
+ { error: 'Only captains can view returned-by-admin list' },
+ { status: 403 }
+ )
+ }
+
const rawType = searchParams.get('type')
const type = rawType ? rawType.split(',').filter(Boolean) : null
const ftType = searchParams.get('ftType')
- const status = searchParams.get('status')
+ const status = searchParams.get('status') || (returnedOnly ? 'pending' : undefined)
const sortBy = searchParams.get('sortBy') || 'newest'
const lbMode = searchParams.get('lbMode') || 'weekly'
const from = searchParams.get('from')
const to = searchParams.get('to')
const search = searchParams.get('search')
- const data = await getCerts({ type, ftType, status, sortBy, lbMode, from, to, search })
+ const data = await getCerts({
+ type,
+ ftType,
+ status: status || undefined,
+ sortBy,
+ lbMode,
+ from,
+ to,
+ search,
+ returnedOnly: returnedOnly || undefined,
+ })
return NextResponse.json(data)
} catch {
return NextResponse.json({ error: 'shit hit the fan loading certifications' }, { status: 500 })
diff --git a/sw-dash/src/app/api/admin/skills/route.ts b/sw-dash/src/app/api/admin/skills/route.ts
index af69ca3..a710fd7 100644
--- a/sw-dash/src/app/api/admin/skills/route.ts
+++ b/sw-dash/src/app/api/admin/skills/route.ts
@@ -14,7 +14,7 @@ export const GET = api()(async ({ user }) => {
return NextResponse.json({ error: 'user not found' }, { status: 404 })
}
- const userSkills = (dbUser.skills as string[]) || []
+ const userSkills = JSON.parse(dbUser.skills || '[]') as string[]
return NextResponse.json({ skills: userSkills, available: SKILLS })
} catch {
@@ -43,19 +43,19 @@ export const POST = api()(async ({ user, req }) => {
select: { skills: true },
})
- const current = (dbUser?.skills as string[]) || []
+ const current = JSON.parse(dbUser?.skills || '[]') as string[]
if (action === 'add') {
if (!current.includes(skill)) {
await prisma.user.update({
where: { id: user.id },
- data: { skills: [...current, skill] },
+ data: { skills: JSON.stringify([...current, skill]) },
})
}
} else {
await prisma.user.update({
where: { id: user.id },
- data: { skills: current.filter((s) => s !== skill) },
+ data: { skills: JSON.stringify(current.filter((s) => s !== skill)) },
})
}
diff --git a/sw-dash/src/app/api/admin/spot_checks/stats/route.ts b/sw-dash/src/app/api/admin/spot_checks/stats/route.ts
index 8fa9cd0..f0d7e1c 100644
--- a/sw-dash/src/app/api/admin/spot_checks/stats/route.ts
+++ b/sw-dash/src/app/api/admin/spot_checks/stats/route.ts
@@ -80,9 +80,64 @@ export const GET = withParams>(PERMS.spot_check)(async ()
wrights.sort((a, b) => b.reviewed - a.reviewed)
+ const spotCheckStaff = await prisma.spotCheck.groupBy({
+ by: ['staffId'],
+ _count: { id: true },
+ })
+
+ const staffIds = spotCheckStaff.map((s) => s.staffId)
+ if (staffIds.length === 0) {
+ return NextResponse.json({
+ stats: { total, failRate, successRate, checked, unchecked },
+ wrights,
+ topCheckers: [],
+ })
+ }
+
+ const byStaffDecision = await prisma.spotCheck.groupBy({
+ by: ['staffId', 'decision'],
+ _count: { id: true },
+ where: { staffId: { in: staffIds } },
+ })
+
+ const decisionMap = new Map()
+ for (const row of byStaffDecision) {
+ if (!decisionMap.has(row.staffId)) {
+ decisionMap.set(row.staffId, { approved: 0, rejected: 0 })
+ }
+ const d = decisionMap.get(row.staffId)!
+ if (row.decision === 'approved') d.approved = row._count.id
+ else if (row.decision === 'rejected') d.rejected = row._count.id
+ }
+
+ const users = await prisma.user.findMany({
+ where: { id: { in: staffIds } },
+ select: { id: true, username: true, avatar: true },
+ })
+ const userMap = new Map(users.map((u) => [u.id, u]))
+
+ const topCheckers = spotCheckStaff
+ .map((s) => {
+ const user = userMap.get(s.staffId)
+ const decisions = decisionMap.get(s.staffId) || { approved: 0, rejected: 0 }
+ return user
+ ? {
+ id: user.id,
+ username: user.username,
+ avatar: user.avatar,
+ total: s._count.id,
+ approved: decisions.approved,
+ rejected: decisions.rejected,
+ }
+ : null
+ })
+ .filter((s): s is NonNullable => s !== null)
+ .sort((a, b) => b.total - a.total)
+
return NextResponse.json({
stats: { total, failRate, successRate, checked, unchecked },
wrights,
+ topCheckers,
})
} catch (e) {
return NextResponse.json({ error: 'stats load failed' }, { status: 500 })
diff --git a/sw-dash/src/app/api/admin/users/[id]/skills/route.ts b/sw-dash/src/app/api/admin/users/[id]/skills/route.ts
index 6d15b8e..e572cfc 100644
--- a/sw-dash/src/app/api/admin/users/[id]/skills/route.ts
+++ b/sw-dash/src/app/api/admin/users/[id]/skills/route.ts
@@ -27,7 +27,7 @@ export const GET = withParams()(async ({ user, params }) => {
return NextResponse.json({ error: 'who dat' }, { status: 404 })
}
- return NextResponse.json({ skills: target.skills || [] })
+ return NextResponse.json({ skills: JSON.parse(target.skills || '[]') })
} catch {
return NextResponse.json({ error: 'couldnt grab skills' }, { status: 500 })
}
@@ -67,7 +67,7 @@ export const POST = withParams()(async ({ user, req, params, ip, ua }) => {
await prisma.user.update({
where: { id: userId },
- data: { skills: skills },
+ data: { skills: JSON.stringify(skills) },
})
if (!isSelf) {
diff --git a/sw-dash/src/app/api/admin/ysws_reviews/[id]/refresh/route.ts b/sw-dash/src/app/api/admin/ysws_reviews/[id]/refresh/route.ts
index 53d2e3f..9523111 100644
--- a/sw-dash/src/app/api/admin/ysws_reviews/[id]/refresh/route.ts
+++ b/sw-dash/src/app/api/admin/ysws_reviews/[id]/refresh/route.ts
@@ -47,7 +47,7 @@ export const POST = yswsApiWithParams(PERMS.ysws_edit)(async ({ params, user })
.filter((d) => d.created_at)
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
- const existingDecisions = (review.decisions as any[]) || []
+ const existingDecisions = JSON.parse((review.decisions as string) || '[]') as any[]
const devlogs: any[] = []
const commits: any[] = []
@@ -124,9 +124,9 @@ export const POST = yswsApiWithParams(PERMS.ysws_edit)(async ({ params, user })
await prisma.yswsReview.update({
where: { id: yswsId },
data: {
- devlogs: JSON.parse(JSON.stringify(devlogs)),
- commits: JSON.parse(JSON.stringify(commits)),
- decisions: JSON.parse(JSON.stringify(decisions)),
+ devlogs: JSON.stringify(devlogs),
+ commits: JSON.stringify(commits),
+ decisions: JSON.stringify(decisions),
},
})
diff --git a/sw-dash/src/app/api/admin/ysws_reviews/[id]/route.ts b/sw-dash/src/app/api/admin/ysws_reviews/[id]/route.ts
index 31a9b73..b8ec680 100644
--- a/sw-dash/src/app/api/admin/ysws_reviews/[id]/route.ts
+++ b/sw-dash/src/app/api/admin/ysws_reviews/[id]/route.ts
@@ -56,7 +56,7 @@ export const PATCH = yswsApiWithParams(PERMS.ysws_edit)(async ({
status: 'pending',
reviewerId: null,
returnReason: null,
- decisions: [],
+ decisions: '[]',
},
})
@@ -93,7 +93,7 @@ export const PATCH = yswsApiWithParams(PERMS.ysws_edit)(async ({
})
if (!review) return NextResponse.json({ error: 'not found' }, { status: 404 })
- let decisions = (review.decisions as Decision[] | null) || []
+ let decisions = JSON.parse(review.decisions || '[]') as Decision[]
if (updates && Array.isArray(updates)) {
decisions = decisions.map((d) => {
@@ -116,7 +116,7 @@ export const PATCH = yswsApiWithParams(PERMS.ysws_edit)(async ({
status: action === 'complete' ? 'done' : 'returned',
returnReason: action === 'return' ? returnReason : null,
reviewerId: user.id,
- decisions: JSON.parse(JSON.stringify(decisions)),
+ decisions: JSON.stringify(decisions),
},
})
diff --git a/sw-dash/src/lib/captain.ts b/sw-dash/src/lib/captain.ts
new file mode 100644
index 0000000..f3611ec
--- /dev/null
+++ b/sw-dash/src/lib/captain.ts
@@ -0,0 +1,252 @@
+import { prisma } from '@/lib/db'
+
+type ReviewsByWeekRow = {
+ weekStart: Date
+ total: bigint
+ approved: bigint
+ rejected: bigint
+}
+
+type SpotChecksByWeekRow = {
+ weekStart: Date
+ passed: bigint
+ failed: bigint
+}
+
+type ProjectTypeRow = {
+ projectType: string
+ total: bigint
+}
+
+export type MemberActivityData = {
+ user: { id: number; username: string | null; avatar: string | null; role: string }
+ summary: {
+ reviewsThisWeek: number
+ reviewsLastWeek: number
+ reviewsAllTime: number
+ spotChecksPassed: number
+ spotChecksFailed: number
+ spotChecksPassRate: number
+ }
+ reviewsByWeek: { weekStart: string; total: number; approved: number; rejected: number }[]
+ /** One entry per day for the last 12 weeks (up to 84 days), for GitHub-style activity grid */
+ reviewsByDay: { date: string; count: number }[]
+ spotChecksByWeek: { weekStart: string; passed: number; failed: number }[]
+ /** Top project types reviewed in the last 12 weeks */
+ projectTypes: { projectType: string; total: number }[]
+}
+
+type ReviewsByDayRow = {
+ day: Date
+ total: bigint
+}
+
+export async function getMemberActivity(userId: number): Promise {
+ const twelveWeeksAgo = new Date()
+ twelveWeeksAgo.setDate(twelveWeeksAgo.getDate() - 12 * 7)
+
+ const [
+ user,
+ reviewsByWeekRaw,
+ reviewsByDayRaw,
+ spotChecksByWeekRaw,
+ reviewsAllTime,
+ projectTypesRaw,
+ ] = await Promise.all([
+ prisma.user.findUnique({
+ where: { id: userId },
+ select: { id: true, username: true, avatar: true, role: true },
+ }),
+ prisma.$queryRaw`
+ SELECT
+ DATE(DATE_SUB(reviewCompletedAt, INTERVAL WEEKDAY(reviewCompletedAt) DAY)) AS weekStart,
+ COUNT(*) AS total,
+ SUM(status = 'approved') AS approved,
+ SUM(status = 'rejected') AS rejected
+ FROM ship_certs
+ WHERE reviewerId = ${userId}
+ AND status IN ('approved', 'rejected')
+ AND reviewCompletedAt IS NOT NULL
+ AND reviewCompletedAt >= ${twelveWeeksAgo}
+ GROUP BY weekStart
+ ORDER BY weekStart ASC
+ `,
+ prisma.$queryRaw`
+ SELECT DATE(reviewCompletedAt) AS day, COUNT(*) AS total
+ FROM ship_certs
+ WHERE reviewerId = ${userId}
+ AND status IN ('approved', 'rejected')
+ AND reviewCompletedAt IS NOT NULL
+ AND reviewCompletedAt >= ${twelveWeeksAgo}
+ GROUP BY day
+ ORDER BY day ASC
+ `,
+ prisma.$queryRaw`
+ SELECT
+ DATE(DATE_SUB(createdAt, INTERVAL WEEKDAY(createdAt) DAY)) AS weekStart,
+ SUM(decision = 'approved') AS passed,
+ SUM(decision = 'rejected') AS failed
+ FROM spot_checks
+ WHERE reviewerId = ${userId}
+ AND createdAt >= ${twelveWeeksAgo}
+ GROUP BY weekStart
+ ORDER BY weekStart ASC
+ `,
+ prisma.shipCert.count({
+ where: {
+ reviewerId: userId,
+ status: { in: ['approved', 'rejected'] },
+ },
+ }),
+ prisma.$queryRaw`
+ SELECT projectType, COUNT(*) AS total
+ FROM ship_certs
+ WHERE reviewerId = ${userId}
+ AND status IN ('approved', 'rejected')
+ AND reviewCompletedAt >= ${twelveWeeksAgo}
+ AND projectType IS NOT NULL
+ GROUP BY projectType
+ ORDER BY total DESC
+ LIMIT 8
+ `,
+ ])
+
+ if (!user) return null
+
+ const reviewsByWeek = reviewsByWeekRaw.map((r) => ({
+ weekStart: r.weekStart.toISOString().split('T')[0],
+ total: Number(r.total),
+ approved: Number(r.approved),
+ rejected: Number(r.rejected),
+ }))
+
+ const dayCountMap = new Map()
+ for (const r of reviewsByDayRaw) {
+ const key = r.day.toISOString().split('T')[0]
+ dayCountMap.set(key, Number(r.total))
+ }
+ const toDateStr = (d: Date) =>
+ `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
+ const reviewsByDay: { date: string; count: number }[] = []
+ // Start on Sunday so the activity grid shows real calendar weeks (Sun–Sat)
+ const start = new Date(twelveWeeksAgo)
+ start.setHours(0, 0, 0, 0)
+ start.setDate(start.getDate() - start.getDay())
+ for (let i = 0; i < 84; i++) {
+ const d = new Date(start)
+ d.setDate(start.getDate() + i)
+ const key = toDateStr(d)
+ reviewsByDay.push({ date: key, count: dayCountMap.get(key) ?? 0 })
+ }
+
+ const spotChecksByWeek = spotChecksByWeekRaw.map((r) => ({
+ weekStart: r.weekStart.toISOString().split('T')[0],
+ passed: Number(r.passed),
+ failed: Number(r.failed),
+ }))
+
+ const now = new Date()
+ const getMonday = (d: Date) => {
+ const copy = new Date(d.getFullYear(), d.getMonth(), d.getDate())
+ const day = copy.getDay()
+ const diff = day === 0 ? -6 : 1 - day
+ copy.setDate(copy.getDate() + diff)
+ return copy
+ }
+ const toLocalDateStr = (d: Date) =>
+ `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
+ const thisMonday = getMonday(now)
+ const thisMondayStr = toLocalDateStr(thisMonday)
+ const lastMonday = new Date(thisMonday)
+ lastMonday.setDate(lastMonday.getDate() - 7)
+ const lastMondayStr = toLocalDateStr(lastMonday)
+
+ const thisWeekBucket = reviewsByWeek.find((b) => b.weekStart === thisMondayStr)
+ const lastWeekBucket = reviewsByWeek.find((b) => b.weekStart === lastMondayStr)
+ const reviewsThisWeek = thisWeekBucket?.total ?? 0
+ const reviewsLastWeek = lastWeekBucket?.total ?? 0
+
+ const spotChecksAll = spotChecksByWeek.reduce(
+ (a, b) => ({
+ passed: a.passed + b.passed,
+ failed: a.failed + b.failed,
+ }),
+ { passed: 0, failed: 0 }
+ )
+ const spotTotal = spotChecksAll.passed + spotChecksAll.failed
+ const passRate = spotTotal > 0 ? Number(((spotChecksAll.passed / spotTotal) * 100).toFixed(1)) : 0
+
+ const projectTypes = projectTypesRaw.map((r) => ({
+ projectType: r.projectType,
+ total: Number(r.total),
+ }))
+
+ return {
+ user: {
+ id: user.id,
+ username: user.username,
+ avatar: user.avatar,
+ role: user.role,
+ },
+ summary: {
+ reviewsThisWeek,
+ reviewsLastWeek,
+ reviewsAllTime,
+ spotChecksPassed: spotChecksAll.passed,
+ spotChecksFailed: spotChecksAll.failed,
+ spotChecksPassRate: passRate,
+ },
+ reviewsByWeek,
+ reviewsByDay,
+ spotChecksByWeek,
+ projectTypes,
+ }
+}
+
+export type TeamMemberRow = {
+ id: number
+ username: string
+ avatar: string | null
+ totalReviews: number
+ lastReviewAt: string | null
+}
+
+export async function getTeamList(): Promise {
+ const ninetyDaysAgo = new Date()
+ ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90)
+
+ const rows = await prisma.$queryRaw<
+ { reviewerId: number; totalReviews: bigint; lastReviewAt: Date | null }[]
+ >`
+ SELECT
+ reviewerId AS reviewerId,
+ COUNT(*) AS totalReviews,
+ MAX(reviewCompletedAt) AS lastReviewAt
+ FROM ship_certs
+ WHERE reviewerId IS NOT NULL
+ AND status IN ('approved', 'rejected')
+ AND reviewCompletedAt >= ${ninetyDaysAgo}
+ GROUP BY reviewerId
+ ORDER BY totalReviews DESC
+ `
+
+ const ids = rows.map((r) => r.reviewerId)
+ if (ids.length === 0) return []
+
+ const users = await prisma.user.findMany({
+ where: { id: { in: ids } },
+ select: { id: true, username: true, avatar: true },
+ })
+ const byId = new Map(users.map((u) => [u.id, u]))
+
+ return rows.map((r) => {
+ const u = byId.get(r.reviewerId)
+ return {
+ id: r.reviewerId,
+ username: u?.username ?? `User #${r.reviewerId}`,
+ avatar: u?.avatar ?? null,
+ totalReviews: Number(r.totalReviews),
+ lastReviewAt: r.lastReviewAt ? r.lastReviewAt.toISOString() : null,
+ }
+ })
+}
diff --git a/sw-dash/src/lib/certs.ts b/sw-dash/src/lib/certs.ts
index 0f31c5d..cd1086e 100644
--- a/sw-dash/src/lib/certs.ts
+++ b/sw-dash/src/lib/certs.ts
@@ -10,6 +10,8 @@ interface Filters {
from?: string | null
to?: string | null
search?: string | null
+ /** When true, only certs with yswsReturnedAt set (returned by admin). Requires status pending. */
+ returnedOnly?: boolean
}
type StatsRow = {
@@ -86,12 +88,12 @@ async function fetchStats(lbMode: string) {
ORDER BY date ASC
`,
- // Stats + queue in ONE SQL query
+ // Stats + queue in ONE SQL query (pending = queue only, excludes returned-by-admin)
prisma.$queryRaw`
SELECT
SUM(status = 'approved') AS approved,
SUM(status = 'rejected') AS rejected,
- SUM(status = 'pending') AS pending,
+ SUM(status = 'pending' AND yswsReturnedAt IS NULL) AS pending,
SUM(reviewCompletedAt IS NOT NULL AND reviewCompletedAt >= ${today}) AS decisionsToday,
SUM(createdAt >= ${today}) AS newShipsToday,
SUM(reviewCompletedAt IS NOT NULL AND reviewCompletedAt >= ${yesterday} AND reviewCompletedAt < ${today}) AS decisionsYesterday,
@@ -264,6 +266,13 @@ async function fetchList(filters: Filters) {
if (type && type.length > 0) where.projectType = { in: type }
if (filters.ftType && filters.ftType !== 'all') where.ftType = filters.ftType
if (status && status !== 'all') where.status = status
+ if (status === 'pending') {
+ if (filters.returnedOnly) {
+ where.yswsReturnedAt = { not: null }
+ } else {
+ where.yswsReturnedAt = null
+ }
+ }
if (filters.from || filters.to) {
where.createdAt = {
...(filters.from ? { gte: new Date(filters.from) } : {}),
@@ -276,6 +285,14 @@ async function fetchList(filters: Filters) {
const orderBy = { createdAt: sortBy === 'oldest' ? 'asc' : 'desc' } as const
+ const groupByWhere: Record = {}
+ if (status && status !== 'all') {
+ groupByWhere.status = status
+ if (status === 'pending') {
+ groupByWhere.yswsReturnedAt = filters.returnedOnly ? { not: null } : null
+ }
+ }
+
const [certs, typeGroups] = await Promise.all([
prisma.shipCert.findMany({
where,
@@ -304,7 +321,7 @@ async function fetchList(filters: Filters) {
prisma.shipCert.groupBy({
by: ['projectType'],
- where: status && status !== 'all' ? { status } : {},
+ where: groupByWhere,
_count: true,
}),
])
@@ -363,6 +380,7 @@ async function getList(filters: Filters) {
from: filters.from || null,
to: filters.to || null,
search: filters.search || null,
+ returnedOnly: filters.returnedOnly ? '1' : null,
})
return cache(key, 15, () => fetchList(filters))
}
diff --git a/sw-dash/src/lib/log.ts b/sw-dash/src/lib/log.ts
index 9447eee..16a9286 100644
--- a/sw-dash/src/lib/log.ts
+++ b/sw-dash/src/lib/log.ts
@@ -1,5 +1,4 @@
import { prisma } from './db'
-import { Prisma } from '@prisma/client'
interface User {
id?: number
@@ -56,18 +55,18 @@ export async function log(data: LogData) {
userAgent: data.meta?.ua,
targetId: data.target?.id,
targetType: data.target?.type,
- metadata: (data.meta || null) as Prisma.InputJsonValue,
+ metadata: data.meta ? JSON.stringify(data.meta) : null,
reqMethod: data.req?.method,
reqUrl: data.req?.url,
- reqBody: data.req?.body as Prisma.InputJsonValue,
- reqHeaders: data.req?.headers as Prisma.InputJsonValue,
+ reqBody: data.req?.body !== undefined ? JSON.stringify(data.req.body) : null,
+ reqHeaders: data.req?.headers ? JSON.stringify(data.req.headers) : null,
resStatus: data.res?.status,
- resBody: data.res?.body as Prisma.InputJsonValue,
- resHeaders: data.res?.headers as Prisma.InputJsonValue,
+ resBody: data.res?.body !== undefined ? JSON.stringify(data.res.body) : null,
+ resHeaders: data.res?.headers ? JSON.stringify(data.res.headers) : null,
errorName: data.error?.name,
errorMsg: data.error?.message,
errorStack: data.error?.stack,
- changes: data.changes as Prisma.InputJsonValue,
+ changes: data.changes ? JSON.stringify(data.changes) : null,
},
})
} catch (e) {
|