Add multi-user card review workflow with role-based access
- Add User model synced from Authentik headers with admin/reviewer/viewer roles - Add assignment fields (assignedToId, assignedById, reviewedById, etc.) to ResponseCard - Add userId tracking to ActivityLog and Notification models - Create auth.ts with getOrCreateUser() and role mapping from Authentik groups - Create /api/users endpoint and /api/cards/assign batch assignment endpoint - Gate card mutations behind role checks (viewers read-only, reviewers edit assigned only) - Gate Monday.com push behind reviewStatus=reviewed instead of ocr_complete - Add "My Cards" stat card, Assigned To filter, and assignment columns to table - Add Assign button with user picker to batch selection toolbar (admin only) - Update card detail: assignment banner, Mark Complete button, prev/next nav, reassign - Make all field components accept readOnly prop for role-based editing - Gate settings page behind admin role - Add userId to stats API for per-user card counts - Expose dbUser and role through UserProfileProvider context Made-with: Cursor
This commit is contained in:
parent
aacf9c776b
commit
0ab9932599
20 changed files with 775 additions and 118 deletions
|
|
@ -7,6 +7,18 @@ datasource db {
|
||||||
provider = "postgresql"
|
provider = "postgresql"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
authentikUid String @unique
|
||||||
|
username String
|
||||||
|
displayName String
|
||||||
|
email String
|
||||||
|
avatarUrl String @default("")
|
||||||
|
role String @default("viewer")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
}
|
||||||
|
|
||||||
model ResponseCard {
|
model ResponseCard {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
@ -52,6 +64,14 @@ model ResponseCard {
|
||||||
firstTimeGuestDate DateTime?
|
firstTimeGuestDate DateTime?
|
||||||
salvationDate DateTime?
|
salvationDate DateTime?
|
||||||
|
|
||||||
|
// Assignment / Review workflow
|
||||||
|
assignedToId String?
|
||||||
|
assignedById String?
|
||||||
|
assignedAt DateTime?
|
||||||
|
reviewedById String?
|
||||||
|
reviewedAt DateTime?
|
||||||
|
reviewNotes String?
|
||||||
|
|
||||||
// Meta
|
// Meta
|
||||||
sourceFile String?
|
sourceFile String?
|
||||||
frontImagePath String?
|
frontImagePath String?
|
||||||
|
|
@ -69,6 +89,8 @@ model ResponseCard {
|
||||||
@@index([name])
|
@@index([name])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([mondayItemId])
|
@@index([mondayItemId])
|
||||||
|
@@index([assignedToId])
|
||||||
|
@@index([reviewedById])
|
||||||
}
|
}
|
||||||
|
|
||||||
model ProcessingJob {
|
model ProcessingJob {
|
||||||
|
|
@ -130,6 +152,7 @@ model ActivityLog {
|
||||||
source String
|
source String
|
||||||
summary String
|
summary String
|
||||||
changes Json?
|
changes Json?
|
||||||
|
userId String?
|
||||||
|
|
||||||
@@index([cardId, createdAt])
|
@@index([cardId, createdAt])
|
||||||
}
|
}
|
||||||
|
|
@ -145,7 +168,9 @@ model Notification {
|
||||||
cardId String?
|
cardId String?
|
||||||
actionUrl String?
|
actionUrl String?
|
||||||
meta Json?
|
meta Json?
|
||||||
|
userId String?
|
||||||
|
|
||||||
@@index([read, dismissed, createdAt])
|
@@index([read, dismissed, createdAt])
|
||||||
@@index([cardId])
|
@@index([cardId])
|
||||||
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { getOrCreateUser, type AppUser } from "@/lib/auth";
|
||||||
|
|
||||||
export type AuthentikUser = {
|
export type AuthentikUser = {
|
||||||
username: string;
|
username: string;
|
||||||
|
|
@ -9,6 +10,8 @@ export type AuthentikUser = {
|
||||||
avatar: string;
|
avatar: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type { AppUser };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads Authentik forward-auth headers injected by Traefik and optionally
|
* Reads Authentik forward-auth headers injected by Traefik and optionally
|
||||||
* enriches with avatar from the Authentik API.
|
* enriches with avatar from the Authentik API.
|
||||||
|
|
@ -67,5 +70,7 @@ export async function GET(req: NextRequest) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({ authenticated: true, user });
|
const dbUser = await getOrCreateUser(req.headers);
|
||||||
|
|
||||||
|
return NextResponse.json({ authenticated: true, user, dbUser });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { prisma } from "@/lib/db";
|
||||||
import { deleteObject } from "@/lib/minio";
|
import { deleteObject } from "@/lib/minio";
|
||||||
import { fireIntegrationEvent } from "@/lib/integrations";
|
import { fireIntegrationEvent } from "@/lib/integrations";
|
||||||
import { logActivity, diffCardFields } from "@/lib/activity-log";
|
import { logActivity, diffCardFields } from "@/lib/activity-log";
|
||||||
|
import { getOrCreateUser, RoleError } from "@/lib/auth";
|
||||||
|
|
||||||
export async function GET(
|
export async function GET(
|
||||||
_request: NextRequest,
|
_request: NextRequest,
|
||||||
|
|
@ -44,6 +45,8 @@ export async function PUT(
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const user = await getOrCreateUser(request.headers);
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const card = await prisma.responseCard.findUnique({
|
const card = await prisma.responseCard.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|
@ -53,6 +56,15 @@ export async function PUT(
|
||||||
return NextResponse.json({ error: "Card not found" }, { status: 404 });
|
return NextResponse.json({ error: "Card not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
if (user.role === "viewer") {
|
||||||
|
return NextResponse.json({ error: "Viewers cannot edit cards" }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (user.role === "reviewer" && card.assignedToId !== user.id) {
|
||||||
|
return NextResponse.json({ error: "You can only edit cards assigned to you" }, { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const body = await request.json().catch(() => ({}));
|
const body = await request.json().catch(() => ({}));
|
||||||
const data: Record<string, unknown> = {};
|
const data: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
|
@ -84,6 +96,7 @@ export async function PUT(
|
||||||
"ocrStatus",
|
"ocrStatus",
|
||||||
"reviewStatus",
|
"reviewStatus",
|
||||||
"ocrError",
|
"ocrError",
|
||||||
|
"reviewNotes",
|
||||||
];
|
];
|
||||||
for (const field of stringFields) {
|
for (const field of stringFields) {
|
||||||
if (body[field] != null) data[field] = String(body[field]);
|
if (body[field] != null) data[field] = String(body[field]);
|
||||||
|
|
@ -94,7 +107,7 @@ export async function PUT(
|
||||||
if (body.iSaidYesBookSent != null) data.iSaidYesBookSent = Boolean(body.iSaidYesBookSent);
|
if (body.iSaidYesBookSent != null) data.iSaidYesBookSent = Boolean(body.iSaidYesBookSent);
|
||||||
if (body.ftGuestLetterSent != null) data.ftGuestLetterSent = Boolean(body.ftGuestLetterSent);
|
if (body.ftGuestLetterSent != null) data.ftGuestLetterSent = Boolean(body.ftGuestLetterSent);
|
||||||
|
|
||||||
for (const dateField of ["firstTimeGuestDate", "salvationDate"] as const) {
|
for (const dateField of ["firstTimeGuestDate", "salvationDate", "assignedAt", "reviewedAt"] as const) {
|
||||||
if (body[dateField] !== undefined) {
|
if (body[dateField] !== undefined) {
|
||||||
data[dateField] = body[dateField] ? new Date(body[dateField]) : null;
|
data[dateField] = body[dateField] ? new Date(body[dateField]) : null;
|
||||||
}
|
}
|
||||||
|
|
@ -106,6 +119,21 @@ export async function PUT(
|
||||||
if (body.howHeard != null) data.howHeard = body.howHeard;
|
if (body.howHeard != null) data.howHeard = body.howHeard;
|
||||||
if (body.rawOcrResponse != null) data.rawOcrResponse = body.rawOcrResponse;
|
if (body.rawOcrResponse != null) data.rawOcrResponse = body.rawOcrResponse;
|
||||||
|
|
||||||
|
for (const assignField of ["assignedToId", "assignedById", "reviewedById"] as const) {
|
||||||
|
if (body[assignField] !== undefined) {
|
||||||
|
data[assignField] = body[assignField] || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.reviewStatus === "in_review" && card.reviewStatus === "assigned") {
|
||||||
|
data.reviewStatus = "in_review";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.reviewStatus === "reviewed" && user) {
|
||||||
|
data.reviewedById = user.id;
|
||||||
|
data.reviewedAt = new Date();
|
||||||
|
}
|
||||||
|
|
||||||
const oldCard = card as unknown as Record<string, unknown>;
|
const oldCard = card as unknown as Record<string, unknown>;
|
||||||
|
|
||||||
const updated = await prisma.responseCard.update({
|
const updated = await prisma.responseCard.update({
|
||||||
|
|
@ -116,7 +144,14 @@ export async function PUT(
|
||||||
const newCard = updated as unknown as Record<string, unknown>;
|
const newCard = updated as unknown as Record<string, unknown>;
|
||||||
const changes = diffCardFields(oldCard, newCard);
|
const changes = diffCardFields(oldCard, newCard);
|
||||||
if (changes.length > 0) {
|
if (changes.length > 0) {
|
||||||
logActivity(id, "manual_edit", "user", `${changes.length} field(s) updated manually`, changes).catch(() => {});
|
logActivity(
|
||||||
|
id,
|
||||||
|
"manual_edit",
|
||||||
|
"user",
|
||||||
|
`${changes.length} field(s) updated manually`,
|
||||||
|
changes,
|
||||||
|
user?.id
|
||||||
|
).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
const oldStatus = card.reviewStatus;
|
const oldStatus = card.reviewStatus;
|
||||||
|
|
@ -131,6 +166,9 @@ export async function PUT(
|
||||||
|
|
||||||
return NextResponse.json(updated);
|
return NextResponse.json(updated);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof RoleError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 403 });
|
||||||
|
}
|
||||||
console.error("[cards/[id] PUT]", error);
|
console.error("[cards/[id] PUT]", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: "Failed to update card" },
|
{ error: "Failed to update card" },
|
||||||
|
|
@ -140,10 +178,15 @@ export async function PUT(
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DELETE(
|
export async function DELETE(
|
||||||
_request: NextRequest,
|
request: NextRequest,
|
||||||
{ params }: { params: Promise<{ id: string }> }
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
|
const user = await getOrCreateUser(request.headers);
|
||||||
|
if (user && user.role !== "admin") {
|
||||||
|
return NextResponse.json({ error: "Only admins can delete cards" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const card = await prisma.responseCard.findUnique({
|
const card = await prisma.responseCard.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
|
|
|
||||||
73
src/app/api/cards/assign/route.ts
Normal file
73
src/app/api/cards/assign/route.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { getOrCreateUser, requireRole, RoleError } from "@/lib/auth";
|
||||||
|
import { createNotification } from "@/lib/notifications";
|
||||||
|
import { logActivity } from "@/lib/activity-log";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getOrCreateUser(req.headers);
|
||||||
|
requireRole(user, "admin");
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const { cardIds, assignToUserId } = body as {
|
||||||
|
cardIds: string[];
|
||||||
|
assignToUserId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!cardIds?.length || !assignToUserId) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "cardIds and assignToUserId are required" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignee = await prisma.user.findUnique({
|
||||||
|
where: { id: assignToUserId },
|
||||||
|
});
|
||||||
|
if (!assignee) {
|
||||||
|
return NextResponse.json({ error: "User not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.responseCard.updateMany({
|
||||||
|
where: { id: { in: cardIds } },
|
||||||
|
data: {
|
||||||
|
assignedToId: assignToUserId,
|
||||||
|
assignedById: user!.id,
|
||||||
|
assignedAt: new Date(),
|
||||||
|
reviewStatus: "assigned",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const cardId of cardIds) {
|
||||||
|
logActivity(
|
||||||
|
cardId,
|
||||||
|
"assignment",
|
||||||
|
"user",
|
||||||
|
`Assigned to ${assignee.displayName} by ${user!.displayName}`,
|
||||||
|
[{ field: "assignedToId", from: null, to: assignToUserId }],
|
||||||
|
user!.id
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
await createNotification({
|
||||||
|
type: "card_assigned",
|
||||||
|
title: "Cards Assigned to You",
|
||||||
|
message: `${cardIds.length} card(s) assigned by ${user!.displayName}`,
|
||||||
|
actionUrl: "/?assignedToId=me",
|
||||||
|
userId: assignToUserId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
ok: true,
|
||||||
|
assigned: cardIds.length,
|
||||||
|
assignee: assignee.displayName,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof RoleError) {
|
||||||
|
return NextResponse.json({ error: error.message }, { status: 403 });
|
||||||
|
}
|
||||||
|
console.error("[cards/assign POST]", error);
|
||||||
|
return NextResponse.json({ error: "Assignment failed" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { getOrCreateUser } from "@/lib/auth";
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -12,9 +13,12 @@ export async function GET(request: NextRequest) {
|
||||||
const attendanceDuration = searchParams.get("attendanceDuration") || undefined;
|
const attendanceDuration = searchParams.get("attendanceDuration") || undefined;
|
||||||
const visitType = searchParams.get("visitType") || undefined;
|
const visitType = searchParams.get("visitType") || undefined;
|
||||||
const serviceAttended = searchParams.get("serviceAttended") || undefined;
|
const serviceAttended = searchParams.get("serviceAttended") || undefined;
|
||||||
|
const assignedToId = searchParams.get("assignedToId") || undefined;
|
||||||
const sortBy = searchParams.get("sortBy") ?? "createdAt";
|
const sortBy = searchParams.get("sortBy") ?? "createdAt";
|
||||||
const sortOrder = searchParams.get("sortOrder") ?? "desc";
|
const sortOrder = searchParams.get("sortOrder") ?? "desc";
|
||||||
|
|
||||||
|
const user = await getOrCreateUser(request.headers);
|
||||||
|
|
||||||
const validSortFields = [
|
const validSortFields = [
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"updatedAt",
|
"updatedAt",
|
||||||
|
|
@ -24,6 +28,7 @@ export async function GET(request: NextRequest) {
|
||||||
"attendanceDuration",
|
"attendanceDuration",
|
||||||
"visitType",
|
"visitType",
|
||||||
"serviceAttended",
|
"serviceAttended",
|
||||||
|
"assignedToId",
|
||||||
];
|
];
|
||||||
const orderByField = validSortFields.includes(sortBy) ? sortBy : "createdAt";
|
const orderByField = validSortFields.includes(sortBy) ? sortBy : "createdAt";
|
||||||
const order = sortOrder === "asc" ? "asc" : "desc";
|
const order = sortOrder === "asc" ? "asc" : "desc";
|
||||||
|
|
@ -36,6 +41,14 @@ export async function GET(request: NextRequest) {
|
||||||
if (visitType) where.visitType = visitType;
|
if (visitType) where.visitType = visitType;
|
||||||
if (serviceAttended) where.serviceAttended = serviceAttended;
|
if (serviceAttended) where.serviceAttended = serviceAttended;
|
||||||
|
|
||||||
|
if (assignedToId === "me" && user) {
|
||||||
|
where.assignedToId = user.id;
|
||||||
|
} else if (assignedToId === "unassigned") {
|
||||||
|
where.assignedToId = null;
|
||||||
|
} else if (assignedToId) {
|
||||||
|
where.assignedToId = assignedToId;
|
||||||
|
}
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ name: { contains: search, mode: "insensitive" } },
|
{ name: { contains: search, mode: "insensitive" } },
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
import { NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/db";
|
import { prisma } from "@/lib/db";
|
||||||
|
import { getOrCreateUser } from "@/lib/auth";
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const [total, byOcrStatus, byReviewStatus] = await Promise.all([
|
const user = await getOrCreateUser(request.headers);
|
||||||
|
const userId = request.nextUrl.searchParams.get("userId") || undefined;
|
||||||
|
const effectiveUserId = userId === "me" && user ? user.id : userId;
|
||||||
|
|
||||||
|
const [total, byOcrStatus, byReviewStatus, myCards] = await Promise.all([
|
||||||
prisma.responseCard.count(),
|
prisma.responseCard.count(),
|
||||||
prisma.responseCard.groupBy({
|
prisma.responseCard.groupBy({
|
||||||
by: ["ocrStatus"],
|
by: ["ocrStatus"],
|
||||||
|
|
@ -13,6 +18,15 @@ export async function GET() {
|
||||||
by: ["reviewStatus"],
|
by: ["reviewStatus"],
|
||||||
_count: { id: true },
|
_count: { id: true },
|
||||||
}),
|
}),
|
||||||
|
effectiveUserId
|
||||||
|
? prisma.responseCard.count({
|
||||||
|
where: { assignedToId: effectiveUserId },
|
||||||
|
})
|
||||||
|
: user
|
||||||
|
? prisma.responseCard.count({
|
||||||
|
where: { assignedToId: user.id },
|
||||||
|
})
|
||||||
|
: Promise.resolve(0),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const ocrStatusCounts = Object.fromEntries(
|
const ocrStatusCounts = Object.fromEntries(
|
||||||
|
|
@ -26,6 +40,7 @@ export async function GET() {
|
||||||
total,
|
total,
|
||||||
byOcrStatus: ocrStatusCounts,
|
byOcrStatus: ocrStatusCounts,
|
||||||
byReviewStatus: reviewStatusCounts,
|
byReviewStatus: reviewStatusCounts,
|
||||||
|
myCards,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[stats GET]", error);
|
console.error("[stats GET]", error);
|
||||||
|
|
|
||||||
34
src/app/api/users/route.ts
Normal file
34
src/app/api/users/route.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/db";
|
||||||
|
import { getOrCreateUser } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const user = await getOrCreateUser(req.headers);
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = req.nextUrl.searchParams.get("role") || undefined;
|
||||||
|
const where: Record<string, unknown> = {};
|
||||||
|
if (role) where.role = role;
|
||||||
|
|
||||||
|
const users = await prisma.user.findMany({
|
||||||
|
where,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
displayName: true,
|
||||||
|
email: true,
|
||||||
|
avatarUrl: true,
|
||||||
|
role: true,
|
||||||
|
},
|
||||||
|
orderBy: { displayName: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ users });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[users GET]", error);
|
||||||
|
return NextResponse.json({ error: "Failed to fetch users" }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -46,6 +46,7 @@ import {
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useUserProfile } from "@/lib/user-profile";
|
||||||
|
|
||||||
const MESSAGE_TOPIC_OPTIONS = [
|
const MESSAGE_TOPIC_OPTIONS = [
|
||||||
"Stress/Anxiety", "Marriage", "Hearing God's Voice", "Dealing With Doubt",
|
"Stress/Anxiety", "Marriage", "Hearing God's Voice", "Dealing With Doubt",
|
||||||
|
|
@ -99,6 +100,12 @@ type CardData = {
|
||||||
ftGuestLetterSent: boolean;
|
ftGuestLetterSent: boolean;
|
||||||
firstTimeGuestDate: string | null;
|
firstTimeGuestDate: string | null;
|
||||||
salvationDate: string | null;
|
salvationDate: string | null;
|
||||||
|
assignedToId: string | null;
|
||||||
|
assignedById: string | null;
|
||||||
|
assignedAt: string | null;
|
||||||
|
reviewedById: string | null;
|
||||||
|
reviewedAt: string | null;
|
||||||
|
reviewNotes: string | null;
|
||||||
ocrStatus: string;
|
ocrStatus: string;
|
||||||
reviewStatus: string;
|
reviewStatus: string;
|
||||||
ocrConfidence: number | null;
|
ocrConfidence: number | null;
|
||||||
|
|
@ -118,10 +125,16 @@ type ActivityEntry = {
|
||||||
changes: { field: string; from: string | null; to: string | null }[] | null;
|
changes: { field: string; from: string | null; to: string | null }[] | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type AssignableUser = { id: string; displayName: string };
|
||||||
|
|
||||||
export default function CardDetailPage() {
|
export default function CardDetailPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const id = params.id as string;
|
const id = params.id as string;
|
||||||
|
const { role, dbUser } = useUserProfile();
|
||||||
|
const isAdmin = role === "admin";
|
||||||
|
const isReviewer = role === "reviewer";
|
||||||
|
const isViewer = role === "viewer";
|
||||||
|
|
||||||
const [card, setCard] = React.useState<CardData | null>(null);
|
const [card, setCard] = React.useState<CardData | null>(null);
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
|
@ -134,6 +147,12 @@ export default function CardDetailPage() {
|
||||||
const [activityLoading, setActivityLoading] = React.useState(false);
|
const [activityLoading, setActivityLoading] = React.useState(false);
|
||||||
const [expandedEntry, setExpandedEntry] = React.useState<string | null>(null);
|
const [expandedEntry, setExpandedEntry] = React.useState<string | null>(null);
|
||||||
const [pushingToMonday, setPushingToMonday] = React.useState(false);
|
const [pushingToMonday, setPushingToMonday] = React.useState(false);
|
||||||
|
const [users, setUsers] = React.useState<AssignableUser[]>([]);
|
||||||
|
const [prevNextIds, setPrevNextIds] = React.useState<{ prev: string | null; next: string | null }>({ prev: null, next: null });
|
||||||
|
|
||||||
|
const isAssignedToMe = card?.assignedToId && dbUser?.id === card.assignedToId;
|
||||||
|
const canEdit = isAdmin || (isReviewer && isAssignedToMe);
|
||||||
|
const canMarkComplete = isAdmin || (isReviewer && isAssignedToMe);
|
||||||
|
|
||||||
const fetchCard = React.useCallback(async () => {
|
const fetchCard = React.useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -154,6 +173,34 @@ export default function CardDetailPage() {
|
||||||
fetchCard();
|
fetchCard();
|
||||||
}, [fetchCard]);
|
}, [fetchCard]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isAdmin) return;
|
||||||
|
fetch("/api/users")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => setUsers(data.users || []))
|
||||||
|
.catch(() => {});
|
||||||
|
}, [isAdmin]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const assignedToId = params.get("assignedToId") || (isReviewer ? "me" : undefined);
|
||||||
|
const apiParams = new URLSearchParams();
|
||||||
|
apiParams.set("limit", "200");
|
||||||
|
if (assignedToId) apiParams.set("assignedToId", assignedToId);
|
||||||
|
|
||||||
|
fetch(`/api/cards?${apiParams.toString()}`)
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
const ids = (data.cards || []).map((c: { id: string }) => c.id);
|
||||||
|
const idx = ids.indexOf(id);
|
||||||
|
setPrevNextIds({
|
||||||
|
prev: idx > 0 ? ids[idx - 1] : null,
|
||||||
|
next: idx >= 0 && idx < ids.length - 1 ? ids[idx + 1] : null,
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [id, isReviewer]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (card?.ocrStatus !== "processing") return;
|
if (card?.ocrStatus !== "processing") return;
|
||||||
const interval = setInterval(async () => {
|
const interval = setInterval(async () => {
|
||||||
|
|
@ -210,21 +257,70 @@ export default function CardDetailPage() {
|
||||||
if (Object.keys(edits).length === 0) return;
|
if (Object.keys(edits).length === 0) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
|
const payload: Record<string, unknown> = { ...edits };
|
||||||
|
if (card?.reviewStatus === "assigned") {
|
||||||
|
payload.reviewStatus = "in_review";
|
||||||
|
}
|
||||||
const res = await fetch(`/api/cards/${id}`, {
|
const res = await fetch(`/api/cards/${id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(edits),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error();
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.error || "Failed to save");
|
||||||
|
}
|
||||||
toast.success("Card updated");
|
toast.success("Card updated");
|
||||||
await fetchCard();
|
await fetchCard();
|
||||||
} catch {
|
} catch (err) {
|
||||||
toast.error("Failed to save");
|
toast.error(err instanceof Error ? err.message : "Failed to save");
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleMarkComplete = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload: Record<string, unknown> = {
|
||||||
|
...edits,
|
||||||
|
reviewStatus: "reviewed",
|
||||||
|
};
|
||||||
|
const res = await fetch(`/api/cards/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.error || "Failed to complete review");
|
||||||
|
}
|
||||||
|
toast.success("Review complete — card will sync to Monday.com");
|
||||||
|
await fetchCard();
|
||||||
|
setEdits({});
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Failed to complete review");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReassign = async (userId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/cards/assign", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ cardIds: [id], assignToUserId: userId }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.error);
|
||||||
|
toast.success(`Reassigned to ${data.assignee}`);
|
||||||
|
fetchCard();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Reassignment failed");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleMarkReviewed = async () => {
|
const handleMarkReviewed = async () => {
|
||||||
await fetch(`/api/cards/${id}`, {
|
await fetch(`/api/cards/${id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
|
|
@ -352,9 +448,26 @@ export default function CardDetailPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{card.assignedToId && (
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-purple-300 bg-purple-500/10 px-4 py-2.5 dark:border-purple-800">
|
||||||
|
<User className="size-4 text-purple-600 dark:text-purple-400" />
|
||||||
|
<span className="text-sm">
|
||||||
|
Assigned to <strong>{card.assignedToId === dbUser?.id ? "you" : (card.assignedToId)}</strong>
|
||||||
|
{card.assignedAt && (
|
||||||
|
<> on {new Date(card.assignedAt).toLocaleDateString()}</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{card.reviewedById && card.reviewedAt && (
|
||||||
|
<span className="text-sm text-muted-foreground ml-2">
|
||||||
|
· Reviewed {new Date(card.reviewedAt).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<Header title={String(card.name || "Unnamed Card")} icon={ScanLine}>
|
<Header title={String(card.name || "Unnamed Card")} icon={ScanLine}>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{ocrStatus !== "processing" && (
|
{isAdmin && ocrStatus !== "processing" && (
|
||||||
<Button variant="outline" size="sm" className="rounded-xl" onClick={handleReprocess} disabled={reprocessing}>
|
<Button variant="outline" size="sm" className="rounded-xl" onClick={handleReprocess} disabled={reprocessing}>
|
||||||
{reprocessing ? (
|
{reprocessing ? (
|
||||||
<><Loader2 className="mr-1 size-4 animate-spin" /> Reprocessing...</>
|
<><Loader2 className="mr-1 size-4 animate-spin" /> Reprocessing...</>
|
||||||
|
|
@ -368,6 +481,7 @@ export default function CardDetailPage() {
|
||||||
<Loader2 className="size-3.5 animate-spin" /> Processing...
|
<Loader2 className="size-3.5 animate-spin" /> Processing...
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{isAdmin && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -381,17 +495,30 @@ export default function CardDetailPage() {
|
||||||
<><LayoutGrid className="mr-1 size-4" /> {card.mondayItemId ? "Update Monday" : "Push to Monday"}</>
|
<><LayoutGrid className="mr-1 size-4" /> {card.mondayItemId ? "Update Monday" : "Push to Monday"}</>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
{reviewStatus !== "reviewed" && (
|
)}
|
||||||
<Button variant="outline" size="sm" className="rounded-xl" onClick={handleMarkReviewed}>
|
{isAdmin && users.length > 0 && (
|
||||||
<Check className="mr-1 size-4" /> Mark Reviewed
|
<Select onValueChange={(v: string | null) => { if (v) handleReassign(v); }}>
|
||||||
|
<SelectTrigger className="w-[160px] rounded-xl h-8 text-sm">
|
||||||
|
<SelectValue placeholder="Reassign..." />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{users.map((u) => (
|
||||||
|
<SelectItem key={u.id} value={u.id}>{u.displayName}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
{canMarkComplete && reviewStatus !== "reviewed" && reviewStatus !== "exported" && (
|
||||||
|
<Button size="sm" className="rounded-xl bg-emerald-600 hover:bg-emerald-700 text-white" onClick={handleMarkComplete} disabled={saving}>
|
||||||
|
<Check className="mr-1 size-4" /> Mark Complete
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{reviewStatus !== "exported" && (
|
{isAdmin && reviewStatus !== "exported" && (
|
||||||
<Button variant="outline" size="sm" className="rounded-xl" onClick={handleExport}>
|
<Button variant="outline" size="sm" className="rounded-xl" onClick={handleExport}>
|
||||||
<Download className="mr-1 size-4" /> Export
|
<Download className="mr-1 size-4" /> Export
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{hasEdits && (
|
{hasEdits && canEdit && (
|
||||||
<Button size="sm" className="rounded-xl" onClick={handleSave} disabled={saving}>
|
<Button size="sm" className="rounded-xl" onClick={handleSave} disabled={saving}>
|
||||||
{saving ? "Saving..." : "Save Changes"}
|
{saving ? "Saving..." : "Save Changes"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -434,22 +561,22 @@ export default function CardDetailPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2">
|
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2">
|
||||||
<Field label="Name" value={getValue("name")} onChange={(v) => setField("name", v)} />
|
<Field label="Name" value={getValue("name")} onChange={(v) => setField("name", v)} readOnly={!canEdit} />
|
||||||
<Field label="Email" value={getValue("email")} onChange={(v) => setField("email", v)} />
|
<Field label="Email" value={getValue("email")} onChange={(v) => setField("email", v)} readOnly={!canEdit} />
|
||||||
<Field label="Cell Phone" value={getValue("cellPhone")} onChange={(v) => setField("cellPhone", v)} />
|
<Field label="Cell Phone" value={getValue("cellPhone")} onChange={(v) => setField("cellPhone", v)} readOnly={!canEdit} />
|
||||||
<Field label="Home Phone" value={getValue("homePhone")} onChange={(v) => setField("homePhone", v)} />
|
<Field label="Home Phone" value={getValue("homePhone")} onChange={(v) => setField("homePhone", v)} readOnly={!canEdit} />
|
||||||
<SelectField label="Gender" value={getValue("gender")} options={["Male", "Female"]} onChange={(v) => setField("gender", v)} />
|
<SelectField label="Gender" value={getValue("gender")} options={["Male", "Female"]} onChange={(v) => setField("gender", v)} readOnly={!canEdit} />
|
||||||
<Field label="Date of Birth" value={getValue("dateOfBirth")} onChange={(v) => setField("dateOfBirth", v)} />
|
<Field label="Date of Birth" value={getValue("dateOfBirth")} onChange={(v) => setField("dateOfBirth", v)} readOnly={!canEdit} />
|
||||||
<SelectField label="Marital Status" value={getValue("maritalStatus")} options={["Married", "Single", "Other"]} onChange={(v) => setField("maritalStatus", v)} />
|
<SelectField label="Marital Status" value={getValue("maritalStatus")} options={["Married", "Single", "Other"]} onChange={(v) => setField("maritalStatus", v)} readOnly={!canEdit} />
|
||||||
<SelectField label="Visit Type" value={getValue("visitType")} options={["First/Second Time Guest", "Update My Information"]} onChange={(v) => setField("visitType", v)} />
|
<SelectField label="Visit Type" value={getValue("visitType")} options={["First/Second Time Guest", "Update My Information"]} onChange={(v) => setField("visitType", v)} readOnly={!canEdit} />
|
||||||
</div>
|
</div>
|
||||||
<Separator className="opacity-50" />
|
<Separator className="opacity-50" />
|
||||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2">
|
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2">
|
||||||
<Field label="Address" value={getValue("address")} onChange={(v) => setField("address", v)} />
|
<Field label="Address" value={getValue("address")} onChange={(v) => setField("address", v)} readOnly={!canEdit} />
|
||||||
<Field label="Apt #" value={getValue("aptNumber")} onChange={(v) => setField("aptNumber", v)} />
|
<Field label="Apt #" value={getValue("aptNumber")} onChange={(v) => setField("aptNumber", v)} readOnly={!canEdit} />
|
||||||
<Field label="City" value={getValue("city")} onChange={(v) => setField("city", v)} />
|
<Field label="City" value={getValue("city")} onChange={(v) => setField("city", v)} readOnly={!canEdit} />
|
||||||
<Field label="State" value={getValue("state")} onChange={(v) => setField("state", v)} />
|
<Field label="State" value={getValue("state")} onChange={(v) => setField("state", v)} readOnly={!canEdit} />
|
||||||
<Field label="Zip" value={getValue("zip")} onChange={(v) => setField("zip", v)} />
|
<Field label="Zip" value={getValue("zip")} onChange={(v) => setField("zip", v)} readOnly={!canEdit} />
|
||||||
</div>
|
</div>
|
||||||
<Separator className="opacity-50" />
|
<Separator className="opacity-50" />
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -458,11 +585,13 @@ export default function CardDetailPage() {
|
||||||
value={getValue("prayerRequests") || ""}
|
value={getValue("prayerRequests") || ""}
|
||||||
onChange={(e) => setField("prayerRequests", e.target.value)}
|
onChange={(e) => setField("prayerRequests", e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
|
readOnly={!canEdit}
|
||||||
|
className={!canEdit ? "opacity-70 cursor-default" : ""}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-6">
|
<div className="flex flex-wrap gap-6">
|
||||||
<BooleanField label="For Prayer Team" value={getBoolValue("prayerForTeam")} onChange={(v) => setField("prayerForTeam", v)} />
|
<BooleanField label="For Prayer Team" value={getBoolValue("prayerForTeam")} onChange={(v) => setField("prayerForTeam", v)} readOnly={!canEdit} />
|
||||||
<BooleanField label="Confidential" value={getBoolValue("prayerConfidential")} onChange={(v) => setField("prayerConfidential", v)} />
|
<BooleanField label="Confidential" value={getBoolValue("prayerConfidential")} onChange={(v) => setField("prayerConfidential", v)} readOnly={!canEdit} />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -481,27 +610,31 @@ export default function CardDetailPage() {
|
||||||
value={getArrayValue("messageTopics")}
|
value={getArrayValue("messageTopics")}
|
||||||
options={MESSAGE_TOPIC_OPTIONS}
|
options={MESSAGE_TOPIC_OPTIONS}
|
||||||
onChange={(v) => setField("messageTopics", v)}
|
onChange={(v) => setField("messageTopics", v)}
|
||||||
|
readOnly={!canEdit}
|
||||||
/>
|
/>
|
||||||
<MultiSelectField
|
<MultiSelectField
|
||||||
label="Next Steps"
|
label="Next Steps"
|
||||||
value={getArrayValue("nextStep")}
|
value={getArrayValue("nextStep")}
|
||||||
options={NEXT_STEP_OPTIONS}
|
options={NEXT_STEP_OPTIONS}
|
||||||
onChange={(v) => setField("nextStep", v)}
|
onChange={(v) => setField("nextStep", v)}
|
||||||
|
readOnly={!canEdit}
|
||||||
/>
|
/>
|
||||||
<SelectField label="Attendance Duration" value={getValue("attendanceDuration")} options={["Less than 6 months", "6 Months - 1 Year", "1-3 Years", "4-6 Years", "7+ Years"]} onChange={(v) => setField("attendanceDuration", v)} />
|
<SelectField label="Attendance Duration" value={getValue("attendanceDuration")} options={["Less than 6 months", "6 Months - 1 Year", "1-3 Years", "4-6 Years", "7+ Years"]} onChange={(v) => setField("attendanceDuration", v)} readOnly={!canEdit} />
|
||||||
<MultiSelectField
|
<MultiSelectField
|
||||||
label="Campus Preference"
|
label="Campus Preference"
|
||||||
value={getArrayValue("campusPreference")}
|
value={getArrayValue("campusPreference")}
|
||||||
options={CAMPUS_OPTIONS}
|
options={CAMPUS_OPTIONS}
|
||||||
onChange={(v) => setField("campusPreference", v)}
|
onChange={(v) => setField("campusPreference", v)}
|
||||||
|
readOnly={!canEdit}
|
||||||
/>
|
/>
|
||||||
<MultiSelectField
|
<MultiSelectField
|
||||||
label="How Heard"
|
label="How Heard"
|
||||||
value={getArrayValue("howHeard")}
|
value={getArrayValue("howHeard")}
|
||||||
options={HOW_HEARD_OPTIONS}
|
options={HOW_HEARD_OPTIONS}
|
||||||
onChange={(v) => setField("howHeard", v)}
|
onChange={(v) => setField("howHeard", v)}
|
||||||
|
readOnly={!canEdit}
|
||||||
/>
|
/>
|
||||||
<SelectField label="Service Attended" value={getValue("serviceAttended")} options={["A", "B", "C", "D"]} onChange={(v) => setField("serviceAttended", v)} />
|
<SelectField label="Service Attended" value={getValue("serviceAttended")} options={["A", "B", "C", "D"]} onChange={(v) => setField("serviceAttended", v)} readOnly={!canEdit} />
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -515,15 +648,15 @@ export default function CardDetailPage() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<Field label="Follow-Up" value={getValue("followUp")} onChange={(v) => setField("followUp", v)} />
|
<Field label="Follow-Up" value={getValue("followUp")} onChange={(v) => setField("followUp", v)} readOnly={!canEdit} />
|
||||||
<Field label="Service Time" value={getValue("serviceTime")} onChange={(v) => setField("serviceTime", v)} />
|
<Field label="Service Time" value={getValue("serviceTime")} onChange={(v) => setField("serviceTime", v)} readOnly={!canEdit} />
|
||||||
<Field label="Planning Center" value={getValue("planningCenter")} onChange={(v) => setField("planningCenter", v)} />
|
<Field label="Planning Center" value={getValue("planningCenter")} onChange={(v) => setField("planningCenter", v)} readOnly={!canEdit} />
|
||||||
<DateField label="First Time Guest Date" value={getDateValue("firstTimeGuestDate")} onChange={(v) => setField("firstTimeGuestDate", v || null)} />
|
<DateField label="First Time Guest Date" value={getDateValue("firstTimeGuestDate")} onChange={(v) => setField("firstTimeGuestDate", v || null)} readOnly={!canEdit} />
|
||||||
<DateField label="Salvation Date" value={getDateValue("salvationDate")} onChange={(v) => setField("salvationDate", v || null)} />
|
<DateField label="Salvation Date" value={getDateValue("salvationDate")} onChange={(v) => setField("salvationDate", v || null)} readOnly={!canEdit} />
|
||||||
<BooleanField label="I Said Yes Book Sent" value={getBoolValue("iSaidYesBookSent")} onChange={(v) => setField("iSaidYesBookSent", v)} />
|
<BooleanField label="I Said Yes Book Sent" value={getBoolValue("iSaidYesBookSent")} onChange={(v) => setField("iSaidYesBookSent", v)} readOnly={!canEdit} />
|
||||||
<BooleanField label="FT Guest Letter Sent" value={getBoolValue("ftGuestLetterSent")} onChange={(v) => setField("ftGuestLetterSent", v)} />
|
<BooleanField label="FT Guest Letter Sent" value={getBoolValue("ftGuestLetterSent")} onChange={(v) => setField("ftGuestLetterSent", v)} readOnly={!canEdit} />
|
||||||
</div>
|
</div>
|
||||||
{getValue("notes") && (
|
{(getValue("notes") || canEdit) && (
|
||||||
<>
|
<>
|
||||||
<Separator className="my-4 opacity-50" />
|
<Separator className="my-4 opacity-50" />
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -532,6 +665,8 @@ export default function CardDetailPage() {
|
||||||
value={getValue("notes")}
|
value={getValue("notes")}
|
||||||
onChange={(e) => setField("notes", e.target.value)}
|
onChange={(e) => setField("notes", e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
|
readOnly={!canEdit}
|
||||||
|
className={!canEdit ? "opacity-70 cursor-default" : ""}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
@ -651,10 +786,22 @@ export default function CardDetailPage() {
|
||||||
<ArrowLeft className="mr-1 size-4" /> All Cards
|
<ArrowLeft className="mr-1 size-4" /> All Cards
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button variant="outline" size="sm" className="rounded-xl" disabled>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-xl"
|
||||||
|
disabled={!prevNextIds.prev}
|
||||||
|
onClick={() => prevNextIds.prev && router.push(`/cards/${prevNextIds.prev}`)}
|
||||||
|
>
|
||||||
<ArrowLeft className="mr-1 size-4" /> Previous
|
<ArrowLeft className="mr-1 size-4" /> Previous
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" className="rounded-xl" disabled>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="rounded-xl"
|
||||||
|
disabled={!prevNextIds.next}
|
||||||
|
onClick={() => prevNextIds.next && router.push(`/cards/${prevNextIds.next}`)}
|
||||||
|
>
|
||||||
Next <ArrowRight className="ml-1 size-4" />
|
Next <ArrowRight className="ml-1 size-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -728,11 +875,11 @@ function ImagePanel({ label, url }: { label: string; url: string | null }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Field({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
function Field({ label, value, onChange, readOnly }: { label: string; value: string; onChange: (v: string) => void; readOnly?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</Label>
|
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</Label>
|
||||||
<Input value={value || ""} onChange={(e) => onChange(e.target.value)} />
|
<Input value={value || ""} onChange={(e) => onChange(e.target.value)} readOnly={readOnly} className={readOnly ? "opacity-70 cursor-default" : ""} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -749,7 +896,15 @@ function formatTimeAgo(dateStr: string): string {
|
||||||
return new Date(dateStr).toLocaleDateString();
|
return new Date(dateStr).toLocaleDateString();
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectField({ label, value, options, onChange }: { label: string; value: string; options: string[]; onChange: (v: string) => void }) {
|
function SelectField({ label, value, options, onChange, readOnly }: { label: string; value: string; options: string[]; onChange: (v: string) => void; readOnly?: boolean }) {
|
||||||
|
if (readOnly) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</Label>
|
||||||
|
<Input value={value || "—"} readOnly className="opacity-70 cursor-default" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</Label>
|
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</Label>
|
||||||
|
|
@ -773,15 +928,18 @@ function MultiSelectField({
|
||||||
value,
|
value,
|
||||||
options,
|
options,
|
||||||
onChange,
|
onChange,
|
||||||
|
readOnly,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string[];
|
value: string[];
|
||||||
options: string[];
|
options: string[];
|
||||||
onChange: (v: string[]) => void;
|
onChange: (v: string[]) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const selected = new Set(value);
|
const selected = new Set(value);
|
||||||
|
|
||||||
const toggle = (opt: string) => {
|
const toggle = (opt: string) => {
|
||||||
|
if (readOnly) return;
|
||||||
const next = new Set(selected);
|
const next = new Set(selected);
|
||||||
if (next.has(opt)) next.delete(opt);
|
if (next.has(opt)) next.delete(opt);
|
||||||
else next.add(opt);
|
else next.add(opt);
|
||||||
|
|
@ -799,11 +957,13 @@ function MultiSelectField({
|
||||||
key={opt}
|
key={opt}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => toggle(opt)}
|
onClick={() => toggle(opt)}
|
||||||
|
disabled={readOnly}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex items-center rounded-lg border px-2.5 py-1 text-xs font-medium transition-colors",
|
"inline-flex items-center rounded-lg border px-2.5 py-1 text-xs font-medium transition-colors",
|
||||||
isOn
|
isOn
|
||||||
? "border-primary/40 bg-primary/10 text-primary hover:bg-primary/20"
|
? "border-primary/40 bg-primary/10 text-primary hover:bg-primary/20"
|
||||||
: "border-border bg-muted/30 text-muted-foreground hover:bg-muted/60 hover:text-foreground"
|
: "border-border bg-muted/30 text-muted-foreground hover:bg-muted/60 hover:text-foreground",
|
||||||
|
readOnly && "cursor-default opacity-70"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{opt}
|
{opt}
|
||||||
|
|
@ -815,11 +975,11 @@ function MultiSelectField({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DateField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
|
function DateField({ label, value, onChange, readOnly }: { label: string; value: string; onChange: (v: string) => void; readOnly?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</Label>
|
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</Label>
|
||||||
<Input type="date" value={value || ""} onChange={(e) => onChange(e.target.value)} />
|
<Input type="date" value={value || ""} onChange={(e) => onChange(e.target.value)} readOnly={readOnly} className={readOnly ? "opacity-70 cursor-default" : ""} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -828,15 +988,17 @@ function BooleanField({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
readOnly,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: boolean;
|
value: boolean;
|
||||||
onChange: (v: boolean) => void;
|
onChange: (v: boolean) => void;
|
||||||
|
readOnly?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Switch checked={value} onCheckedChange={onChange} size="sm" />
|
<Switch checked={value} onCheckedChange={readOnly ? undefined : onChange} size="sm" disabled={readOnly} />
|
||||||
<Label className="text-sm cursor-pointer" onClick={() => onChange(!value)}>
|
<Label className={cn("text-sm", readOnly ? "cursor-default opacity-70" : "cursor-pointer")} onClick={readOnly ? undefined : () => onChange(!value)}>
|
||||||
{label}
|
{label}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import {
|
import {
|
||||||
|
|
@ -26,6 +27,7 @@ import {
|
||||||
|
|
||||||
import { Header } from "@/components/layout/header";
|
import { Header } from "@/components/layout/header";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useUserProfile } from "@/lib/user-profile";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -153,6 +155,15 @@ function loadNotificationPrefs(): NotificationPrefs {
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
|
const { role, loading: userLoading } = useUserProfile();
|
||||||
|
const settingsRouter = useRouter();
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!userLoading && role !== "admin") {
|
||||||
|
settingsRouter.replace("/");
|
||||||
|
toast.error("Settings are restricted to admins");
|
||||||
|
}
|
||||||
|
}, [role, userLoading, settingsRouter]);
|
||||||
|
|
||||||
const [settings, setSettings] = React.useState<SettingsData>({
|
const [settings, setSettings] = React.useState<SettingsData>({
|
||||||
ollamaUrl: "",
|
ollamaUrl: "",
|
||||||
|
|
|
||||||
|
|
@ -113,11 +113,19 @@ export type ResponseCard = {
|
||||||
firstTimeGuestDate: string | null;
|
firstTimeGuestDate: string | null;
|
||||||
salvationDate: string | null;
|
salvationDate: string | null;
|
||||||
mondayItemId: string | null;
|
mondayItemId: string | null;
|
||||||
|
assignedToId: string | null;
|
||||||
|
assignedById: string | null;
|
||||||
|
assignedAt: string | null;
|
||||||
|
reviewedById: string | null;
|
||||||
|
reviewedAt: string | null;
|
||||||
|
reviewNotes: string | null;
|
||||||
ocrStatus: string;
|
ocrStatus: string;
|
||||||
reviewStatus: string;
|
reviewStatus: string;
|
||||||
ocrConfidence: number | null;
|
ocrConfidence: number | null;
|
||||||
frontImageUrl: string | null;
|
frontImageUrl: string | null;
|
||||||
backImageUrl: string | null;
|
backImageUrl: string | null;
|
||||||
|
assignedToName?: string | null;
|
||||||
|
reviewedByName?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const COPYABLE_FIELDS: { field: keyof ResponseCard; label: string }[] = [
|
export const COPYABLE_FIELDS: { field: keyof ResponseCard; label: string }[] = [
|
||||||
|
|
@ -152,6 +160,10 @@ const ocrStatusVariant: Record<string, string> = {
|
||||||
const reviewStatusVariant: Record<string, string> = {
|
const reviewStatusVariant: Record<string, string> = {
|
||||||
unreviewed:
|
unreviewed:
|
||||||
"bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
|
"bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
|
||||||
|
assigned:
|
||||||
|
"bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300",
|
||||||
|
in_review:
|
||||||
|
"bg-cyan-100 text-cyan-800 dark:bg-cyan-900/30 dark:text-cyan-300",
|
||||||
reviewed: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300",
|
reviewed: "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300",
|
||||||
exported: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
|
exported: "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
|
||||||
};
|
};
|
||||||
|
|
@ -478,6 +490,26 @@ export function createColumns(actions?: ColumnActions): ColumnDef<ResponseCard>[
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "assignedTo",
|
||||||
|
header: "Assigned To",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
{row.original.assignedToName ?? "—"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "reviewedBy",
|
||||||
|
header: "Reviewed By",
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
{row.original.reviewedByName ?? "—"}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
accessorKey: "ocrStatus",
|
accessorKey: "ocrStatus",
|
||||||
header: ({ column }) => (
|
header: ({ column }) => (
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { DataTable, getDefaultColumnVisibility, ALL_TOGGLEABLE_COLUMNS } from ".
|
||||||
import { SelectionToolbar } from "./selection-toolbar";
|
import { SelectionToolbar } from "./selection-toolbar";
|
||||||
import { UploadModal, type UploadingFile } from "./upload-modal";
|
import { UploadModal, type UploadingFile } from "./upload-modal";
|
||||||
import { createColumns, COPYABLE_FIELDS, type ResponseCard } from "./columns";
|
import { createColumns, COPYABLE_FIELDS, type ResponseCard } from "./columns";
|
||||||
|
import { useUserProfile } from "@/lib/user-profile";
|
||||||
|
|
||||||
const VISIT_TYPE_OPTIONS = [
|
const VISIT_TYPE_OPTIONS = [
|
||||||
"First/Second Time Guest",
|
"First/Second Time Guest",
|
||||||
|
|
@ -29,6 +30,8 @@ const SERVICE_OPTIONS = ["A", "B", "C", "D"];
|
||||||
export function DashboardContent() {
|
export function DashboardContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const { role } = useUserProfile();
|
||||||
|
const isAdmin = role === "admin";
|
||||||
|
|
||||||
const page = parseInt(searchParams.get("page") || "1");
|
const page = parseInt(searchParams.get("page") || "1");
|
||||||
const limit = parseInt(searchParams.get("limit") || "20");
|
const limit = parseInt(searchParams.get("limit") || "20");
|
||||||
|
|
@ -102,6 +105,7 @@ export function DashboardContent() {
|
||||||
|
|
||||||
params.delete("ocrStatus");
|
params.delete("ocrStatus");
|
||||||
params.delete("reviewStatus");
|
params.delete("reviewStatus");
|
||||||
|
params.delete("assignedToId");
|
||||||
|
|
||||||
if (filter === "complete") {
|
if (filter === "complete") {
|
||||||
params.set("ocrStatus", "complete");
|
params.set("ocrStatus", "complete");
|
||||||
|
|
@ -109,6 +113,8 @@ export function DashboardContent() {
|
||||||
params.set("ocrStatus", "error");
|
params.set("ocrStatus", "error");
|
||||||
} else if (filter === "unreviewed") {
|
} else if (filter === "unreviewed") {
|
||||||
params.set("reviewStatus", "unreviewed");
|
params.set("reviewStatus", "unreviewed");
|
||||||
|
} else if (filter === "my_cards") {
|
||||||
|
params.set("assignedToId", "me");
|
||||||
}
|
}
|
||||||
|
|
||||||
params.set("page", "1");
|
params.set("page", "1");
|
||||||
|
|
@ -201,6 +207,26 @@ export function DashboardContent() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAssign = async (ids: string[], userId: string) => {
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/cards/assign", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ cardIds: ids, assignToUserId: userId }),
|
||||||
|
});
|
||||||
|
const result = await res.json();
|
||||||
|
if (!res.ok) {
|
||||||
|
toast.error(result.error || "Assignment failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success(`Assigned ${result.assigned} card(s) to ${result.assignee}`);
|
||||||
|
setSelectedIds([]);
|
||||||
|
fetchCards();
|
||||||
|
} catch {
|
||||||
|
toast.error("Failed to assign cards");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleExportCsv = () => {
|
const handleExportCsv = () => {
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
toast.error("No data to export");
|
toast.error("No data to export");
|
||||||
|
|
@ -245,7 +271,8 @@ export function DashboardContent() {
|
||||||
() =>
|
() =>
|
||||||
createColumns({
|
createColumns({
|
||||||
onViewDetails: (card) => router.push(`/cards/${card.id}`),
|
onViewDetails: (card) => router.push(`/cards/${card.id}`),
|
||||||
onMarkReviewed: async (card) => {
|
onMarkReviewed: isAdmin
|
||||||
|
? async (card) => {
|
||||||
await fetch(`/api/cards/${card.id}`, {
|
await fetch(`/api/cards/${card.id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
|
@ -253,8 +280,10 @@ export function DashboardContent() {
|
||||||
});
|
});
|
||||||
toast.success("Marked as reviewed");
|
toast.success("Marked as reviewed");
|
||||||
fetchCards();
|
fetchCards();
|
||||||
},
|
}
|
||||||
onReprocess: async (card) => {
|
: undefined,
|
||||||
|
onReprocess: isAdmin
|
||||||
|
? async (card) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/cards/${card.id}/reprocess`, {
|
const res = await fetch(`/api/cards/${card.id}/reprocess`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -270,14 +299,17 @@ export function DashboardContent() {
|
||||||
err instanceof Error ? err.message : "Failed to start reprocessing"
|
err instanceof Error ? err.message : "Failed to start reprocessing"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
onDelete: async (card) => {
|
: undefined,
|
||||||
|
onDelete: isAdmin
|
||||||
|
? async (card) => {
|
||||||
await fetch(`/api/cards/${card.id}`, { method: "DELETE" });
|
await fetch(`/api/cards/${card.id}`, { method: "DELETE" });
|
||||||
toast.success("Card deleted");
|
toast.success("Card deleted");
|
||||||
fetchCards();
|
fetchCards();
|
||||||
},
|
}
|
||||||
|
: undefined,
|
||||||
}),
|
}),
|
||||||
[router, fetchCards]
|
[router, fetchCards, isAdmin]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleUploadStart = (files: UploadingFile[]) => {
|
const handleUploadStart = (files: UploadingFile[]) => {
|
||||||
|
|
@ -421,9 +453,10 @@ export function DashboardContent() {
|
||||||
attendanceDurationOptions={ATTENDANCE_OPTIONS}
|
attendanceDurationOptions={ATTENDANCE_OPTIONS}
|
||||||
serviceAttendedOptions={SERVICE_OPTIONS}
|
serviceAttendedOptions={SERVICE_OPTIONS}
|
||||||
onExportCsv={handleExportCsv}
|
onExportCsv={handleExportCsv}
|
||||||
onUploadClick={openUpload}
|
onUploadClick={isAdmin ? openUpload : undefined}
|
||||||
columnVisibility={columnVisibility}
|
columnVisibility={columnVisibility}
|
||||||
onColumnVisibilityChange={setColumnVisibility}
|
onColumnVisibilityChange={setColumnVisibility}
|
||||||
|
showAssignedToFilter
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Data table */}
|
{/* Data table */}
|
||||||
|
|
@ -450,11 +483,12 @@ export function DashboardContent() {
|
||||||
<SelectionToolbar
|
<SelectionToolbar
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
selectedRows={selectedRows}
|
selectedRows={selectedRows}
|
||||||
onMarkReviewed={(ids) => handleBulkAction(ids, "reviewed")}
|
onMarkReviewed={isAdmin ? (ids) => handleBulkAction(ids, "reviewed") : undefined}
|
||||||
onMarkExported={(ids) => handleBulkAction(ids, "exported")}
|
onMarkExported={isAdmin ? (ids) => handleBulkAction(ids, "exported") : undefined}
|
||||||
onReprocess={handleBulkReprocess}
|
onReprocess={isAdmin ? handleBulkReprocess : undefined}
|
||||||
onSyncMonday={handleBulkSyncMonday}
|
onSyncMonday={isAdmin ? handleBulkSyncMonday : undefined}
|
||||||
onDelete={(ids) => handleBulkAction(ids, "delete")}
|
onAssign={isAdmin ? handleAssign : undefined}
|
||||||
|
onDelete={isAdmin ? (ids) => handleBulkAction(ids, "delete") : undefined}
|
||||||
onClear={() => setSelectedIds([])}
|
onClear={() => setSelectedIds([])}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -93,6 +93,8 @@ const COLUMN_GROUPS: { label: string; columns: { id: string; label: string }[] }
|
||||||
{ id: "firstTimeGuestDate", label: "FT Guest Date" },
|
{ id: "firstTimeGuestDate", label: "FT Guest Date" },
|
||||||
{ id: "salvationDate", label: "Salvation Date" },
|
{ id: "salvationDate", label: "Salvation Date" },
|
||||||
{ id: "mondayLinked", label: "Monday.com" },
|
{ id: "mondayLinked", label: "Monday.com" },
|
||||||
|
{ id: "assignedTo", label: "Assigned To" },
|
||||||
|
{ id: "reviewedBy", label: "Reviewed By" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
@ -105,6 +107,7 @@ const DEFAULT_HIDDEN: string[] = [
|
||||||
"messageTopics", "nextStep", "campusPreference", "howHeard",
|
"messageTopics", "nextStep", "campusPreference", "howHeard",
|
||||||
"followUp", "notes", "serviceTime", "planningCenter",
|
"followUp", "notes", "serviceTime", "planningCenter",
|
||||||
"iSaidYesBookSent", "ftGuestLetterSent", "firstTimeGuestDate", "salvationDate", "mondayLinked",
|
"iSaidYesBookSent", "ftGuestLetterSent", "firstTimeGuestDate", "salvationDate", "mondayLinked",
|
||||||
|
"assignedTo", "reviewedBy",
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getDefaultColumnVisibility(): VisibilityState {
|
export function getDefaultColumnVisibility(): VisibilityState {
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ const COLUMN_GROUPS: { label: string; ids: string[] }[] = [
|
||||||
{ label: "Personal", ids: ["homePhone", "gender", "dateOfBirth", "maritalStatus", "address", "zip"] },
|
{ label: "Personal", ids: ["homePhone", "gender", "dateOfBirth", "maritalStatus", "address", "zip"] },
|
||||||
{ label: "Survey", ids: ["attendanceDuration", "serviceAttended", "messageTopics", "nextStep", "campusPreference", "howHeard"] },
|
{ label: "Survey", ids: ["attendanceDuration", "serviceAttended", "messageTopics", "nextStep", "campusPreference", "howHeard"] },
|
||||||
{ label: "Prayer", ids: ["prayerRequests", "prayerForTeam", "prayerConfidential"] },
|
{ label: "Prayer", ids: ["prayerRequests", "prayerForTeam", "prayerConfidential"] },
|
||||||
{ label: "Workflow", ids: ["followUp", "notes", "serviceTime", "planningCenter", "iSaidYesBookSent", "ftGuestLetterSent", "mondayLinked"] },
|
{ label: "Workflow", ids: ["followUp", "notes", "serviceTime", "planningCenter", "iSaidYesBookSent", "ftGuestLetterSent", "mondayLinked", "assignedTo", "reviewedBy"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
const COL_LABELS: Record<string, string> = {};
|
const COL_LABELS: Record<string, string> = {};
|
||||||
|
|
@ -35,6 +35,8 @@ for (const col of ALL_TOGGLEABLE_COLUMNS) {
|
||||||
COL_LABELS[col.id] = col.label;
|
COL_LABELS[col.id] = col.label;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UserOption = { id: string; displayName: string };
|
||||||
|
|
||||||
export type FiltersProps = {
|
export type FiltersProps = {
|
||||||
search?: string;
|
search?: string;
|
||||||
visitType?: string;
|
visitType?: string;
|
||||||
|
|
@ -47,6 +49,7 @@ export type FiltersProps = {
|
||||||
onUploadClick?: () => void;
|
onUploadClick?: () => void;
|
||||||
columnVisibility?: VisibilityState;
|
columnVisibility?: VisibilityState;
|
||||||
onColumnVisibilityChange?: (visibility: VisibilityState) => void;
|
onColumnVisibilityChange?: (visibility: VisibilityState) => void;
|
||||||
|
showAssignedToFilter?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Filters({
|
export function Filters({
|
||||||
|
|
@ -61,10 +64,20 @@ export function Filters({
|
||||||
onUploadClick,
|
onUploadClick,
|
||||||
columnVisibility = {},
|
columnVisibility = {},
|
||||||
onColumnVisibilityChange,
|
onColumnVisibilityChange,
|
||||||
|
showAssignedToFilter = false,
|
||||||
}: FiltersProps) {
|
}: FiltersProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const [users, setUsers] = React.useState<UserOption[]>([]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!showAssignedToFilter) return;
|
||||||
|
fetch("/api/users")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => setUsers(data.users || []))
|
||||||
|
.catch(() => {});
|
||||||
|
}, [showAssignedToFilter]);
|
||||||
|
|
||||||
const search = searchParams.get("search") ?? initialSearch;
|
const search = searchParams.get("search") ?? initialSearch;
|
||||||
const visitType = searchParams.get("visitType") ?? initialVisitType;
|
const visitType = searchParams.get("visitType") ?? initialVisitType;
|
||||||
|
|
@ -72,6 +85,7 @@ export function Filters({
|
||||||
searchParams.get("attendanceDuration") ?? initialAttendanceDuration;
|
searchParams.get("attendanceDuration") ?? initialAttendanceDuration;
|
||||||
const serviceAttended =
|
const serviceAttended =
|
||||||
searchParams.get("serviceAttended") ?? initialServiceAttended;
|
searchParams.get("serviceAttended") ?? initialServiceAttended;
|
||||||
|
const assignedToId = searchParams.get("assignedToId") ?? "";
|
||||||
|
|
||||||
const updateParams = React.useCallback(
|
const updateParams = React.useCallback(
|
||||||
(updates: Record<string, string | undefined>) => {
|
(updates: Record<string, string | undefined>) => {
|
||||||
|
|
@ -176,6 +190,31 @@ export function Filters({
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
|
{showAssignedToFilter && (
|
||||||
|
<Select
|
||||||
|
value={assignedToId || null}
|
||||||
|
onValueChange={(v: string | null) =>
|
||||||
|
updateParams({
|
||||||
|
assignedToId: !v || v === "__all__" ? undefined : v,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[150px] rounded-xl">
|
||||||
|
<SelectValue placeholder="Assigned To" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__all__">All assignees</SelectItem>
|
||||||
|
<SelectItem value="me">My Cards</SelectItem>
|
||||||
|
<SelectItem value="unassigned">Unassigned</SelectItem>
|
||||||
|
{users.map((u) => (
|
||||||
|
<SelectItem key={u.id} value={u.id}>
|
||||||
|
{u.displayName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
|
UserPlus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
|
@ -22,6 +23,8 @@ import { Switch } from "@/components/ui/switch";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { COPYABLE_FIELDS, type ResponseCard } from "./columns";
|
import { COPYABLE_FIELDS, type ResponseCard } from "./columns";
|
||||||
|
|
||||||
|
type AssignableUser = { id: string; displayName: string };
|
||||||
|
|
||||||
interface SelectionToolbarProps {
|
interface SelectionToolbarProps {
|
||||||
selectedIds: string[];
|
selectedIds: string[];
|
||||||
selectedRows: ResponseCard[];
|
selectedRows: ResponseCard[];
|
||||||
|
|
@ -29,6 +32,7 @@ interface SelectionToolbarProps {
|
||||||
onMarkExported?: (ids: string[]) => void;
|
onMarkExported?: (ids: string[]) => void;
|
||||||
onReprocess?: (ids: string[]) => void;
|
onReprocess?: (ids: string[]) => void;
|
||||||
onSyncMonday?: (ids: string[]) => void;
|
onSyncMonday?: (ids: string[]) => void;
|
||||||
|
onAssign?: (ids: string[], userId: string) => void;
|
||||||
onDelete?: (ids: string[]) => void;
|
onDelete?: (ids: string[]) => void;
|
||||||
onClear: () => void;
|
onClear: () => void;
|
||||||
}
|
}
|
||||||
|
|
@ -57,6 +61,7 @@ export function SelectionToolbar({
|
||||||
onMarkExported,
|
onMarkExported,
|
||||||
onReprocess,
|
onReprocess,
|
||||||
onSyncMonday,
|
onSyncMonday,
|
||||||
|
onAssign,
|
||||||
onDelete,
|
onDelete,
|
||||||
onClear,
|
onClear,
|
||||||
}: SelectionToolbarProps) {
|
}: SelectionToolbarProps) {
|
||||||
|
|
@ -65,6 +70,26 @@ export function SelectionToolbar({
|
||||||
() => new Set(DEFAULT_COPY_FIELDS)
|
() => new Set(DEFAULT_COPY_FIELDS)
|
||||||
);
|
);
|
||||||
const [copyOpen, setCopyOpen] = React.useState(false);
|
const [copyOpen, setCopyOpen] = React.useState(false);
|
||||||
|
const [assignOpen, setAssignOpen] = React.useState(false);
|
||||||
|
const [users, setUsers] = React.useState<AssignableUser[]>([]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!assignOpen) return;
|
||||||
|
fetch("/api/users?role=reviewer")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((data) => {
|
||||||
|
const reviewers: AssignableUser[] = data.users || [];
|
||||||
|
fetch("/api/users?role=admin")
|
||||||
|
.then((r2) => r2.json())
|
||||||
|
.then((d2) => {
|
||||||
|
const admins: AssignableUser[] = d2.users || [];
|
||||||
|
const all = [...admins, ...reviewers];
|
||||||
|
const seen = new Set<string>();
|
||||||
|
setUsers(all.filter((u) => (seen.has(u.id) ? false : (seen.add(u.id), true))));
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
}, [assignOpen]);
|
||||||
|
|
||||||
const toggleCopyField = (field: string) => {
|
const toggleCopyField = (field: string) => {
|
||||||
setCopyFields((prev) => {
|
setCopyFields((prev) => {
|
||||||
|
|
@ -169,6 +194,46 @@ export function SelectionToolbar({
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{onAssign && (
|
||||||
|
<Popover open={assignOpen} onOpenChange={setAssignOpen}>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<Button variant="ghost" size="sm" className="rounded-xl" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<UserPlus className="size-4" />
|
||||||
|
<span className="hidden sm:inline ml-1">Assign</span>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent side="top" sideOffset={8} className="w-56 p-0">
|
||||||
|
<div className="px-3 pt-3 pb-2">
|
||||||
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||||
|
Assign to
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-[200px] overflow-y-auto px-1 pb-2">
|
||||||
|
{users.length === 0 && (
|
||||||
|
<p className="px-3 py-2 text-sm text-muted-foreground">
|
||||||
|
No users available
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{users.map((u) => (
|
||||||
|
<button
|
||||||
|
key={u.id}
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground text-left"
|
||||||
|
onClick={() => {
|
||||||
|
onAssign(selectedIds, u.id);
|
||||||
|
setAssignOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{u.displayName}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)}
|
||||||
|
|
||||||
<Popover open={copyOpen} onOpenChange={setCopyOpen}>
|
<Popover open={copyOpen} onOpenChange={setCopyOpen}>
|
||||||
<PopoverTrigger
|
<PopoverTrigger
|
||||||
render={
|
render={
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Clock,
|
Clock,
|
||||||
|
UserCheck,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
|
@ -13,9 +14,10 @@ type Stats = {
|
||||||
total: number;
|
total: number;
|
||||||
byOcrStatus: Record<string, number>;
|
byOcrStatus: Record<string, number>;
|
||||||
byReviewStatus: Record<string, number>;
|
byReviewStatus: Record<string, number>;
|
||||||
|
myCards?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type StatFilter = "all" | "complete" | "error" | "unreviewed" | null;
|
export type StatFilter = "all" | "complete" | "error" | "unreviewed" | "my_cards" | null;
|
||||||
|
|
||||||
type StatCardData = {
|
type StatCardData = {
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -48,6 +50,7 @@ export function StatCards({ activeFilter, onFilterChange }: StatCardsProps) {
|
||||||
{ label: "OCR Complete", value: 0, icon: CheckCircle, filterKey: "complete", accentClass: "text-emerald-600 bg-emerald-500/10 dark:text-emerald-400", activeRing: "ring-emerald-500/40" },
|
{ label: "OCR Complete", value: 0, icon: CheckCircle, filterKey: "complete", accentClass: "text-emerald-600 bg-emerald-500/10 dark:text-emerald-400", activeRing: "ring-emerald-500/40" },
|
||||||
{ label: "Errors", value: 0, icon: AlertTriangle, filterKey: "error", accentClass: "text-red-600 bg-red-500/10 dark:text-red-400", activeRing: "ring-red-500/40" },
|
{ label: "Errors", value: 0, icon: AlertTriangle, filterKey: "error", accentClass: "text-red-600 bg-red-500/10 dark:text-red-400", activeRing: "ring-red-500/40" },
|
||||||
{ label: "Pending Review", value: 0, icon: Clock, filterKey: "unreviewed", accentClass: "text-amber-600 bg-amber-500/10 dark:text-amber-400", activeRing: "ring-amber-500/40" },
|
{ label: "Pending Review", value: 0, icon: Clock, filterKey: "unreviewed", accentClass: "text-amber-600 bg-amber-500/10 dark:text-amber-400", activeRing: "ring-amber-500/40" },
|
||||||
|
{ label: "My Cards", value: 0, icon: UserCheck, filterKey: "my_cards", accentClass: "text-blue-600 bg-blue-500/10 dark:text-blue-400", activeRing: "ring-blue-500/40" },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
|
|
@ -83,6 +86,14 @@ export function StatCards({ activeFilter, onFilterChange }: StatCardsProps) {
|
||||||
accentClass: "text-amber-600 bg-amber-500/10 dark:text-amber-400",
|
accentClass: "text-amber-600 bg-amber-500/10 dark:text-amber-400",
|
||||||
activeRing: "ring-amber-500/40",
|
activeRing: "ring-amber-500/40",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "My Cards",
|
||||||
|
value: stats.myCards ?? 0,
|
||||||
|
icon: UserCheck,
|
||||||
|
filterKey: "my_cards" as StatFilter,
|
||||||
|
accentClass: "text-blue-600 bg-blue-500/10 dark:text-blue-400",
|
||||||
|
activeRing: "ring-blue-500/40",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}, [stats]);
|
}, [stats]);
|
||||||
|
|
||||||
|
|
@ -95,7 +106,7 @@ export function StatCards({ activeFilter, onFilterChange }: StatCardsProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-4">
|
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-5">
|
||||||
{cards.map((card) => {
|
{cards.map((card) => {
|
||||||
const Icon = card.icon;
|
const Icon = card.icon;
|
||||||
const isActive = activeFilter === card.filterKey;
|
const isActive = activeFilter === card.filterKey;
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,8 @@ export async function logActivity(
|
||||||
action: string,
|
action: string,
|
||||||
source: string,
|
source: string,
|
||||||
summary: string,
|
summary: string,
|
||||||
changes?: FieldChange[] | null
|
changes?: FieldChange[] | null,
|
||||||
|
userId?: string | null
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
await prisma.activityLog.create({
|
await prisma.activityLog.create({
|
||||||
|
|
@ -47,6 +48,7 @@ export async function logActivity(
|
||||||
source,
|
source,
|
||||||
summary,
|
summary,
|
||||||
changes: changes && changes.length > 0 ? changes : undefined,
|
changes: changes && changes.length > 0 ? changes : undefined,
|
||||||
|
userId: userId ?? undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
84
src/lib/auth.ts
Normal file
84
src/lib/auth.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { prisma } from "./db";
|
||||||
|
|
||||||
|
export type AppUser = {
|
||||||
|
id: string;
|
||||||
|
authentikUid: string;
|
||||||
|
username: string;
|
||||||
|
displayName: string;
|
||||||
|
email: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
role: "admin" | "reviewer" | "viewer";
|
||||||
|
};
|
||||||
|
|
||||||
|
type CacheEntry = { user: AppUser; ts: number };
|
||||||
|
const userCache = new Map<string, CacheEntry>();
|
||||||
|
const CACHE_TTL_MS = 60_000;
|
||||||
|
|
||||||
|
function mapGroupsToRole(groups: string[]): "admin" | "reviewer" | "viewer" {
|
||||||
|
const lower = groups.map((g) => g.toLowerCase());
|
||||||
|
if (lower.some((g) => g.includes("admin"))) return "admin";
|
||||||
|
if (lower.some((g) => g.includes("reviewer") || g.includes("review"))) return "reviewer";
|
||||||
|
return "viewer";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getOrCreateUser(headers: Headers): Promise<AppUser | null> {
|
||||||
|
const uid = headers.get("x-authentik-uid") ?? "";
|
||||||
|
const username = headers.get("x-authentik-username") ?? "";
|
||||||
|
const email = headers.get("x-authentik-email") ?? "";
|
||||||
|
|
||||||
|
if (!uid && !username && !email) return null;
|
||||||
|
|
||||||
|
const cacheKey = uid || username || email;
|
||||||
|
const cached = userCache.get(cacheKey);
|
||||||
|
if (cached && Date.now() - cached.ts < CACHE_TTL_MS) {
|
||||||
|
return cached.user;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = headers.get("x-authentik-name") ?? "";
|
||||||
|
const groupsRaw = headers.get("x-authentik-groups") ?? "";
|
||||||
|
const groups = groupsRaw ? groupsRaw.split("|") : [];
|
||||||
|
const role = mapGroupsToRole(groups);
|
||||||
|
|
||||||
|
const dbUser = await prisma.user.upsert({
|
||||||
|
where: { authentikUid: uid || `fallback-${username || email}` },
|
||||||
|
update: {
|
||||||
|
username: username || undefined,
|
||||||
|
displayName: name || username || undefined,
|
||||||
|
email: email || undefined,
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
authentikUid: uid || `fallback-${username || email}`,
|
||||||
|
username: username || email,
|
||||||
|
displayName: name || username || email,
|
||||||
|
email,
|
||||||
|
role,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const user: AppUser = {
|
||||||
|
id: dbUser.id,
|
||||||
|
authentikUid: dbUser.authentikUid,
|
||||||
|
username: dbUser.username,
|
||||||
|
displayName: dbUser.displayName,
|
||||||
|
email: dbUser.email,
|
||||||
|
avatarUrl: dbUser.avatarUrl,
|
||||||
|
role: dbUser.role as AppUser["role"],
|
||||||
|
};
|
||||||
|
|
||||||
|
userCache.set(cacheKey, { user, ts: Date.now() });
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireRole(user: AppUser | null, ...roles: AppUser["role"][]): void {
|
||||||
|
if (!user || !roles.includes(user.role)) {
|
||||||
|
throw new RoleError("Insufficient permissions");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RoleError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "RoleError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -169,18 +169,10 @@ async function handleMonday(
|
||||||
card: Record<string, unknown>,
|
card: Record<string, unknown>,
|
||||||
cardId: string
|
cardId: string
|
||||||
) {
|
) {
|
||||||
if (event === "ocr_complete") {
|
if (event === "card_reviewed" || event === "card_exported") {
|
||||||
await pushCardToMonday(cardId, settings);
|
await pushCardToMonday(cardId, settings);
|
||||||
} else if (event === "card_reviewed" || event === "card_exported") {
|
|
||||||
const token = settings.mondayApiToken;
|
|
||||||
const boardId = settings.mondayBoardId;
|
|
||||||
const columnMap = (settings.mondayColumnMap as Record<string, unknown>) ?? {};
|
|
||||||
const mondayItemId = card.mondayItemId as string | null;
|
|
||||||
if (mondayItemId) {
|
|
||||||
const columnValues = mapCardToColumnValues(card, columnMap);
|
|
||||||
await updateItem(token, boardId, mondayItemId, columnValues);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// ocr_complete no longer triggers Monday.com push -- cards are pushed when reviewed
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function pushCardToMonday(
|
export async function pushCardToMonday(
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ type CreateNotificationInput = {
|
||||||
cardId?: string;
|
cardId?: string;
|
||||||
actionUrl?: string;
|
actionUrl?: string;
|
||||||
meta?: Prisma.InputJsonValue;
|
meta?: Prisma.InputJsonValue;
|
||||||
|
userId?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function createNotification(input: CreateNotificationInput) {
|
export async function createNotification(input: CreateNotificationInput) {
|
||||||
|
|
@ -20,6 +21,7 @@ export async function createNotification(input: CreateNotificationInput) {
|
||||||
cardId: input.cardId,
|
cardId: input.cardId,
|
||||||
actionUrl: input.actionUrl,
|
actionUrl: input.actionUrl,
|
||||||
meta: input.meta,
|
meta: input.meta,
|
||||||
|
userId: input.userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import type { AuthentikUser } from "@/app/api/auth/me/route";
|
import type { AuthentikUser } from "@/app/api/auth/me/route";
|
||||||
|
import type { AppUser } from "@/lib/auth";
|
||||||
|
|
||||||
|
export type UserRole = "admin" | "reviewer" | "viewer";
|
||||||
|
|
||||||
export type UserProfile = {
|
export type UserProfile = {
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
|
@ -28,6 +31,8 @@ type UserProfileContextValue = {
|
||||||
updateProfile: (updates: Partial<UserProfile>) => void;
|
updateProfile: (updates: Partial<UserProfile>) => void;
|
||||||
initials: string;
|
initials: string;
|
||||||
authentikUser: AuthentikUser | null;
|
authentikUser: AuthentikUser | null;
|
||||||
|
dbUser: AppUser | null;
|
||||||
|
role: UserRole;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
};
|
};
|
||||||
|
|
@ -58,6 +63,7 @@ function saveLocalProfile(profile: Partial<UserProfile>) {
|
||||||
|
|
||||||
export function UserProfileProvider({ children }: { children: React.ReactNode }) {
|
export function UserProfileProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [authentikUser, setAuthentikUser] = React.useState<AuthentikUser | null>(null);
|
const [authentikUser, setAuthentikUser] = React.useState<AuthentikUser | null>(null);
|
||||||
|
const [dbUser, setDbUser] = React.useState<AppUser | null>(null);
|
||||||
const [localOverrides, setLocalOverrides] = React.useState<Partial<UserProfile>>({});
|
const [localOverrides, setLocalOverrides] = React.useState<Partial<UserProfile>>({});
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const [mounted, setMounted] = React.useState(false);
|
const [mounted, setMounted] = React.useState(false);
|
||||||
|
|
@ -72,6 +78,9 @@ export function UserProfileProvider({ children }: { children: React.ReactNode })
|
||||||
if (data.authenticated && data.user) {
|
if (data.authenticated && data.user) {
|
||||||
setAuthentikUser(data.user);
|
setAuthentikUser(data.user);
|
||||||
}
|
}
|
||||||
|
if (data.dbUser) {
|
||||||
|
setDbUser(data.dbUser);
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
|
|
@ -107,10 +116,11 @@ export function UserProfileProvider({ children }: { children: React.ReactNode })
|
||||||
const initials = React.useMemo(() => getInitials(profile.displayName), [profile.displayName]);
|
const initials = React.useMemo(() => getInitials(profile.displayName), [profile.displayName]);
|
||||||
|
|
||||||
const isAuthenticated = !!authentikUser;
|
const isAuthenticated = !!authentikUser;
|
||||||
|
const role: UserRole = dbUser?.role ?? "admin";
|
||||||
|
|
||||||
const value = React.useMemo(
|
const value = React.useMemo(
|
||||||
() => ({ profile, updateProfile, initials, authentikUser, isAuthenticated, loading }),
|
() => ({ profile, updateProfile, initials, authentikUser, dbUser, role, isAuthenticated, loading }),
|
||||||
[profile, updateProfile, initials, authentikUser, isAuthenticated, loading]
|
[profile, updateProfile, initials, authentikUser, dbUser, role, isAuthenticated, loading]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mounted) return <>{children}</>;
|
if (!mounted) return <>{children}</>;
|
||||||
|
|
@ -130,6 +140,8 @@ export function useUserProfile() {
|
||||||
updateProfile: () => {},
|
updateProfile: () => {},
|
||||||
initials: "",
|
initials: "",
|
||||||
authentikUser: null,
|
authentikUser: null,
|
||||||
|
dbUser: null,
|
||||||
|
role: "admin" as UserRole,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue