diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7ee80de --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# IDE / Editor +.idea/ diff --git a/sw-dash/prisma/migrations/20250116010641_remove_unused_hire_fields/migration.sql b/sw-dash/prisma/migrations/20250116010641_remove_unused_hire_fields/migration.sql new file mode 100644 index 0000000..277a4ac --- /dev/null +++ b/sw-dash/prisma/migrations/20250116010641_remove_unused_hire_fields/migration.sql @@ -0,0 +1,2 @@ +-- Migration already applied. Placeholder for history tracking. +SELECT 1; diff --git a/sw-dash/prisma/migrations/20250312000000_captain_role_permissions/migration.sql b/sw-dash/prisma/migrations/20250312000000_captain_role_permissions/migration.sql new file mode 100644 index 0000000..314c4a0 --- /dev/null +++ b/sw-dash/prisma/migrations/20250312000000_captain_role_permissions/migration.sql @@ -0,0 +1,78 @@ +-- AlterTable +ALTER TABLE `users` MODIFY `skills` LONGTEXT NULL; + +-- AlterTable +ALTER TABLE `ysws_reviews` MODIFY `devlogs` LONGTEXT NULL, + MODIFY `commits` LONGTEXT NULL, + MODIFY `decisions` LONGTEXT NULL; + +-- AlterTable +ALTER TABLE `metrics_history` MODIFY `output` LONGTEXT NULL; + +-- AlterTable +ALTER TABLE `sys_logs` MODIFY `metadata` LONGTEXT NULL, + MODIFY `reqBody` LONGTEXT NULL, + MODIFY `reqHeaders` LONGTEXT NULL, + MODIFY `resBody` LONGTEXT NULL, + MODIFY `resHeaders` LONGTEXT NULL, + MODIFY `changes` LONGTEXT NULL; + +-- CreateTable +CREATE TABLE `spot_check_session_certs` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `sessionId` INTEGER NOT NULL, + `certId` INTEGER NOT NULL, + `addedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `spot_check_session_certs_certId_idx`(`certId`), + INDEX `spot_check_session_certs_sessionId_idx`(`sessionId`), + UNIQUE INDEX `spot_check_session_certs_sessionId_certId_key`(`sessionId`, `certId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `spot_check_sessions` ( + `id` INTEGER NOT NULL AUTO_INCREMENT, + `staffId` INTEGER NOT NULL, + `startedAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `pausedAt` DATETIME(3) NULL, + `endedAt` DATETIME(3) NULL, + `totalSecondsAccrued` INTEGER NOT NULL DEFAULT 0, + `status` VARCHAR(191) NOT NULL DEFAULT 'active', + `wrightId` INTEGER NULL, + + INDEX `spot_check_sessions_staffId_idx`(`staffId`), + INDEX `spot_check_sessions_status_idx`(`status`), + INDEX `spot_check_sessions_wrightId_idx`(`wrightId`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateIndex +CREATE INDEX `payout_reqs_adminId_fkey` ON `payout_reqs`(`adminId`); + +-- CreateIndex +CREATE INDEX `sessions_userId_fkey` ON `sessions`(`userId`); + +-- CreateIndex +CREATE INDEX `ship_certs_claimerId_fkey` ON `ship_certs`(`claimerId`); + +-- CreateIndex +CREATE INDEX `ft_submitter_notes_staffId_fkey` ON `ft_submitter_notes`(`staffId`); + +-- CreateIndex +CREATE INDEX `ticket_notes_authorId_fkey` ON `ticket_notes`(`authorId`); + +-- CreateIndex +CREATE INDEX `spot_checks_resolvedBy_fkey` ON `spot_checks`(`resolvedBy`); + +-- AddForeignKey +ALTER TABLE `spot_check_session_certs` ADD CONSTRAINT `spot_check_session_certs_certId_fkey` FOREIGN KEY (`certId`) REFERENCES `ship_certs`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `spot_check_session_certs` ADD CONSTRAINT `spot_check_session_certs_sessionId_fkey` FOREIGN KEY (`sessionId`) REFERENCES `spot_check_sessions`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `spot_check_sessions` ADD CONSTRAINT `spot_check_sessions_staffId_fkey` FOREIGN KEY (`staffId`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `spot_check_sessions` ADD CONSTRAINT `spot_check_sessions_wrightId_fkey` FOREIGN KEY (`wrightId`) REFERENCES `users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/sw-dash/prisma/schema.prisma b/sw-dash/prisma/schema.prisma index d0c0a64..e196994 100644 --- a/sw-dash/prisma/schema.prisma +++ b/sw-dash/prisma/schema.prisma @@ -8,46 +8,45 @@ datasource db { } model User { - id Int @id @default(autoincrement()) - slackId String @unique - ftuid String? @unique - username String - avatar String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - isActive Boolean @default(true) - role String @default("observer") - sessionExpires DateTime? - sessionToken String? - currentChallenge String? - staffNotes String? @db.Text - skills Json? - lastSeen DateTime? - auditLogs AuditLog[] - subscriptions AssignSubsc[] - assignedReviews Assignment[] @relation("AssignedTo") - createdReviews Assignment[] @relation("CreatedBy") - yubikeys Yubikey[] - shipCerts ShipCert[] - claimedShipCerts ShipCert[] @relation("ClaimedBy") - sessions Session[] - pushSubs PushSub[] - ticketNotes TicketNote[] - - cookieBalance Float @default(0) - cookiesEarned Float @default(0) - streak Int @default(0) - lastReviewDate DateTime? @db.Date - payoutReqs PayoutReq[] @relation("PayoutReqs") - payoutApprovals PayoutReq[] @relation("PayoutApprovals") - yswsReviews YswsReview[] @relation("YswsReviewer") - - spotStaff SpotCheck[] @relation("Staff") - spotReviewed SpotCheck[] @relation("Reviewed") - spotResolved SpotCheck[] @relation("Resolved") - swApiKey String? @unique @db.VarChar(64) - - submitterNotes FtSubmitterNote[] + id Int @id @default(autoincrement()) + slackId String @unique + username String + avatar String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + isActive Boolean @default(true) + role String @default("observer") + sessionExpires DateTime? + sessionToken String? + currentChallenge String? + staffNotes String? @db.Text + skills String? @db.LongText + lastSeen DateTime? + cookieBalance Float @default(0) + cookiesEarned Float @default(0) + ftuid String? @unique + lastReviewDate DateTime? @db.Date + streak Int @default(0) + swApiKey String? @unique @db.VarChar(64) + subscriptions AssignSubsc[] + assignedReviews Assignment[] @relation("AssignedTo") + createdReviews Assignment[] @relation("CreatedBy") + auditLogs AuditLog[] + submitterNotes FtSubmitterNote[] + payoutApprovals PayoutReq[] @relation("PayoutApprovals") + payoutReqs PayoutReq[] @relation("PayoutReqs") + pushSubs PushSub[] + sessions Session[] + claimedShipCerts ShipCert[] @relation("ClaimedBy") + shipCerts ShipCert[] + spot_check_sessions_spot_check_sessions_staffIdTousers spot_check_sessions[] @relation("spot_check_sessions_staffIdTousers") + spot_check_sessions_spot_check_sessions_wrightIdTousers spot_check_sessions[] @relation("spot_check_sessions_wrightIdTousers") + spotResolved SpotCheck[] @relation("Resolved") + spotReviewed SpotCheck[] @relation("Reviewed") + spotStaff SpotCheck[] @relation("Staff") + ticketNotes TicketNote[] + yswsReviews YswsReview[] @relation("YswsReviewer") + yubikeys Yubikey[] @@map("users") } @@ -56,9 +55,6 @@ model PayoutReq { id Int @id @default(autoincrement()) userId Int amount Float - bonus Float @default(0) - bonusReason String? - finalAmount Float? balBefore Float balAfter Float? status String @default("pending") @@ -66,12 +62,15 @@ model PayoutReq { proofUrl String? createdAt DateTime @default(now()) approvedAt DateTime? - - user User @relation("PayoutReqs", fields: [userId], references: [id]) - admin User? @relation("PayoutApprovals", fields: [adminId], references: [id]) + bonus Float @default(0) + bonusReason String? + finalAmount Float? + admin User? @relation("PayoutApprovals", fields: [adminId], references: [id]) + user User @relation("PayoutReqs", fields: [userId], references: [id]) @@index([userId]) @@index([status]) + @@index([adminId], map: "payout_reqs_adminId_fkey") @@map("payout_reqs") } @@ -93,12 +92,13 @@ model Session { id String @id @default(cuid()) token String @unique userId Int - user User @relation(fields: [userId], references: [id], onDelete: Cascade) device String? - ip String? createdAt DateTime @default(now()) expiresAt DateTime + ip String? + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + @@index([userId], map: "sessions_userId_fkey") @@map("sessions") } @@ -120,17 +120,17 @@ model Assignment { repoUrl String @db.Text description String? status String @default("pending") - comments String? @db.Text createdAt DateTime @default(now()) updatedAt DateTime @updatedAt assigneeId Int? - projectName String? + comments String? @db.Text demoUrl String? @db.Text + projectName String? shipCertId Int? @unique subscriptions AssignSubsc[] assignee User? @relation("AssignedTo", fields: [assigneeId], references: [id]) - author User @relation("CreatedBy", fields: [userId], references: [id]) shipCert ShipCert? @relation(fields: [shipCertId], references: [id]) + author User @relation("CreatedBy", fields: [userId], references: [id]) @@index([assigneeId]) @@index([userId]) @@ -154,51 +154,47 @@ model AssignSubsc { } model ShipCert { - id Int @id @default(autoincrement()) - ftProjectId String? - ftSlackId String? - ftUsername String? - ftType String? - - projectName String? - projectType String? - description String? @db.Text - demoUrl String? @db.Text - repoUrl String? @db.Text - readmeUrl String? @db.Text - devTime String? - - status String @default("pending") - reviewerId Int? - claimerId Int? - internalNotes String? @db.Text - reviewFeedback String? @db.Text - proofVideoUrl String? - reviewStartedAt DateTime? - reviewCompletedAt DateTime? - syncedToFt Boolean @default(false) - cookiesEarned Float? - payoutMulti Float? - customBounty Float? - aiSummary String? @db.Text - - yswsReturnReason String? @db.Text - yswsReturnedBy String? - yswsReturnedAt DateTime? - - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - reviewer User? @relation(fields: [reviewerId], references: [id]) - claimer User? @relation("ClaimedBy", fields: [claimerId], references: [id]) - assignments Assignment[] - yswsReviews YswsReview[] - spotChecks SpotCheck[] - - spotChecked Boolean @default(false) - spotCheckedAt DateTime? - spotCheckedBy Int? - spotPassed Boolean? - spotRemoved Boolean @default(false) + id Int @id @default(autoincrement()) + ftProjectId String? + ftSlackId String? + ftUsername String? + projectName String? + projectType String? + description String? @db.Text + demoUrl String? @db.Text + repoUrl String? @db.Text + readmeUrl String? @db.Text + devTime String? + status String @default("pending") + reviewerId Int? + internalNotes String? @db.Text + reviewFeedback String? @db.Text + proofVideoUrl String? + reviewStartedAt DateTime? + reviewCompletedAt DateTime? + syncedToFt Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + claimerId Int? + cookiesEarned Float? + payoutMulti Float? + yswsReturnReason String? @db.Text + yswsReturnedAt DateTime? + yswsReturnedBy String? + customBounty Float? + aiSummary String? @db.Text + ftType String? + spotChecked Boolean @default(false) + spotCheckedAt DateTime? + spotCheckedBy Int? + spotPassed Boolean? + spotRemoved Boolean @default(false) + assignments Assignment? + claimer User? @relation("ClaimedBy", fields: [claimerId], references: [id]) + reviewer User? @relation(fields: [reviewerId], references: [id]) + spot_check_session_certs spot_check_session_certs[] + spotChecks SpotCheck[] + yswsReviews YswsReview[] @@index([reviewerId]) @@index([status]) @@ -209,6 +205,7 @@ model ShipCert { @@index([status, projectType]) @@index([status, yswsReturnedAt, createdAt]) @@index([status, reviewCompletedAt, reviewerId]) + @@index([claimerId], map: "ship_certs_claimerId_fkey") @@map("ship_certs") } @@ -223,6 +220,7 @@ model FtSubmitterNote { staff User @relation(fields: [staffId], references: [id]) @@index([slackId]) + @@index([staffId], map: "ft_submitter_notes_staffId_fkey") @@map("ft_submitter_notes") } @@ -232,14 +230,13 @@ model YswsReview { status String @default("pending") reviewerId Int? returnReason String? @db.Text - devlogs Json? - commits Json? - decisions Json? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - - shipCert ShipCert @relation(fields: [shipCertId], references: [id], onDelete: Cascade) - reviewer User? @relation("YswsReviewer", fields: [reviewerId], references: [id]) + devlogs String? @db.LongText + commits String? @db.LongText + decisions String? @db.LongText + reviewer User? @relation("YswsReviewer", fields: [reviewerId], references: [id]) + shipCert ShipCert @relation(fields: [shipCertId], references: [id], onDelete: Cascade) @@index([shipCertId]) @@index([status]) @@ -286,9 +283,9 @@ model Ticket { status Ticket_status? @default(open) createdAt DateTime? @default(now()) @db.Timestamp(0) closedAt DateTime? @db.Timestamp(0) - closedBy String? @db.VarChar(50) userAvatar String? @db.VarChar(500) assignees String? @db.Text + closedBy String? @db.VarChar(50) messages TicketMsg[] notes TicketNote[] @@ -311,17 +308,16 @@ model TicketFeedback { model TicketUser { id Int @id @default(autoincrement()) - userId String @db.VarChar(50) + userId String @unique @db.VarChar(50) isOptedIn Boolean @default(true) - @@unique([userId]) @@map("ticket_users") } model metricsHistory { id Int @id @default(autoincrement()) createdAt DateTime @default(now()) - output Json? + output String? @db.LongText @@map("metrics_history") } @@ -350,52 +346,44 @@ model TicketNote { authorId Int text String @db.Text createdAt DateTime @default(now()) - ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) author User @relation(fields: [authorId], references: [id]) + ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) @@index([ticketId]) + @@index([authorId], map: "ticket_notes_authorId_fkey") @@map("ticket_notes") } -enum Ticket_status { - open - closed -} - model SysLog { - id String @id @default(cuid()) + id String @id @default(cuid()) userId Int? slackId String? username String? role String? action String - context String? @db.Text + context String? @db.Text statusCode Int - ip String? - userAgent String? @db.Text - email String? + createdAt DateTime @default(now()) avatar String? + email String? + ip String? + userAgent String? @db.Text + metadata String? @db.LongText + severity String? @default("info") targetId Int? targetType String? - metadata Json? - severity String? @default("info") // not used anymore, leaving for old logs - + changes String? @db.LongText + duration Int? + errorMsg String? @db.Text + errorName String? + errorStack String? @db.Text + reqBody String? @db.LongText + reqHeaders String? @db.LongText reqMethod String? - reqUrl String? @db.Text - reqBody Json? - reqHeaders Json? + reqUrl String? @db.Text + resBody String? @db.LongText + resHeaders String? @db.LongText resStatus Int? - resBody Json? - resHeaders Json? - - errorName String? - errorMsg String? @db.Text - errorStack String? @db.Text - - changes Json? - duration Int? - - createdAt DateTime @default(now()) @@index([userId]) @@index([action]) @@ -408,34 +396,66 @@ model SysLog { } model SpotCheck { - id Int @id @default(autoincrement()) - caseId String @unique - certId Int + id Int @id @default(autoincrement()) + caseId String @unique staffId Int - reviewerId Int - - decision String - status String @default("unresolved") - - notes String? @db.Text - reasoning String? @db.Text - - lbRemoved Boolean @default(false) - fpReason String? @db.Text - + decision String + status String @default("unresolved") + notes String? @db.Text + reasoning String? @db.Text createdAt DateTime @default(now()) updatedAt DateTime @updatedAt resolvedAt DateTime? resolvedBy Int? - - cert ShipCert @relation(fields: [certId], references: [id]) - staff User @relation("Staff", fields: [staffId], references: [id]) - reviewed User @relation("Reviewed", fields: [reviewerId], references: [id]) - resolver User? @relation("Resolved", fields: [resolvedBy], references: [id]) + certId Int + lbRemoved Boolean @default(false) + reviewerId Int + fpReason String? @db.Text + cert ShipCert @relation(fields: [certId], references: [id]) + resolver User? @relation("Resolved", fields: [resolvedBy], references: [id]) + reviewed User @relation("Reviewed", fields: [reviewerId], references: [id]) + staff User @relation("Staff", fields: [staffId], references: [id]) @@index([certId]) @@index([staffId]) @@index([reviewerId]) @@index([status]) + @@index([resolvedBy], map: "spot_checks_resolvedBy_fkey") @@map("spot_checks") } + +model spot_check_session_certs { + id Int @id @default(autoincrement()) + sessionId Int + certId Int + addedAt DateTime @default(now()) + ship_certs ShipCert @relation(fields: [certId], references: [id], onDelete: Cascade) + spot_check_sessions spot_check_sessions @relation(fields: [sessionId], references: [id], onDelete: Cascade) + + @@unique([sessionId, certId]) + @@index([certId]) + @@index([sessionId]) +} + +model spot_check_sessions { + id Int @id @default(autoincrement()) + staffId Int + startedAt DateTime @default(now()) + pausedAt DateTime? + endedAt DateTime? + totalSecondsAccrued Int @default(0) + status String @default("active") + wrightId Int? + spot_check_session_certs spot_check_session_certs[] + users_spot_check_sessions_staffIdTousers User @relation("spot_check_sessions_staffIdTousers", fields: [staffId], references: [id], onDelete: Cascade) + users_spot_check_sessions_wrightIdTousers User? @relation("spot_check_sessions_wrightIdTousers", fields: [wrightId], references: [id]) + + @@index([staffId]) + @@index([status]) + @@index([wrightId]) +} + +enum Ticket_status { + open + closed +} diff --git a/sw-dash/src/app/admin/captain/page.tsx b/sw-dash/src/app/admin/captain/page.tsx index 28e0dea..215c12b 100644 --- a/sw-dash/src/app/admin/captain/page.tsx +++ b/sw-dash/src/app/admin/captain/page.tsx @@ -17,6 +17,7 @@ type DashboardData = { byReviewer: { reviewerId: number; username: string; total: number; oldCerts: number }[] } backlogCount: number + returnedCount: number } function formatSince(sinceIso: string) { @@ -180,19 +181,24 @@ export default function CaptainPage() { ) : ( @@ -215,6 +221,25 @@ export default function CaptainPage() {

projects still in review

+
+
+ + Returned by admin + + + View → + +
+
+ {data.returnedCount} +
+

+ need captain triage (excluded from main queue) +

+
)} diff --git a/sw-dash/src/app/admin/captain/team/[userId]/activity-grid.tsx b/sw-dash/src/app/admin/captain/team/[userId]/activity-grid.tsx new file mode 100644 index 0000000..a80b924 --- /dev/null +++ b/sw-dash/src/app/admin/captain/team/[userId]/activity-grid.tsx @@ -0,0 +1,235 @@ +'use client' + +import { useMemo, useState, useCallback } from 'react' + +type DayData = { date: string; count: number } + +const DAY_LABELS = ['S', 'M', 'T', 'W', 'T', 'F', 'S'] +const WEEKS = 12 +const DAYS_PER_WEEK = 7 + +function toDateStr(d: Date) { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} + +function formatWeekLabel(dateStr: string) { + const d = new Date(dateStr + 'T12:00:00') + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +} + +function monthLabelForWeek(weekStart: string, index: number, allWeekStarts: string[]): string { + const d = new Date(weekStart + 'T12:00:00') + if (index === 0) return d.toLocaleDateString('en-US', { month: 'short' }) + const prev = new Date(allWeekStarts[index - 1] + 'T12:00:00') + if (d.getMonth() !== prev.getMonth()) return d.toLocaleDateString('en-US', { month: 'short' }) + return '' +} + +export function ReviewActivityGrid({ data }: { data: DayData[] }) { + const byDate = useMemo(() => { + const m = new Map() + for (const { date, count } of data) m.set(date, count) + return m + }, [data]) + + const { weekStarts, gridByWeek } = useMemo(() => { + if (data.length === 0) { + return { + weekStarts: [] as string[], + gridByWeek: [] as { weekStart: string; dayOfWeek: number; date: string; count: number }[][], + } + } + const first = data[0].date + const start = new Date(first + 'T12:00:00') + const weekStarts: string[] = [] + const gridByWeek: { weekStart: string; dayOfWeek: number; date: string; count: number }[][] = [] + for (let w = 0; w < WEEKS; w++) { + const weekStart = new Date(start) + weekStart.setDate(start.getDate() + w * 7) + const weekStartStr = toDateStr(weekStart) + weekStarts.push(weekStartStr) + const week: { weekStart: string; dayOfWeek: number; date: string; count: number }[] = [] + for (let d = 0; d < DAYS_PER_WEEK; d++) { + const cellDate = new Date(weekStart) + cellDate.setDate(weekStart.getDate() + d) + const dateStr = toDateStr(cellDate) + week.push({ + weekStart: weekStartStr, + dayOfWeek: d, + date: dateStr, + count: byDate.get(dateStr) ?? 0, + }) + } + gridByWeek.push(week) + } + return { weekStarts, gridByWeek } + }, [data, byDate]) + + const [selectedWeekIndex, setSelectedWeekIndex] = useState( + weekStarts.length > 0 ? weekStarts.length - 1 : 0 + ) + const [hoverCell, setHoverCell] = useState<{ + date: string + count: number + weekTotal: number + } | null>(null) + + const selectedWeekStart = weekStarts[selectedWeekIndex] ?? null + const weekTotals = useMemo(() => { + const totals = new Map() + for (const week of gridByWeek) { + let t = 0 + for (const cell of week) t += cell.count + totals.set(week[0].weekStart, t) + } + return totals + }, [gridByWeek]) + + const totalReviews = useMemo(() => data.reduce((a, b) => a + b.count, 0), [data]) + + const maxCount = useMemo(() => Math.max(1, ...data.map((d) => d.count)), [data]) + const colorFor = useCallback( + (count: number) => { + if (count <= 0) return 'bg-zinc-700/60' + const level = Math.min(4, Math.ceil((count / maxCount) * 4)) + return ['bg-amber-900/70', 'bg-amber-700/80', 'bg-amber-600', 'bg-amber-500', 'bg-amber-400'][ + level + ] as string + }, + [maxCount] + ) + + if (weekStarts.length === 0) { + return ( +
No activity data
+ ) + } + + const hoverLine = + hoverCell != null + ? `${hoverCell.count} review${hoverCell.count !== 1 ? 's' : ''} on ${hoverCell.date} · ${hoverCell.weekTotal} that week` + : 'Hover a day for details' + + const selectedWeekTotal = selectedWeekStart != null ? (weekTotals.get(selectedWeekStart) ?? 0) : 0 + + return ( +
+ {/* Week selector + week-of + hover line */} +
+
+ + +
+ + Week of {selectedWeekStart != null ? formatWeekLabel(selectedWeekStart) : '—'} + + + {selectedWeekTotal} review{selectedWeekTotal !== 1 ? 's' : ''} that week + + {hoverLine} +
+ + {/* Header row: empty corner + month labels at boundaries, clickable week cells */} +
+
+ {weekStarts.map((weekStart, week) => { + const monthLabel = monthLabelForWeek(weekStart, week, weekStarts) + return ( + + ) + })} +
+ + {/* Grid: 7 rows (days) × 12 columns (weeks) */} +
+ {DAY_LABELS.map((label, dayOfWeek) => ( +
+
+ {label} +
+ {gridByWeek.map((week, weekIdx) => { + const cell = week[dayOfWeek] + if (!cell) return
+ const weekTotal = weekTotals.get(cell.weekStart) ?? 0 + const isSelected = selectedWeekIndex === weekIdx + return ( +
+ ))} +
+ +
+ + {totalReviews} review{totalReviews !== 1 ? 's' : ''} in 12 weeks + + + Less + + {[0, 1, 2, 3, 4].map((i) => ( + + ))} + + More + +
+
+ ) +} diff --git a/sw-dash/src/app/admin/captain/team/[userId]/page.tsx b/sw-dash/src/app/admin/captain/team/[userId]/page.tsx new file mode 100644 index 0000000..abdcf51 --- /dev/null +++ b/sw-dash/src/app/admin/captain/team/[userId]/page.tsx @@ -0,0 +1,151 @@ +import { redirect, notFound } from 'next/navigation' +import Link from 'next/link' +import Image from 'next/image' +import { getUser } from '@/lib/server-auth' +import { can, PERMS } from '@/lib/perms' +import { cache, genKey } from '@/lib/cache' +import { getMemberActivity } from '@/lib/captain' +import { ErrorBanner } from '@/components/admin/error-banner' +import { ReviewActivityGrid } from './activity-grid' + +const CACHE_TTL = 90 + +interface Props { + params: Promise<{ userId: string }> +} + +export default async function CaptainTeamMemberPage({ params }: Props) { + const user = await getUser() + if (!user) redirect('/') + if (!can(user.role, PERMS.captain_dashboard)) redirect('/admin') + + const { userId: userIdParam } = await params + const userId = parseInt(userIdParam, 10) + if (isNaN(userId) || userId <= 0) notFound() + + const cacheKey = genKey('captain-team-member', { userId: String(userId) }) + const data = await cache(cacheKey, CACHE_TTL, () => getMemberActivity(userId)) + + if (!data) notFound() + + const { summary, reviewsByDay, projectTypes } = data + const diff = summary.reviewsThisWeek - summary.reviewsLastWeek + const comparison = + diff > 0 + ? `↑ ${diff} from last week` + : diff < 0 + ? `↓ ${Math.abs(diff)} from last week` + : 'same as last week' + const maxProjectTypeCount = projectTypes[0]?.total ?? 1 + + return ( +
+ + +
+ {/* Same nav pattern as captain dashboard: section label + links */} +
+

+ Captain → Team +

+
+ + ← Overview + + · + + Team + +
+
+ +
+ {/* Card 1: Who + primary KPI (same pattern as dashboard "projects reviewed" card) */} +
+
+ {data.user.avatar && ( + + )} +
+

+ {data.user.username ?? `User #${data.user.id}`} +

+

+ {data.user.role} +

+
+
+

+ Reviews this week +

+
+ + {summary.reviewsThisWeek} + + {comparison} +
+
+ + {/* Card 2: GitHub-style activity grid (last 12 weeks) */} +
+
+ + Review activity + + last 12 weeks +
+ +
+ + {/* Card 3: Project types reviewed in the last 12 weeks */} +
+
+ + Project types + + last 12 weeks +
+ {projectTypes.length === 0 ? ( +

No reviews in this period

+ ) : ( +
    + {projectTypes.map(({ projectType, total }) => ( +
  • + + {projectType} + +
    +
    +
    + + {total} + +
  • + ))} +
+ )} +
+
+
+
+ ) +} diff --git a/sw-dash/src/app/admin/captain/team/[userId]/reviews-chart.tsx b/sw-dash/src/app/admin/captain/team/[userId]/reviews-chart.tsx new file mode 100644 index 0000000..cd1a7a1 --- /dev/null +++ b/sw-dash/src/app/admin/captain/team/[userId]/reviews-chart.tsx @@ -0,0 +1,122 @@ +'use client' + +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + ResponsiveContainer, + CartesianGrid, + Legend, +} from 'recharts' + +type WeekBucket = { weekStart: string; total: number; approved: number; rejected: number } + +function formatWeek(weekStart: string) { + const d = new Date(weekStart + 'T12:00:00') + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +} + +function CustomTooltip({ active, payload, label }: any) { + if (!active || !payload?.length) return null + const p = payload[0].payload as WeekBucket + return ( +
+

{formatWeek(label)}

+

Approved: {p.approved}

+

Rejected: {p.rejected}

+

Total: {p.total}

+
+ ) +} + +export function ReviewsByWeekChart({ data }: { data: WeekBucket[] }) { + if (!data.length) { + return ( +
+ No reviews in the last 4 weeks +
+ ) + } + const axis = { stroke: '#78716c', style: { fontSize: '10px' } } + return ( + + + + + + } cursor={{ fill: 'rgba(245,158,11,0.1)' }} /> + Approved / Rejected} + /> + + + + + ) +} + +type SpotWeekBucket = { weekStart: string; passed: number; failed: number } + +function SpotTooltip({ active, payload, label }: any) { + if (!active || !payload?.length) return null + const p = payload[0].payload as SpotWeekBucket + return ( +
+

{formatWeek(label)}

+

Passed: {p.passed}

+

Failed: {p.failed}

+
+ ) +} + +export function SpotChecksByWeekChart({ data }: { data: SpotWeekBucket[] }) { + if (!data.length) { + return ( +
+ No spot checks in the last 12 weeks +
+ ) + } + const axis = { stroke: '#78716c', style: { fontSize: '10px' } } + return ( + + + + + + } cursor={{ fill: 'rgba(245,158,11,0.1)' }} /> + + + + + ) +} diff --git a/sw-dash/src/app/admin/captain/team/page.tsx b/sw-dash/src/app/admin/captain/team/page.tsx index c331f1c..f2f82e2 100644 --- a/sw-dash/src/app/admin/captain/team/page.tsx +++ b/sw-dash/src/app/admin/captain/team/page.tsx @@ -1,14 +1,33 @@ import { redirect } from 'next/navigation' import Link from 'next/link' +import Image from 'next/image' import { getUser } from '@/lib/server-auth' import { can, PERMS } from '@/lib/perms' +import { cache, genKey } from '@/lib/cache' +import { getTeamList } from '@/lib/captain' import { ErrorBanner } from '@/components/admin/error-banner' +const CACHE_TTL = 90 + +function formatLastReview(iso: string | null) { + if (!iso) return '—' + const d = new Date(iso) + const now = new Date() + const diffDays = Math.floor((now.getTime() - d.getTime()) / (24 * 60 * 60 * 1000)) + if (diffDays === 0) return 'Today' + if (diffDays === 1) return 'Yesterday' + if (diffDays < 7) return `${diffDays} days ago` + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) +} + export default async function CaptainTeamPage() { const user = await getUser() if (!user) redirect('/') if (!can(user.role, PERMS.captain_dashboard)) redirect('/admin') + const cacheKey = genKey('captain-team-list', {}) + const members = await cache(cacheKey, CACHE_TTL, getTeamList) + return (
Captain → Team -
-

- Team view: reviewer list and load. (Coming soon.) -

- - ← Back to Overview - -
+ + ← Back to Overview + +
+ +
+ {members.length === 0 ? ( +
+

No reviewers in the last 90 days.

+
+ ) : ( + members.map((m) => ( + + {m.avatar ? ( + + ) : ( +
+ {m.username.charAt(0).toUpperCase()} +
+ )} +
+

{m.username}

+

+ {m.totalReviews} reviews in last 90 days + {m.lastReviewAt && ( + + · last {formatLastReview(m.lastReviewAt)} + + )} +

+
+ See all activity → + + )) + )}
diff --git a/sw-dash/src/app/admin/ship_certifications/certs-view.tsx b/sw-dash/src/app/admin/ship_certifications/certs-view.tsx index d98d87a..51d4e31 100644 --- a/sw-dash/src/app/admin/ship_certifications/certs-view.tsx +++ b/sw-dash/src/app/admin/ship_certifications/certs-view.tsx @@ -16,6 +16,10 @@ interface Props { leaderboard: Reviewer[] types: TypeCount[] } + /** When true, show "RETURNED BY ADMIN" label plus reason and returned-by. When false, show only "Returned". */ + showReturnedByAdmin?: boolean + /** When true, we are showing only returned-by-admin certs (captain view). Pass returned=1 in API calls. */ + isReturnedView?: boolean } const fmtTime = (secs: number) => { @@ -133,7 +137,7 @@ function MultiSelect({ ) } -export function CertsView({ initial }: Props) { +export function CertsView({ initial, showReturnedByAdmin = false, isReturnedView = false }: Props) { const params = useSearchParams() const router = useRouter() const [ftType, setFtType] = useState(params.get('ftType') || 'all') @@ -168,6 +172,7 @@ export function CertsView({ initial }: Props) { if (selectedTypes.length > 0) p.set('type', selectedTypes.join(',')) if (ftType !== 'all') p.set('ftType', ftType) if (status !== 'all') p.set('status', status) + if (isReturnedView) p.set('returned', '1') p.set('sortBy', sortBy) p.set('lbMode', lbMode) if (search) p.set('search', search) @@ -184,10 +189,11 @@ export function CertsView({ initial }: Props) { } finally { setLoading(false) } - }, [selectedTypes, ftType, status, sortBy, lbMode, search, from, to]) + }, [selectedTypes, ftType, status, sortBy, lbMode, search, from, to, isReturnedView]) const hasUrlFilters = !!( params.get('status') || + params.get('returned') || params.get('ftType') || params.get('type') || params.get('search') || @@ -210,12 +216,13 @@ export function CertsView({ initial }: Props) { if (selectedTypes.length > 0) p.set('type', selectedTypes.join(',')) if (ftType !== 'all') p.set('ftType', ftType) if (status !== 'pending') p.set('status', status) + if (isReturnedView) p.set('returned', '1') if (sortBy !== 'oldest') p.set('sortBy', sortBy) if (search) p.set('search', search) if (from) p.set('from', from) if (to) p.set('to', to) router.replace(`?${p}`, { scroll: false }) - }, [ftType, status, sortBy, search, from, to, selectedTypes]) + }, [ftType, status, sortBy, search, from, to, selectedTypes, isReturnedView]) useEffect(() => { const iv = setInterval(() => setNow(Date.now()), 1000) @@ -521,7 +528,9 @@ export function CertsView({ initial }: Props) { )} {c.yswsReturned ? ( - RETURNED + {showReturnedByAdmin + ? `RETURNED BY ${c.yswsReturnedBy ?? 'admin'}` + : 'RETURNED'} ) : ( - {c.yswsReturned && ( -
-
{c.yswsReturnReason}
-
by {c.yswsReturnedBy}
-
+ {c.yswsReturned && showReturnedByAdmin && c.yswsReturnReason && ( +
{c.yswsReturnReason}
)}
@@ -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) {