Compare commits
6
Commits
v1.1.2
...
3c0233f087
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c0233f087 | ||
|
|
ae2ba4d317 | ||
|
|
dc7ebd6873 | ||
|
|
f593aabd25 | ||
|
|
ea256f33ea | ||
|
|
912b36cbc7 |
@@ -0,0 +1,50 @@
|
|||||||
|
|
||||||
|
import { Kysely, sql } from 'kysely'
|
||||||
|
import { addDefaultColumns } from '../db';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
// TABLE: VERIFICATIONS
|
||||||
|
await db.schema.createTable("verifications")
|
||||||
|
.$call(addDefaultColumns)
|
||||||
|
.addColumn("assessment_id", "bigint", (cb) => cb.notNull().unique())
|
||||||
|
.addColumn("status", "text", (cb) => cb.notNull().defaultTo("request")) // request | in_review | action_requested | completed
|
||||||
|
.addColumn("completed_at", "timestamptz")
|
||||||
|
.addColumn("completed_by", "uuid")
|
||||||
|
.addColumn("url_back_squat", "text")
|
||||||
|
.addColumn("url_deadlift", "text")
|
||||||
|
.addColumn("url_bench_press", "text")
|
||||||
|
.addColumn("url_run", "text")
|
||||||
|
.addColumn("url_broad_jump", "text")
|
||||||
|
.addColumn("url_cone_drill", "text")
|
||||||
|
.addColumn("verf_back_squat", "boolean")
|
||||||
|
.addColumn("verf_deadlift", "boolean")
|
||||||
|
.addColumn("verf_bench_press", "boolean")
|
||||||
|
.addColumn("verf_run", "boolean")
|
||||||
|
.addColumn("verf_broad_jump", "boolean")
|
||||||
|
.addColumn("verf_cone_drill", "boolean")
|
||||||
|
.addColumn("reviewer_notes", "text") // can be structured in the future, for now keep simple
|
||||||
|
.addForeignKeyConstraint(
|
||||||
|
"fk_verifications_assessment_id",
|
||||||
|
["assessment_id"],
|
||||||
|
"assessments",
|
||||||
|
["id"],
|
||||||
|
(cb) => cb.onDelete("cascade")
|
||||||
|
)
|
||||||
|
.addForeignKeyConstraint(
|
||||||
|
"fk_verifications_completed_by",
|
||||||
|
["completed_by"],
|
||||||
|
"accounts",
|
||||||
|
["id"],
|
||||||
|
(cb) => cb.onDelete("restrict")
|
||||||
|
)
|
||||||
|
.addCheckConstraint(
|
||||||
|
"chk_verifications_completed_fields",
|
||||||
|
sql`status != 'completed' OR (completed_at IS NOT NULL AND completed_by IS NOT NULL)`
|
||||||
|
)
|
||||||
|
.execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
// TABLE: VERIFICATIONS
|
||||||
|
await db.schema.dropTable("verifications").ifExists().execute()
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { Activity, Gender, inToCm, lbToKg, minToMs, secToMs } from "@blade-and-brawn/domain";
|
||||||
|
import { db } from "../database/db";
|
||||||
|
import { env, log } from "../util";
|
||||||
|
import type { AccountRole } from "../services/accounts";
|
||||||
|
import { VerificationsService } from "../services/verifications";
|
||||||
|
|
||||||
|
const TEST_DISCORD_ID = "test-verification-seed";
|
||||||
|
const TEST_ASSESSMENT_NAME = "Test Verification Player";
|
||||||
|
|
||||||
|
async function seedTestAccount(): Promise<string> {
|
||||||
|
const existing = await db.selectFrom("accounts")
|
||||||
|
.select("id")
|
||||||
|
.where("discord_id", "=", TEST_DISCORD_ID)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (existing) {
|
||||||
|
log.info({ id: existing.id }, "test account already seeded, reusing");
|
||||||
|
return existing.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await db.insertInto("accounts")
|
||||||
|
.values({
|
||||||
|
discord_id: TEST_DISCORD_ID,
|
||||||
|
name: "Test Player",
|
||||||
|
role: "user" satisfies AccountRole,
|
||||||
|
})
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
log.info({ id: account.id }, "seeded test account");
|
||||||
|
return account.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedTestAssessment(accountId: string): Promise<string> {
|
||||||
|
const existing = await db.selectFrom("assessments")
|
||||||
|
.select("id")
|
||||||
|
.where("account_id", "=", accountId)
|
||||||
|
.where("name", "=", TEST_ASSESSMENT_NAME)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (existing) {
|
||||||
|
log.info({ id: existing.id }, "test assessment already seeded, reusing");
|
||||||
|
return existing.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.insertInto("assessments")
|
||||||
|
.values({
|
||||||
|
account_id: accountId,
|
||||||
|
name: TEST_ASSESSMENT_NAME,
|
||||||
|
gender: Gender.Male,
|
||||||
|
age: 22,
|
||||||
|
weight: lbToKg(180),
|
||||||
|
perf_back_squat: lbToKg(225),
|
||||||
|
perf_deadlift: lbToKg(315),
|
||||||
|
perf_bench_press: lbToKg(185),
|
||||||
|
perf_broad_jump: inToCm(96),
|
||||||
|
perf_run: minToMs(7) + secToMs(30),
|
||||||
|
perf_cone_drill: secToMs(7),
|
||||||
|
})
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
log.info({ id: result.id }, "seeded test assessment");
|
||||||
|
return result.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const answer = prompt(`Seed a test verification request in the database (${env.DATABASE_URL})? (y/N)`);
|
||||||
|
if (answer?.trim().toLowerCase() !== "y") {
|
||||||
|
log.info("Aborted");
|
||||||
|
await db.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const accountId = await seedTestAccount();
|
||||||
|
const assessmentId = await seedTestAssessment(accountId);
|
||||||
|
|
||||||
|
const verifications = new VerificationsService();
|
||||||
|
const verification = await verifications.request(assessmentId, accountId, {
|
||||||
|
[Activity.BackSquat]: "https://example.com/videos/back-squat.mp4",
|
||||||
|
[Activity.Deadlift]: "https://example.com/videos/deadlift.mp4",
|
||||||
|
[Activity.BenchPress]: "https://example.com/videos/bench-press.mp4",
|
||||||
|
[Activity.Run]: "https://example.com/videos/run.mp4",
|
||||||
|
[Activity.BroadJump]: "https://example.com/videos/broad-jump.mp4",
|
||||||
|
[Activity.ConeDrill]: "https://example.com/videos/cone-drill.mp4",
|
||||||
|
});
|
||||||
|
if (!verification) throw new Error("Failed to seed verification request — assessment ownership check failed");
|
||||||
|
|
||||||
|
log.info({ id: verification.id, assessmentId, accountId }, "seeded test verification request");
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
log.error({ err }, "seed failed");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
await db.destroy();
|
||||||
|
}
|
||||||
|
})();
|
||||||
+73
-1
@@ -1,5 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ActivityPerformanceSchema,
|
ActivityPerformanceSchema,
|
||||||
|
ActivityVerificationsSchema,
|
||||||
|
ActivityVideosSchema,
|
||||||
PlayerSchema,
|
PlayerSchema,
|
||||||
} from "@blade-and-brawn/domain"
|
} from "@blade-and-brawn/domain"
|
||||||
import { cors } from "@elysiajs/cors";
|
import { cors } from "@elysiajs/cors";
|
||||||
@@ -23,6 +25,7 @@ import { EventsService, EventStatusSchema } from "./services/events";
|
|||||||
import { AccountsService, type AccountRole } from "./services/accounts";
|
import { AccountsService, type AccountRole } from "./services/accounts";
|
||||||
import { Value } from "@sinclair/typebox/value";
|
import { Value } from "@sinclair/typebox/value";
|
||||||
import { AssessmentsService } from "./services/assessments";
|
import { AssessmentsService } from "./services/assessments";
|
||||||
|
import { VerificationsService, VerificationStatusSchema } from "./services/verifications";
|
||||||
import { Not } from "@sinclair/typebox";
|
import { Not } from "@sinclair/typebox";
|
||||||
|
|
||||||
// CONSTANTS
|
// CONSTANTS
|
||||||
@@ -40,7 +43,8 @@ const s = (() => {
|
|||||||
const Accounts = new AccountsService(Calculator);
|
const Accounts = new AccountsService(Calculator);
|
||||||
const Events = new EventsService();
|
const Events = new EventsService();
|
||||||
const Assessments = new AssessmentsService();
|
const Assessments = new AssessmentsService();
|
||||||
return { Standards, Calculator, Commerce, Accounts, Events, Assessments };
|
const Verifications = new VerificationsService();
|
||||||
|
return { Standards, Calculator, Commerce, Accounts, Events, Assessments, Verifications };
|
||||||
})();
|
})();
|
||||||
|
|
||||||
// QUEUES
|
// QUEUES
|
||||||
@@ -523,6 +527,73 @@ export const app = new Elysia()
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// VERIFICATIONS
|
||||||
|
.group("/verifications", (app) => app
|
||||||
|
.guard({ auth: true }, (app) => app
|
||||||
|
.post("/me", async ({ body: { assessmentId, activityVideoUrls }, accountId }) => {
|
||||||
|
const requested = await s.Verifications.request(assessmentId, accountId, activityVideoUrls);
|
||||||
|
if (!requested) throw new NotFoundError("Assessment not found");
|
||||||
|
return requested;
|
||||||
|
}, {
|
||||||
|
body: t.Object({
|
||||||
|
assessmentId: t.String(),
|
||||||
|
activityVideoUrls: ActivityVideosSchema,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.guard({ authAdmin: true }, (app) => app
|
||||||
|
.get("/", async ({ query }) => {
|
||||||
|
return await s.Verifications.list({
|
||||||
|
filter: { status: query.status },
|
||||||
|
limit: query.limit,
|
||||||
|
offset: query.offset,
|
||||||
|
});
|
||||||
|
}, {
|
||||||
|
query: t.Object({
|
||||||
|
status: t.Optional(VerificationStatusSchema),
|
||||||
|
limit: t.Optional(t.Numeric()),
|
||||||
|
offset: t.Optional(t.Numeric()),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.get("/:id", async ({ params: { id } }) => {
|
||||||
|
const verification = await s.Verifications.get(id);
|
||||||
|
if (!verification) throw new NotFoundError("Verification not found");
|
||||||
|
return verification;
|
||||||
|
}, {
|
||||||
|
params: t.Object({ id: t.String() })
|
||||||
|
})
|
||||||
|
.post("/:id/request-action", async ({ params: { id }, body: { reviewerNotes, activityVerifications } }) => {
|
||||||
|
const verification = await s.Verifications.get(id);
|
||||||
|
if (!verification) throw new NotFoundError("Verification not found");
|
||||||
|
if (verification.status === "completed" || verification.status === "action_requested")
|
||||||
|
throw status(409, { error: "Cannot modify a completed or action-requested verification" });
|
||||||
|
|
||||||
|
const updated = await s.Verifications.requestAction(id, reviewerNotes ?? null, activityVerifications);
|
||||||
|
if (!updated) throw new NotFoundError("Verification not found");
|
||||||
|
}, {
|
||||||
|
params: t.Object({ id: t.String() }),
|
||||||
|
body: t.Object({
|
||||||
|
reviewerNotes: t.Optional(t.String()),
|
||||||
|
activityVerifications: ActivityVerificationsSchema,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.post("/:id/complete", async ({ params: { id }, body: { activityVerifications }, accountId }) => {
|
||||||
|
const verification = await s.Verifications.get(id);
|
||||||
|
if (!verification) throw new NotFoundError("Verification not found");
|
||||||
|
if (verification.status === "completed" || verification.status === "action_requested")
|
||||||
|
throw status(409, { error: "Cannot modify a completed or action-requested verification" });
|
||||||
|
|
||||||
|
const updated = await s.Verifications.complete(id, accountId, activityVerifications);
|
||||||
|
if (!updated) throw new NotFoundError("Verification not found");
|
||||||
|
}, {
|
||||||
|
params: t.Object({ id: t.String() }),
|
||||||
|
body: t.Object({
|
||||||
|
activityVerifications: ActivityVerificationsSchema,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
// WEBHOOKS
|
// WEBHOOKS
|
||||||
.post("/webhooks/printful", async ({ body, query }) => {
|
.post("/webhooks/printful", async ({ body, query }) => {
|
||||||
// https://webflow.com/integrations/printful
|
// https://webflow.com/integrations/printful
|
||||||
@@ -630,3 +701,4 @@ app.listen(3000, async () => {
|
|||||||
|
|
||||||
export type API = typeof app
|
export type API = typeof app
|
||||||
export { type EventStatus } from "./services/events";
|
export { type EventStatus } from "./services/events";
|
||||||
|
export { type VerificationStatus } from "./services/verifications";
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { Activity, type ActivityVerifications, type ActivityVideoUrls } from "@blade-and-brawn/domain";
|
||||||
|
import type { Static } from "@sinclair/typebox";
|
||||||
|
import { t } from "elysia";
|
||||||
|
import { db } from "../database/db";
|
||||||
|
|
||||||
|
export const VerificationStatusSchema = t.Union([
|
||||||
|
t.Literal("request"),
|
||||||
|
t.Literal("in_review"),
|
||||||
|
t.Literal("action_requested"),
|
||||||
|
t.Literal("completed"),
|
||||||
|
]);
|
||||||
|
export type VerificationStatus = Static<typeof VerificationStatusSchema>;
|
||||||
|
|
||||||
|
export class VerificationsService {
|
||||||
|
async request(assessmentId: string, accountId: string, activityVideoUrls: ActivityVideoUrls) {
|
||||||
|
// Only the owning account may request verification of their own assessment.
|
||||||
|
const assessment = await db.selectFrom("assessments")
|
||||||
|
.select("id")
|
||||||
|
.where("id", "=", assessmentId)
|
||||||
|
.where("account_id", "=", accountId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
if (!assessment) return undefined;
|
||||||
|
|
||||||
|
return await db.insertInto("verifications")
|
||||||
|
.values({
|
||||||
|
assessment_id: assessmentId,
|
||||||
|
status: "request" satisfies VerificationStatus,
|
||||||
|
url_back_squat: activityVideoUrls[Activity.BackSquat] ?? null,
|
||||||
|
url_deadlift: activityVideoUrls[Activity.Deadlift] ?? null,
|
||||||
|
url_bench_press: activityVideoUrls[Activity.BenchPress] ?? null,
|
||||||
|
url_broad_jump: activityVideoUrls[Activity.BroadJump] ?? null,
|
||||||
|
url_run: activityVideoUrls[Activity.Run] ?? null,
|
||||||
|
url_cone_drill: activityVideoUrls[Activity.ConeDrill] ?? null,
|
||||||
|
})
|
||||||
|
.onConflict((oc) => oc
|
||||||
|
.column("assessment_id")
|
||||||
|
.doUpdateSet((eb) => ({
|
||||||
|
status: "request" satisfies VerificationStatus,
|
||||||
|
url_back_squat: eb.ref("excluded.url_back_squat"),
|
||||||
|
url_deadlift: eb.ref("excluded.url_deadlift"),
|
||||||
|
url_bench_press: eb.ref("excluded.url_bench_press"),
|
||||||
|
url_broad_jump: eb.ref("excluded.url_broad_jump"),
|
||||||
|
url_run: eb.ref("excluded.url_run"),
|
||||||
|
url_cone_drill: eb.ref("excluded.url_cone_drill"),
|
||||||
|
// Re-requesting means new videos are under review, so the
|
||||||
|
// previous cycle's per-activity judgments and completion
|
||||||
|
// info no longer apply.
|
||||||
|
verf_back_squat: null,
|
||||||
|
verf_deadlift: null,
|
||||||
|
verf_bench_press: null,
|
||||||
|
verf_broad_jump: null,
|
||||||
|
verf_run: null,
|
||||||
|
verf_cone_drill: null,
|
||||||
|
completed_at: null,
|
||||||
|
completed_by: null,
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
.returning("id")
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Joined with the assessment being verified, since reviewing a request
|
||||||
|
// requires seeing the player's claimed performance alongside the videos.
|
||||||
|
private static baseQuery() {
|
||||||
|
return db.selectFrom("verifications")
|
||||||
|
.innerJoin("assessments", "assessments.id", "verifications.assessment_id")
|
||||||
|
.select([
|
||||||
|
"verifications.id",
|
||||||
|
"verifications.assessment_id",
|
||||||
|
"verifications.status",
|
||||||
|
"verifications.created_at",
|
||||||
|
"verifications.completed_at",
|
||||||
|
"verifications.completed_by",
|
||||||
|
"verifications.reviewer_notes",
|
||||||
|
"verifications.url_back_squat",
|
||||||
|
"verifications.url_deadlift",
|
||||||
|
"verifications.url_bench_press",
|
||||||
|
"verifications.url_broad_jump",
|
||||||
|
"verifications.url_run",
|
||||||
|
"verifications.url_cone_drill",
|
||||||
|
"verifications.verf_back_squat",
|
||||||
|
"verifications.verf_deadlift",
|
||||||
|
"verifications.verf_bench_press",
|
||||||
|
"verifications.verf_broad_jump",
|
||||||
|
"verifications.verf_run",
|
||||||
|
"verifications.verf_cone_drill",
|
||||||
|
"assessments.name",
|
||||||
|
"assessments.gender",
|
||||||
|
"assessments.age",
|
||||||
|
"assessments.weight",
|
||||||
|
"assessments.perf_back_squat",
|
||||||
|
"assessments.perf_deadlift",
|
||||||
|
"assessments.perf_bench_press",
|
||||||
|
"assessments.perf_broad_jump",
|
||||||
|
"assessments.perf_run",
|
||||||
|
"assessments.perf_cone_drill",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string) {
|
||||||
|
return await VerificationsService.baseQuery()
|
||||||
|
.where("verifications.id", "=", id)
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(opt: {
|
||||||
|
filter?: { status?: VerificationStatus },
|
||||||
|
limit?: number,
|
||||||
|
offset?: number,
|
||||||
|
} = {}) {
|
||||||
|
return await VerificationsService.baseQuery()
|
||||||
|
.$if(opt.filter?.status !== undefined, (qb) => qb
|
||||||
|
.where("verifications.status", "=", opt.filter!.status!)
|
||||||
|
)
|
||||||
|
.orderBy("verifications.created_at", "desc")
|
||||||
|
.$if(opt.limit !== undefined, (qb) => qb.limit(opt.limit!))
|
||||||
|
.$if(opt.offset !== undefined, (qb) => qb.offset(opt.offset!))
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
async requestAction(id: string, reviewerNotes: string | null, activityVerifications: ActivityVerifications) {
|
||||||
|
const result = await db.updateTable("verifications")
|
||||||
|
.where("id", "=", id)
|
||||||
|
.set({
|
||||||
|
status: "action_requested" satisfies VerificationStatus,
|
||||||
|
reviewer_notes: reviewerNotes,
|
||||||
|
verf_back_squat: activityVerifications[Activity.BackSquat] ?? null,
|
||||||
|
verf_deadlift: activityVerifications[Activity.Deadlift] ?? null,
|
||||||
|
verf_bench_press: activityVerifications[Activity.BenchPress] ?? null,
|
||||||
|
verf_broad_jump: activityVerifications[Activity.BroadJump] ?? null,
|
||||||
|
verf_run: activityVerifications[Activity.Run] ?? null,
|
||||||
|
verf_cone_drill: activityVerifications[Activity.ConeDrill] ?? null,
|
||||||
|
})
|
||||||
|
.executeTakeFirst();
|
||||||
|
return result.numUpdatedRows > 0n;
|
||||||
|
}
|
||||||
|
|
||||||
|
async complete(id: string, completedBy: string, activityVerifications: ActivityVerifications) {
|
||||||
|
const result = await db.updateTable("verifications")
|
||||||
|
.where("id", "=", id)
|
||||||
|
.set({
|
||||||
|
status: "completed" satisfies VerificationStatus,
|
||||||
|
completed_at: new Date(),
|
||||||
|
completed_by: completedBy,
|
||||||
|
verf_back_squat: activityVerifications[Activity.BackSquat] ?? null,
|
||||||
|
verf_deadlift: activityVerifications[Activity.Deadlift] ?? null,
|
||||||
|
verf_bench_press: activityVerifications[Activity.BenchPress] ?? null,
|
||||||
|
verf_broad_jump: activityVerifications[Activity.BroadJump] ?? null,
|
||||||
|
verf_run: activityVerifications[Activity.Run] ?? null,
|
||||||
|
verf_cone_drill: activityVerifications[Activity.ConeDrill] ?? null,
|
||||||
|
})
|
||||||
|
.executeTakeFirst();
|
||||||
|
return result.numUpdatedRows > 0n;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,11 @@
|
|||||||
path: "/activity",
|
path: "/activity",
|
||||||
links: [{ name: "Events", path: "/events" }],
|
links: [{ name: "Events", path: "/events" }],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Verification",
|
||||||
|
path: "/verification",
|
||||||
|
links: [{ name: "Requests", path: "/requests" }],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
let { children } = $props();
|
let { children } = $props();
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { api } from "$lib/api";
|
||||||
|
import { Activity } from "@blade-and-brawn/domain";
|
||||||
|
import type { VerificationStatus } from "@blade-and-brawn/api";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
type VerificationRow = NonNullable<
|
||||||
|
Awaited<ReturnType<typeof api.verifications.get>>["data"]
|
||||||
|
>[number];
|
||||||
|
|
||||||
|
const LIMIT = 25;
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<VerificationStatus, string> = {
|
||||||
|
request: "badge-info",
|
||||||
|
in_review: "badge-warning",
|
||||||
|
action_requested: "badge-error",
|
||||||
|
completed: "badge-success",
|
||||||
|
};
|
||||||
|
|
||||||
|
const URL_COLUMN = {
|
||||||
|
[Activity.BackSquat]: "url_back_squat",
|
||||||
|
[Activity.Deadlift]: "url_deadlift",
|
||||||
|
[Activity.BenchPress]: "url_bench_press",
|
||||||
|
[Activity.Run]: "url_run",
|
||||||
|
[Activity.BroadJump]: "url_broad_jump",
|
||||||
|
[Activity.ConeDrill]: "url_cone_drill",
|
||||||
|
} as const satisfies Record<Activity, keyof VerificationRow>;
|
||||||
|
|
||||||
|
function videoCount(row: VerificationRow): number {
|
||||||
|
return Object.values(Activity).filter((a) => row[URL_COLUMN[a]]).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string | Date | null): string {
|
||||||
|
if (!value) return "—";
|
||||||
|
return new Date(value).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(err: unknown): string {
|
||||||
|
const value = (err as { value?: { error?: string } })?.value;
|
||||||
|
return (
|
||||||
|
value?.error ??
|
||||||
|
(err instanceof Error ? err.message : "Unknown error")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let requests = $state<VerificationRow[]>([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let loadError = $state<string | null>(null);
|
||||||
|
let offset = $state(0);
|
||||||
|
let status = $state<VerificationStatus | "">("request");
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
loading = true;
|
||||||
|
loadError = null;
|
||||||
|
try {
|
||||||
|
const res = await api.verifications.get({
|
||||||
|
query: { status: status || undefined, limit: LIMIT, offset },
|
||||||
|
});
|
||||||
|
if (res.error) throw res.error;
|
||||||
|
requests = res.data as VerificationRow[];
|
||||||
|
} catch (err) {
|
||||||
|
loadError = errorMessage(err);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(value: string) {
|
||||||
|
status = value as VerificationStatus | "";
|
||||||
|
offset = 0;
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevPage() {
|
||||||
|
offset = Math.max(0, offset - LIMIT);
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextPage() {
|
||||||
|
offset += LIMIT;
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(refresh);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="w-full flex items-center gap-2 flex-wrap mt-10 mb-5">
|
||||||
|
<select
|
||||||
|
class="select select-sm w-44"
|
||||||
|
value={status}
|
||||||
|
onchange={(e) => setStatus(e.currentTarget.value)}
|
||||||
|
>
|
||||||
|
<option value="">All statuses</option>
|
||||||
|
<option value="request">Request</option>
|
||||||
|
<option value="action_requested">Action requested</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary btn-sm"
|
||||||
|
onclick={refresh}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? "Refreshing..." : "Refresh"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="btn btn-neutral btn-sm"
|
||||||
|
onclick={prevPage}
|
||||||
|
disabled={offset === 0 || loading}
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-neutral btn-sm"
|
||||||
|
onclick={nextPage}
|
||||||
|
disabled={requests.length < LIMIT || loading}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loadError}
|
||||||
|
<div role="alert" class="alert alert-error w-full mb-4">
|
||||||
|
<span>{loadError}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="w-full overflow-x-auto">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Player</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Videos</th>
|
||||||
|
<th>Requested</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each requests as row (row.id)}
|
||||||
|
<tr class="hover:bg-base-300">
|
||||||
|
<td>{row.name}</td>
|
||||||
|
<td>
|
||||||
|
<div class="badge {STATUS_BADGE[row.status]}">
|
||||||
|
{row.status}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{videoCount(row)} / {Object.values(Activity).length}</td>
|
||||||
|
<td class="whitespace-nowrap">{formatDate(row.created_at)}</td>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
class="btn btn-primary btn-xs"
|
||||||
|
href="/verification/requests/{row.id}"
|
||||||
|
>
|
||||||
|
Review
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{:else}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center opacity-60">
|
||||||
|
{loading ? "Loading..." : "No verification requests"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { api } from "$lib/api";
|
||||||
|
import { Activity, cmToIn, kgToLb, msToTime } from "@blade-and-brawn/domain";
|
||||||
|
import type { ActivityVerifications } from "@blade-and-brawn/domain";
|
||||||
|
import type { VerificationStatus } from "@blade-and-brawn/api";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
|
||||||
|
async function loadVerification(id: string) {
|
||||||
|
return await api.verifications({ id }).get();
|
||||||
|
}
|
||||||
|
|
||||||
|
type VerificationDetail = NonNullable<
|
||||||
|
Awaited<ReturnType<typeof loadVerification>>["data"]
|
||||||
|
>;
|
||||||
|
type VerfChoice = "unset" | "pass" | "fail";
|
||||||
|
|
||||||
|
const ACTIVITIES = Object.values(Activity);
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<VerificationStatus, string> = {
|
||||||
|
request: "badge-info",
|
||||||
|
in_review: "badge-warning",
|
||||||
|
action_requested: "badge-error",
|
||||||
|
completed: "badge-success",
|
||||||
|
};
|
||||||
|
|
||||||
|
const URL_COLUMN = {
|
||||||
|
[Activity.BackSquat]: "url_back_squat",
|
||||||
|
[Activity.Deadlift]: "url_deadlift",
|
||||||
|
[Activity.BenchPress]: "url_bench_press",
|
||||||
|
[Activity.Run]: "url_run",
|
||||||
|
[Activity.BroadJump]: "url_broad_jump",
|
||||||
|
[Activity.ConeDrill]: "url_cone_drill",
|
||||||
|
} as const satisfies Record<Activity, keyof VerificationDetail>;
|
||||||
|
|
||||||
|
const VERF_COLUMN = {
|
||||||
|
[Activity.BackSquat]: "verf_back_squat",
|
||||||
|
[Activity.Deadlift]: "verf_deadlift",
|
||||||
|
[Activity.BenchPress]: "verf_bench_press",
|
||||||
|
[Activity.Run]: "verf_run",
|
||||||
|
[Activity.BroadJump]: "verf_broad_jump",
|
||||||
|
[Activity.ConeDrill]: "verf_cone_drill",
|
||||||
|
} as const satisfies Record<Activity, keyof VerificationDetail>;
|
||||||
|
|
||||||
|
const PERF_COLUMN = {
|
||||||
|
[Activity.BackSquat]: "perf_back_squat",
|
||||||
|
[Activity.Deadlift]: "perf_deadlift",
|
||||||
|
[Activity.BenchPress]: "perf_bench_press",
|
||||||
|
[Activity.Run]: "perf_run",
|
||||||
|
[Activity.BroadJump]: "perf_broad_jump",
|
||||||
|
[Activity.ConeDrill]: "perf_cone_drill",
|
||||||
|
} as const satisfies Record<Activity, keyof VerificationDetail>;
|
||||||
|
|
||||||
|
function formatPerformance(activity: Activity, value: number | null): string {
|
||||||
|
if (value === null) return "—";
|
||||||
|
switch (activity) {
|
||||||
|
case Activity.BackSquat:
|
||||||
|
case Activity.Deadlift:
|
||||||
|
case Activity.BenchPress:
|
||||||
|
return `${Math.round(kgToLb(value))} lb`;
|
||||||
|
case Activity.Run:
|
||||||
|
case Activity.ConeDrill:
|
||||||
|
return msToTime(value, true);
|
||||||
|
case Activity.BroadJump: {
|
||||||
|
const inches = cmToIn(value);
|
||||||
|
const ft = Math.floor(inches / 12);
|
||||||
|
const rem = Math.round((inches - ft * 12) * 2) / 2;
|
||||||
|
return `${ft}' ${rem}"`;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function verfChoiceFrom(value: boolean | null): VerfChoice {
|
||||||
|
if (value === true) return "pass";
|
||||||
|
if (value === false) return "fail";
|
||||||
|
return "unset";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toActivityVerifications(
|
||||||
|
choices: Record<Activity, VerfChoice>,
|
||||||
|
): ActivityVerifications {
|
||||||
|
const result: ActivityVerifications = {};
|
||||||
|
for (const activity of ACTIVITIES) {
|
||||||
|
if (choices[activity] === "pass") result[activity] = true;
|
||||||
|
else if (choices[activity] === "fail") result[activity] = false;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(err: unknown): string {
|
||||||
|
const value = (err as { value?: { error?: string } })?.value;
|
||||||
|
return (
|
||||||
|
value?.error ??
|
||||||
|
(err instanceof Error ? err.message : "Unknown error")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = page.params.id;
|
||||||
|
|
||||||
|
let verification = $state<VerificationDetail | null>(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let loadError = $state<string | null>(null);
|
||||||
|
let stepIndex = $state(0);
|
||||||
|
let notes = $state("");
|
||||||
|
let verf = $state<Record<Activity, VerfChoice>>(
|
||||||
|
Object.fromEntries(ACTIVITIES.map((a) => [a, "unset"])) as Record<
|
||||||
|
Activity,
|
||||||
|
VerfChoice
|
||||||
|
>,
|
||||||
|
);
|
||||||
|
let submitting = $state<"action" | "complete" | undefined>();
|
||||||
|
let submitError = $state<string | null>(null);
|
||||||
|
|
||||||
|
const SUMMARY_STEP = ACTIVITIES.length;
|
||||||
|
const isSummaryStep = $derived(stepIndex === SUMMARY_STEP);
|
||||||
|
const reviewedCount = $derived(
|
||||||
|
ACTIVITIES.filter((a) => verf[a] !== "unset").length,
|
||||||
|
);
|
||||||
|
const allReviewed = $derived(reviewedCount === ACTIVITIES.length);
|
||||||
|
const isReadOnly = $derived(
|
||||||
|
verification?.status === "completed" ||
|
||||||
|
verification?.status === "action_requested",
|
||||||
|
);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
loadError = null;
|
||||||
|
try {
|
||||||
|
const res = await loadVerification(id);
|
||||||
|
if (res.error) throw res.error;
|
||||||
|
if (!res.data) throw new Error("Verification not found");
|
||||||
|
verification = res.data;
|
||||||
|
notes = verification.reviewer_notes ?? "";
|
||||||
|
verf = Object.fromEntries(
|
||||||
|
ACTIVITIES.map((a) => [
|
||||||
|
a,
|
||||||
|
verfChoiceFrom(verification![VERF_COLUMN[a]]),
|
||||||
|
]),
|
||||||
|
) as Record<Activity, VerfChoice>;
|
||||||
|
} catch (err) {
|
||||||
|
loadError = errorMessage(err);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goPrev() {
|
||||||
|
stepIndex = Math.max(0, stepIndex - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goNext() {
|
||||||
|
stepIndex = Math.min(SUMMARY_STEP, stepIndex + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVerf(activity: Activity, choice: VerfChoice) {
|
||||||
|
if (isReadOnly) return;
|
||||||
|
verf[activity] = choice;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestAction() {
|
||||||
|
submitting = "action";
|
||||||
|
submitError = null;
|
||||||
|
try {
|
||||||
|
const res = await api
|
||||||
|
.verifications({ id })
|
||||||
|
["request-action"].post({
|
||||||
|
reviewerNotes: notes || undefined,
|
||||||
|
activityVerifications: toActivityVerifications(verf),
|
||||||
|
});
|
||||||
|
if (res.error) throw res.error;
|
||||||
|
await goto("/verification/requests");
|
||||||
|
} catch (err) {
|
||||||
|
submitError = errorMessage(err);
|
||||||
|
} finally {
|
||||||
|
submitting = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function complete() {
|
||||||
|
submitting = "complete";
|
||||||
|
submitError = null;
|
||||||
|
try {
|
||||||
|
const res = await api.verifications({ id }).complete.post({
|
||||||
|
activityVerifications: toActivityVerifications(verf),
|
||||||
|
});
|
||||||
|
if (res.error) throw res.error;
|
||||||
|
await goto("/verification/requests");
|
||||||
|
} catch (err) {
|
||||||
|
submitError = errorMessage(err);
|
||||||
|
} finally {
|
||||||
|
submitting = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="w-full mt-10 mb-5">
|
||||||
|
<a href="/verification/requests" class="link link-hover text-sm opacity-70"
|
||||||
|
>← Back to requests</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="w-full flex justify-center pt-10">
|
||||||
|
<span class="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
{:else if loadError}
|
||||||
|
<div role="alert" class="alert alert-error w-full mb-4">
|
||||||
|
<span>{loadError}</span>
|
||||||
|
</div>
|
||||||
|
{:else if verification}
|
||||||
|
{@const v = verification}
|
||||||
|
<div class="w-full flex items-center gap-3 flex-wrap mb-6">
|
||||||
|
<h1 class="text-xl font-semibold">{v.name}</h1>
|
||||||
|
<div class="badge {STATUS_BADGE[v.status]}">{v.status}</div>
|
||||||
|
<span class="text-sm opacity-70"
|
||||||
|
>{v.gender} · age {v.age} · {Math.round(kgToLb(v.weight))} lb</span
|
||||||
|
>
|
||||||
|
<div class="flex-1"></div>
|
||||||
|
<span class="text-sm opacity-70"
|
||||||
|
>{reviewedCount} / {ACTIVITIES.length} reviewed</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div role="tablist" class="tabs tabs-border w-full">
|
||||||
|
{#each ACTIVITIES as activity, i (activity)}
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
class="tab gap-2"
|
||||||
|
class:tab-active={stepIndex === i}
|
||||||
|
onclick={() => (stepIndex = i)}
|
||||||
|
>
|
||||||
|
{activity}
|
||||||
|
{#if verf[activity] === "pass"}
|
||||||
|
<span class="text-success">✓</span>
|
||||||
|
{:else if verf[activity] === "fail"}
|
||||||
|
<span class="text-error">✕</span>
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
<button
|
||||||
|
role="tab"
|
||||||
|
class="tab"
|
||||||
|
class:tab-active={isSummaryStep}
|
||||||
|
onclick={() => (stepIndex = SUMMARY_STEP)}
|
||||||
|
>
|
||||||
|
Summary
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !isSummaryStep}
|
||||||
|
{@const activity = ACTIVITIES[stepIndex]}
|
||||||
|
<div class="w-full flex flex-col gap-4 bg-base-200/60 rounded-lg p-6 mt-4">
|
||||||
|
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<h2 class="text-lg font-semibold">{activity}</h2>
|
||||||
|
<span class="text-sm opacity-70"
|
||||||
|
>Claimed: {formatPerformance(
|
||||||
|
activity,
|
||||||
|
v[PERF_COLUMN[activity]],
|
||||||
|
)}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if v[URL_COLUMN[activity]]}
|
||||||
|
{@const videoUrl = v[URL_COLUMN[activity]]}
|
||||||
|
<!-- svelte-ignore a11y_media_has_caption -->
|
||||||
|
<video
|
||||||
|
class="w-full max-h-[480px] rounded-lg bg-black"
|
||||||
|
src={videoUrl}
|
||||||
|
controls
|
||||||
|
preload="metadata"
|
||||||
|
></video>
|
||||||
|
<a
|
||||||
|
class="link link-primary text-xs self-start"
|
||||||
|
href={videoUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Open video in new tab
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<div
|
||||||
|
class="w-full aspect-video rounded-lg bg-base-300 flex items-center justify-center opacity-60"
|
||||||
|
>
|
||||||
|
No video uploaded
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex gap-2 justify-center mt-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-sm {verf[activity] === 'pass'
|
||||||
|
? 'btn-success'
|
||||||
|
: 'btn-outline btn-success'}"
|
||||||
|
disabled={isReadOnly}
|
||||||
|
onclick={() => setVerf(activity, "pass")}
|
||||||
|
>
|
||||||
|
Pass
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm {verf[activity] === 'fail'
|
||||||
|
? 'btn-error'
|
||||||
|
: 'btn-outline btn-error'}"
|
||||||
|
disabled={isReadOnly}
|
||||||
|
onclick={() => setVerf(activity, "fail")}
|
||||||
|
>
|
||||||
|
Fail
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-ghost"
|
||||||
|
disabled={isReadOnly || verf[activity] === "unset"}
|
||||||
|
onclick={() => setVerf(activity, "unset")}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between mt-2">
|
||||||
|
<button
|
||||||
|
class="btn btn-neutral btn-sm"
|
||||||
|
onclick={goPrev}
|
||||||
|
disabled={stepIndex === 0}
|
||||||
|
>
|
||||||
|
← Previous
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-neutral btn-sm" onclick={goNext}>
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="w-full flex flex-col gap-4 bg-base-200/60 rounded-lg p-6 mt-4">
|
||||||
|
<h2 class="text-lg font-semibold">Review summary</h2>
|
||||||
|
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="table table-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Activity</th>
|
||||||
|
<th>Claimed</th>
|
||||||
|
<th>Video</th>
|
||||||
|
<th>Result</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each ACTIVITIES as activity, i (activity)}
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
class="link link-hover"
|
||||||
|
onclick={() => (stepIndex = i)}
|
||||||
|
>
|
||||||
|
{activity}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{formatPerformance(
|
||||||
|
activity,
|
||||||
|
v[PERF_COLUMN[activity]],
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{#if v[URL_COLUMN[activity]]}
|
||||||
|
<a
|
||||||
|
class="link link-primary"
|
||||||
|
href={v[URL_COLUMN[activity]]}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<span class="opacity-50">None</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{#if verf[activity] === "pass"}
|
||||||
|
<span class="badge badge-success"
|
||||||
|
>Pass</span
|
||||||
|
>
|
||||||
|
{:else if verf[activity] === "fail"}
|
||||||
|
<span class="badge badge-error"
|
||||||
|
>Fail</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span class="badge badge-ghost"
|
||||||
|
>Unreviewed</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="form-control">
|
||||||
|
<span class="label mb-1 text-xs">Reviewer notes</span>
|
||||||
|
<textarea
|
||||||
|
class="textarea textarea-bordered w-full"
|
||||||
|
rows="3"
|
||||||
|
placeholder="What needs to be fixed?"
|
||||||
|
disabled={isReadOnly}
|
||||||
|
bind:value={notes}
|
||||||
|
></textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{#if submitError}
|
||||||
|
<div role="alert" class="alert alert-error">
|
||||||
|
<span>{submitError}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2 justify-between">
|
||||||
|
<button
|
||||||
|
class="btn btn-neutral btn-sm"
|
||||||
|
onclick={goPrev}
|
||||||
|
disabled={stepIndex === 0}
|
||||||
|
>
|
||||||
|
← Previous
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#if isReadOnly}
|
||||||
|
<span class="text-sm opacity-70">
|
||||||
|
{v.status === "completed"
|
||||||
|
? "This verification is completed and read-only."
|
||||||
|
: "This verification is waiting on the player to resubmit and is read-only."}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{#if !allReviewed}
|
||||||
|
<span class="text-xs text-warning"
|
||||||
|
>Mark every activity Pass or Fail to complete</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<button
|
||||||
|
class="btn btn-warning"
|
||||||
|
disabled={!!submitting}
|
||||||
|
onclick={requestAction}
|
||||||
|
>
|
||||||
|
{submitting === "action"
|
||||||
|
? "Submitting..."
|
||||||
|
: "Request action"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-success"
|
||||||
|
disabled={!!submitting || !allReviewed}
|
||||||
|
onclick={complete}
|
||||||
|
>
|
||||||
|
{submitting === "complete"
|
||||||
|
? "Submitting..."
|
||||||
|
: "Complete"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
@@ -42,3 +42,9 @@ export const ActivityPerformanceSchema = t.Object({
|
|||||||
performance: t.Number(),
|
performance: t.Number(),
|
||||||
});
|
});
|
||||||
export type ActivityPerformance = Static<typeof ActivityPerformanceSchema>;
|
export type ActivityPerformance = Static<typeof ActivityPerformanceSchema>;
|
||||||
|
|
||||||
|
export const ActivityVideosSchema = t.Partial(t.Record(t.Enum(Activity), t.String()));
|
||||||
|
export type ActivityVideoUrls = Static<typeof ActivityVideosSchema>;
|
||||||
|
|
||||||
|
export const ActivityVerificationsSchema = t.Partial(t.Record(t.Enum(Activity), t.Boolean()));
|
||||||
|
export type ActivityVerifications = Static<typeof ActivityVerificationsSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user