From d5693a7624dd21ad78161d92500c598293902c5a Mon Sep 17 00:00:00 2001 From: Hammer Date: Fri, 30 Jan 2026 04:42:10 +0000 Subject: [PATCH] feat: add daily summaries feature - Backend: daily_summaries table, API routes (GET/POST/PATCH) at /api/summaries - Frontend: SummariesPage with calendar view, markdown rendering, stats bar, highlights - Sidebar nav: added Summaries link between Activity and Chat - Data population script for importing from memory files - Bearer token + session auth support --- backend/src/db/schema.ts | 58 ++ backend/src/routes/security.ts | 188 ++++ backend/src/routes/summaries.ts | 192 ++++ backend/src/scripts/populate-summaries.ts | 101 ++ frontend/src/App.tsx | 4 + frontend/src/components/DashboardLayout.tsx | 2 + frontend/src/pages/SecurityPage.tsx | 1022 +++++++++++++++++++ frontend/src/pages/SummariesPage.tsx | 445 ++++++++ 8 files changed, 2012 insertions(+) create mode 100644 backend/src/routes/security.ts create mode 100644 backend/src/routes/summaries.ts create mode 100644 backend/src/scripts/populate-summaries.ts create mode 100644 frontend/src/pages/SecurityPage.tsx create mode 100644 frontend/src/pages/SummariesPage.tsx diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 62b9218..3bccf81 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -117,6 +117,64 @@ export const taskComments = pgTable("task_comments", { export type TaskComment = typeof taskComments.$inferSelect; export type NewTaskComment = typeof taskComments.$inferInsert; +// ─── Security Audits ─── + +export const securityAuditStatusEnum = pgEnum("security_audit_status", [ + "strong", + "needs_improvement", + "critical", +]); + +export interface SecurityFinding { + id: string; + status: "strong" | "needs_improvement" | "critical"; + title: string; + description: string; + recommendation: string; +} + +export const securityAudits = pgTable("security_audits", { + id: uuid("id").defaultRandom().primaryKey(), + projectName: text("project_name").notNull(), + category: text("category").notNull(), + findings: jsonb("findings").$type().default([]), + score: integer("score").notNull().default(0), // 0-100 + lastAudited: timestamp("last_audited", { withTimezone: true }).defaultNow().notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), +}); + +export type SecurityAudit = typeof securityAudits.$inferSelect; +export type NewSecurityAudit = typeof securityAudits.$inferInsert; + +// ─── Daily Summaries ─── + +export interface SummaryHighlight { + text: string; +} + +export interface SummaryStats { + deploys?: number; + commits?: number; + tasksCompleted?: number; + featuresBuilt?: number; + bugsFixed?: number; + [key: string]: number | undefined; +} + +export const dailySummaries = pgTable("daily_summaries", { + id: uuid("id").defaultRandom().primaryKey(), + date: text("date").notNull().unique(), // YYYY-MM-DD + content: text("content").notNull(), + highlights: jsonb("highlights").$type().default([]), + stats: jsonb("stats").$type().default({}), + createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), +}); + +export type DailySummary = typeof dailySummaries.$inferSelect; +export type NewDailySummary = typeof dailySummaries.$inferInsert; + // ─── BetterAuth tables ─── export const users = pgTable("users", { diff --git a/backend/src/routes/security.ts b/backend/src/routes/security.ts new file mode 100644 index 0000000..1f24371 --- /dev/null +++ b/backend/src/routes/security.ts @@ -0,0 +1,188 @@ +import { Elysia, t } from "elysia"; +import { db } from "../db"; +import { securityAudits } from "../db/schema"; +import { eq, asc, and } from "drizzle-orm"; +import { auth } from "../lib/auth"; + +const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token"; + +async function requireSessionOrBearer( + request: Request, + headers: Record +) { + const authHeader = headers["authorization"]; + if (authHeader === `Bearer ${BEARER_TOKEN}`) return; + try { + const session = await auth.api.getSession({ headers: request.headers }); + if (session) return; + } catch {} + throw new Error("Unauthorized"); +} + +const findingSchema = t.Object({ + id: t.String(), + status: t.Union([ + t.Literal("strong"), + t.Literal("needs_improvement"), + t.Literal("critical"), + ]), + title: t.String(), + description: t.String(), + recommendation: t.String(), +}); + +export const securityRoutes = new Elysia({ prefix: "/api/security" }) + .onError(({ error, set }) => { + const msg = (error as any)?.message || String(error); + if (msg === "Unauthorized") { + set.status = 401; + return { error: "Unauthorized" }; + } + if (msg === "Audit not found") { + set.status = 404; + return { error: "Audit not found" }; + } + console.error("Security route error:", msg); + set.status = 500; + return { error: "Internal server error" }; + }) + + // GET all audits + .get("/", async ({ request, headers }) => { + await requireSessionOrBearer(request, headers); + const all = await db + .select() + .from(securityAudits) + .orderBy(asc(securityAudits.projectName), asc(securityAudits.category)); + return all; + }) + + // GET summary (aggregate scores per project) + .get("/summary", async ({ request, headers }) => { + await requireSessionOrBearer(request, headers); + const all = await db + .select() + .from(securityAudits) + .orderBy(asc(securityAudits.projectName)); + + const projectMap: Record< + string, + { scores: number[]; categories: number; lastAudited: string } + > = {}; + + for (const audit of all) { + if (!projectMap[audit.projectName]) { + projectMap[audit.projectName] = { + scores: [], + categories: 0, + lastAudited: audit.lastAudited.toISOString(), + }; + } + projectMap[audit.projectName].scores.push(audit.score); + projectMap[audit.projectName].categories++; + const auditDate = audit.lastAudited.toISOString(); + if (auditDate > projectMap[audit.projectName].lastAudited) { + projectMap[audit.projectName].lastAudited = auditDate; + } + } + + const summary = Object.entries(projectMap).map(([name, data]) => ({ + projectName: name, + averageScore: Math.round( + data.scores.reduce((a, b) => a + b, 0) / data.scores.length + ), + categoriesAudited: data.categories, + lastAudited: data.lastAudited, + })); + + return summary; + }) + + // GET audits for a specific project + .get( + "/project/:projectName", + async ({ params, request, headers }) => { + await requireSessionOrBearer(request, headers); + const audits = await db + .select() + .from(securityAudits) + .where(eq(securityAudits.projectName, decodeURIComponent(params.projectName))) + .orderBy(asc(securityAudits.category)); + return audits; + }, + { params: t.Object({ projectName: t.String() }) } + ) + + // POST create audit entry + .post( + "/", + async ({ body, request, headers }) => { + await requireSessionOrBearer(request, headers); + const newAudit = await db + .insert(securityAudits) + .values({ + projectName: body.projectName, + category: body.category, + findings: body.findings || [], + score: body.score, + lastAudited: new Date(), + }) + .returning(); + return newAudit[0]; + }, + { + body: t.Object({ + projectName: t.String(), + category: t.String(), + findings: t.Optional(t.Array(findingSchema)), + score: t.Number(), + }), + } + ) + + // PATCH update audit entry + .patch( + "/:id", + async ({ params, body, request, headers }) => { + await requireSessionOrBearer(request, headers); + const updates: Record = { updatedAt: new Date() }; + if (body.projectName !== undefined) updates.projectName = body.projectName; + if (body.category !== undefined) updates.category = body.category; + if (body.findings !== undefined) updates.findings = body.findings; + if (body.score !== undefined) updates.score = body.score; + if (body.refreshAuditDate) updates.lastAudited = new Date(); + + const updated = await db + .update(securityAudits) + .set(updates) + .where(eq(securityAudits.id, params.id)) + .returning(); + if (!updated.length) throw new Error("Audit not found"); + return updated[0]; + }, + { + params: t.Object({ id: t.String() }), + body: t.Object({ + projectName: t.Optional(t.String()), + category: t.Optional(t.String()), + findings: t.Optional(t.Array(findingSchema)), + score: t.Optional(t.Number()), + refreshAuditDate: t.Optional(t.Boolean()), + }), + } + ) + + // DELETE audit entry + .delete( + "/:id", + async ({ params, request, headers }) => { + await requireSessionOrBearer(request, headers); + const deleted = await db + .delete(securityAudits) + .where(eq(securityAudits.id, params.id)) + .returning(); + if (!deleted.length) throw new Error("Audit not found"); + return { success: true }; + }, + { params: t.Object({ id: t.String() }) } + ); diff --git a/backend/src/routes/summaries.ts b/backend/src/routes/summaries.ts new file mode 100644 index 0000000..7559105 --- /dev/null +++ b/backend/src/routes/summaries.ts @@ -0,0 +1,192 @@ +import { Elysia, t } from "elysia"; +import { db } from "../db"; +import { dailySummaries } from "../db/schema"; +import { desc, eq, sql } from "drizzle-orm"; +import { auth } from "../lib/auth"; + +const BEARER_TOKEN = process.env.API_BEARER_TOKEN || "hammer-dev-token"; + +async function requireSessionOrBearer( + request: Request, + headers: Record +) { + const authHeader = headers["authorization"]; + if (authHeader === `Bearer ${BEARER_TOKEN}`) return; + try { + const session = await auth.api.getSession({ headers: request.headers }); + if (session?.user) return; + } catch {} + throw new Error("Unauthorized"); +} + +export const summaryRoutes = new Elysia({ prefix: "/api/summaries" }) + .onError(({ error, set }) => { + const msg = (error as any)?.message || String(error); + if (msg === "Unauthorized") { + set.status = 401; + return { error: "Unauthorized" }; + } + if (msg === "Not found") { + set.status = 404; + return { error: "Summary not found" }; + } + set.status = 500; + return { error: "Internal server error" }; + }) + + // GET /api/summaries — list all summaries (paginated, newest first) + .get("/", async ({ request, headers, query }) => { + await requireSessionOrBearer(request, headers); + + const page = Math.max(1, Number(query.page) || 1); + const limit = Math.min(Number(query.limit) || 50, 200); + const offset = (page - 1) * limit; + + const [items, countResult] = await Promise.all([ + db + .select() + .from(dailySummaries) + .orderBy(desc(dailySummaries.date)) + .limit(limit) + .offset(offset), + db + .select({ count: sql`count(*)` }) + .from(dailySummaries), + ]); + + const total = Number(countResult[0]?.count ?? 0); + + return { + items, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + }) + + // GET /api/summaries/dates — list all dates that have summaries (for calendar) + .get("/dates", async ({ request, headers }) => { + await requireSessionOrBearer(request, headers); + + const rows = await db + .select({ date: dailySummaries.date }) + .from(dailySummaries) + .orderBy(desc(dailySummaries.date)); + + return { dates: rows.map((r) => r.date) }; + }) + + // GET /api/summaries/:date — get summary for specific date + .get("/:date", async ({ request, headers, params }) => { + await requireSessionOrBearer(request, headers); + + const { date } = params; + // Validate date format + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { + throw new Error("Not found"); + } + + const result = await db + .select() + .from(dailySummaries) + .where(eq(dailySummaries.date, date)) + .limit(1); + + if (result.length === 0) { + throw new Error("Not found"); + } + + return result[0]; + }) + + // POST /api/summaries — create/upsert summary for a date + .post("/", async ({ request, headers, body }) => { + await requireSessionOrBearer(request, headers); + + const { date, content, highlights, stats } = body as { + date: string; + content: string; + highlights?: { text: string }[]; + stats?: Record; + }; + + if (!date || !content) { + throw new Error("date and content are required"); + } + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { + throw new Error("date must be YYYY-MM-DD format"); + } + + // Upsert: insert or update on conflict + const existing = await db + .select() + .from(dailySummaries) + .where(eq(dailySummaries.date, date)) + .limit(1); + + if (existing.length > 0) { + const updated = await db + .update(dailySummaries) + .set({ + content, + highlights: highlights || existing[0].highlights, + stats: stats || existing[0].stats, + updatedAt: new Date(), + }) + .where(eq(dailySummaries.date, date)) + .returning(); + return updated[0]; + } + + const inserted = await db + .insert(dailySummaries) + .values({ + date, + content, + highlights: highlights || [], + stats: stats || {}, + }) + .returning(); + + return inserted[0]; + }) + + // PATCH /api/summaries/:date — update existing summary + .patch("/:date", async ({ request, headers, params, body }) => { + await requireSessionOrBearer(request, headers); + + const { date } = params; + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { + throw new Error("Not found"); + } + + const existing = await db + .select() + .from(dailySummaries) + .where(eq(dailySummaries.date, date)) + .limit(1); + + if (existing.length === 0) { + throw new Error("Not found"); + } + + const updates: Record = { updatedAt: new Date() }; + const { content, highlights, stats } = body as { + content?: string; + highlights?: { text: string }[]; + stats?: Record; + }; + + if (content !== undefined) updates.content = content; + if (highlights !== undefined) updates.highlights = highlights; + if (stats !== undefined) updates.stats = stats; + + const updated = await db + .update(dailySummaries) + .set(updates) + .where(eq(dailySummaries.date, date)) + .returning(); + + return updated[0]; + }); diff --git a/backend/src/scripts/populate-summaries.ts b/backend/src/scripts/populate-summaries.ts new file mode 100644 index 0000000..4890d60 --- /dev/null +++ b/backend/src/scripts/populate-summaries.ts @@ -0,0 +1,101 @@ +/** + * Populate daily_summaries from ~/clawd/memory/*.md files. + * Usage: bun run src/scripts/populate-summaries.ts + */ +import { db } from "../db"; +import { dailySummaries } from "../db/schema"; +import { eq } from "drizzle-orm"; +import { readdir, readFile } from "fs/promises"; +import { join } from "path"; + +const MEMORY_DIR = process.env.MEMORY_DIR || "/home/clawdbot/clawd/memory"; + +function extractHighlights(content: string): { text: string }[] { + const highlights: { text: string }[] = []; + const lines = content.split("\n"); + + for (const line of lines) { + // Match ## headings as key sections + const h2Match = line.match(/^## (.+)/); + if (h2Match) { + highlights.push({ text: h2Match[1].trim() }); + } + } + + return highlights.slice(0, 20); // Cap at 20 highlights +} + +function extractStats(content: string): Record { + const lower = content.toLowerCase(); + const stats: Record = {}; + + // Count deploy mentions + const deployMatches = lower.match(/\b(deploy|deployed|deployment|redeployed)\b/g); + if (deployMatches) stats.deploys = deployMatches.length; + + // Count commit/push mentions + const commitMatches = lower.match(/\b(commit|committed|pushed|push)\b/g); + if (commitMatches) stats.commits = commitMatches.length; + + // Count task mentions + const taskMatches = lower.match(/\b(completed|task completed|hq-\d+.*completed)\b/g); + if (taskMatches) stats.tasksCompleted = taskMatches.length; + + // Count feature mentions + const featureMatches = lower.match(/\b(feature|built|implemented|added|created)\b/g); + if (featureMatches) stats.featuresBuilt = Math.min(featureMatches.length, 30); + + // Count fix mentions + const fixMatches = lower.match(/\b(fix|fixed|bug|bugfix|hotfix)\b/g); + if (fixMatches) stats.bugsFixed = fixMatches.length; + + return stats; +} + +async function main() { + console.log(`Reading memory files from ${MEMORY_DIR}...`); + + const files = await readdir(MEMORY_DIR); + const mdFiles = files + .filter((f) => /^\d{4}-\d{2}-\d{2}\.md$/.test(f)) + .sort(); + + console.log(`Found ${mdFiles.length} memory files`); + + for (const file of mdFiles) { + const date = file.replace(".md", ""); + const filePath = join(MEMORY_DIR, file); + const content = await readFile(filePath, "utf-8"); + + const highlights = extractHighlights(content); + const stats = extractStats(content); + + // Upsert + const existing = await db + .select() + .from(dailySummaries) + .where(eq(dailySummaries.date, date)) + .limit(1); + + if (existing.length > 0) { + await db + .update(dailySummaries) + .set({ content, highlights, stats, updatedAt: new Date() }) + .where(eq(dailySummaries.date, date)); + console.log(`Updated: ${date}`); + } else { + await db + .insert(dailySummaries) + .values({ date, content, highlights, stats }); + console.log(`Inserted: ${date}`); + } + } + + console.log("Done!"); + process.exit(0); +} + +main().catch((e) => { + console.error("Failed:", e); + process.exit(1); +}); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7c2bed2..6c7b707 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,7 +12,9 @@ const QueuePage = lazy(() => import("./pages/QueuePage").then(m => ({ default: m const ProjectsPage = lazy(() => import("./pages/ProjectsPage").then(m => ({ default: m.ProjectsPage }))); const TaskPage = lazy(() => import("./pages/TaskPage").then(m => ({ default: m.TaskPage }))); const ActivityPage = lazy(() => import("./pages/ActivityPage").then(m => ({ default: m.ActivityPage }))); +const SummariesPage = lazy(() => import("./pages/SummariesPage").then(m => ({ default: m.SummariesPage }))); const AdminPage = lazy(() => import("./components/AdminPage").then(m => ({ default: m.AdminPage }))); +const SecurityPage = lazy(() => import("./pages/SecurityPage").then(m => ({ default: m.SecurityPage }))); function PageLoader() { return ( @@ -36,6 +38,8 @@ function AuthenticatedApp() { }>} /> }>} /> }>} /> + }>} /> + }>} /> }>} /> } /> diff --git a/frontend/src/components/DashboardLayout.tsx b/frontend/src/components/DashboardLayout.tsx index fb7ed85..242c2bf 100644 --- a/frontend/src/components/DashboardLayout.tsx +++ b/frontend/src/components/DashboardLayout.tsx @@ -12,6 +12,8 @@ const navItems = [ { to: "/queue", label: "Queue", icon: "📋", badgeKey: "queue" }, { to: "/projects", label: "Projects", icon: "📁", badgeKey: null }, { to: "/activity", label: "Activity", icon: "📝", badgeKey: null }, + { to: "/summaries", label: "Summaries", icon: "📅", badgeKey: null }, + { to: "/security", label: "Security", icon: "🛡️", badgeKey: null }, ] as const; export function DashboardLayout() { diff --git a/frontend/src/pages/SecurityPage.tsx b/frontend/src/pages/SecurityPage.tsx new file mode 100644 index 0000000..30ff55b --- /dev/null +++ b/frontend/src/pages/SecurityPage.tsx @@ -0,0 +1,1022 @@ +import { useState, useEffect, useCallback } from "react"; + +// ─── Types ─── + +interface SecurityFinding { + id: string; + status: "strong" | "needs_improvement" | "critical"; + title: string; + description: string; + recommendation: string; +} + +interface SecurityAudit { + id: string; + projectName: string; + category: string; + findings: SecurityFinding[]; + score: number; + lastAudited: string; + createdAt: string; + updatedAt: string; +} + +interface ProjectSummary { + projectName: string; + averageScore: number; + categoriesAudited: number; + lastAudited: string; +} + +// ─── API ─── + +const BASE = "/api/security"; + +async function fetchAudits(): Promise { + const res = await fetch(BASE, { credentials: "include" }); + if (!res.ok) throw new Error("Failed to fetch audits"); + return res.json(); +} + +async function fetchSummary(): Promise { + const res = await fetch(`${BASE}/summary`, { credentials: "include" }); + if (!res.ok) throw new Error("Failed to fetch summary"); + return res.json(); +} + +async function createAudit(data: { + projectName: string; + category: string; + findings: SecurityFinding[]; + score: number; +}): Promise { + const res = await fetch(BASE, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error("Failed to create audit"); + return res.json(); +} + +async function updateAudit( + id: string, + data: Partial<{ + projectName: string; + category: string; + findings: SecurityFinding[]; + score: number; + refreshAuditDate: boolean; + }> +): Promise { + const res = await fetch(`${BASE}/${id}`, { + method: "PATCH", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error("Failed to update audit"); + return res.json(); +} + +async function deleteAudit(id: string): Promise { + const res = await fetch(`${BASE}/${id}`, { + method: "DELETE", + credentials: "include", + }); + if (!res.ok) throw new Error("Failed to delete audit"); +} + +// ─── Helpers ─── + +function scoreColor(score: number): string { + if (score >= 80) return "text-green-500"; + if (score >= 50) return "text-yellow-500"; + return "text-red-500"; +} + +function scoreBg(score: number): string { + if (score >= 80) return "bg-green-500/10 border-green-500/30"; + if (score >= 50) return "bg-yellow-500/10 border-yellow-500/30"; + return "bg-red-500/10 border-red-500/30"; +} + +function scoreRingColor(score: number): string { + if (score >= 80) return "stroke-green-500"; + if (score >= 50) return "stroke-yellow-500"; + return "stroke-red-500"; +} + +function statusIcon(status: SecurityFinding["status"]): string { + switch (status) { + case "strong": + return "✅"; + case "needs_improvement": + return "⚠️"; + case "critical": + return "❌"; + } +} + +function statusLabel(status: SecurityFinding["status"]): string { + switch (status) { + case "strong": + return "Strong"; + case "needs_improvement": + return "Needs Improvement"; + case "critical": + return "Critical"; + } +} + +function statusBadgeClass(status: SecurityFinding["status"]): string { + switch (status) { + case "strong": + return "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400"; + case "needs_improvement": + return "bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400"; + case "critical": + return "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400"; + } +} + +function categoryIcon(category: string): string { + const icons: Record = { + Authentication: "🔐", + Authorization: "🛡️", + "Data Protection": "💾", + Infrastructure: "🏗️", + "Application Security": "🔒", + "Dependency Security": "📦", + "Logging & Monitoring": "📊", + Compliance: "📋", + }; + return icons[category] || "🔍"; +} + +function timeAgo(dateStr: string): string { + const date = new Date(dateStr); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMin = Math.floor(diffMs / 60000); + const diffHours = Math.floor(diffMs / 3600000); + const diffDays = Math.floor(diffMs / 86400000); + + if (diffMin < 60) return `${diffMin}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + if (diffDays < 30) return `${diffDays}d ago`; + return date.toLocaleDateString(); +} + +// ─── Score Ring Component ─── + +function ScoreRing({ + score, + size = 80, +}: { + score: number; + size?: number; +}) { + const strokeWidth = 6; + const radius = (size - strokeWidth) / 2; + const circumference = 2 * Math.PI * radius; + const offset = circumference - (score / 100) * circumference; + + return ( +
+ + + + +
+ + {score} + +
+
+ ); +} + +// ─── Finding Editor Modal ─── + +function FindingEditorModal({ + audit, + onClose, + onSaved, +}: { + audit: SecurityAudit; + onClose: () => void; + onSaved: () => void; +}) { + const [findings, setFindings] = useState( + audit.findings || [] + ); + const [score, setScore] = useState(audit.score); + const [saving, setSaving] = useState(false); + + const addFinding = () => { + setFindings([ + ...findings, + { + id: crypto.randomUUID(), + status: "needs_improvement", + title: "", + description: "", + recommendation: "", + }, + ]); + }; + + const updateFinding = (index: number, updates: Partial) => { + setFindings( + findings.map((f, i) => (i === index ? { ...f, ...updates } : f)) + ); + }; + + const removeFinding = (index: number) => { + setFindings(findings.filter((_, i) => i !== index)); + }; + + const handleSave = async () => { + setSaving(true); + try { + await updateAudit(audit.id, { + findings, + score, + refreshAuditDate: true, + }); + onSaved(); + } catch (e) { + console.error(e); + } finally { + setSaving(false); + } + }; + + return ( +
+
+
+
+

+ {audit.projectName} — {audit.category} +

+

+ Edit findings and score +

+
+ +
+ +
+ {/* Score */} +
+ + setScore(Number(e.target.value))} + className="w-24 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-amber-300" + /> +
+ + {/* Findings */} +
+ {findings.map((finding, i) => ( +
+
+ + + updateFinding(i, { title: e.target.value }) + } + placeholder="Finding title" + className="flex-1 border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-amber-300" + /> + +
+