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"
|
||||
}
|
||||
|
||||
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 {
|
||||
id String @id @default(cuid())
|
||||
createdAt DateTime @default(now())
|
||||
|
|
@ -52,6 +64,14 @@ model ResponseCard {
|
|||
firstTimeGuestDate DateTime?
|
||||
salvationDate DateTime?
|
||||
|
||||
// Assignment / Review workflow
|
||||
assignedToId String?
|
||||
assignedById String?
|
||||
assignedAt DateTime?
|
||||
reviewedById String?
|
||||
reviewedAt DateTime?
|
||||
reviewNotes String?
|
||||
|
||||
// Meta
|
||||
sourceFile String?
|
||||
frontImagePath String?
|
||||
|
|
@ -69,6 +89,8 @@ model ResponseCard {
|
|||
@@index([name])
|
||||
@@index([createdAt])
|
||||
@@index([mondayItemId])
|
||||
@@index([assignedToId])
|
||||
@@index([reviewedById])
|
||||
}
|
||||
|
||||
model ProcessingJob {
|
||||
|
|
@ -130,6 +152,7 @@ model ActivityLog {
|
|||
source String
|
||||
summary String
|
||||
changes Json?
|
||||
userId String?
|
||||
|
||||
@@index([cardId, createdAt])
|
||||
}
|
||||
|
|
@ -145,7 +168,9 @@ model Notification {
|
|||
cardId String?
|
||||
actionUrl String?
|
||||
meta Json?
|
||||
userId String?
|
||||
|
||||
@@index([read, dismissed, createdAt])
|
||||
@@index([cardId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getOrCreateUser, type AppUser } from "@/lib/auth";
|
||||
|
||||
export type AuthentikUser = {
|
||||
username: string;
|
||||
|
|
@ -9,6 +10,8 @@ export type AuthentikUser = {
|
|||
avatar: string;
|
||||
};
|
||||
|
||||
export type { AppUser };
|
||||
|
||||
/**
|
||||
* Reads Authentik forward-auth headers injected by Traefik and optionally
|
||||
* 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 { fireIntegrationEvent } from "@/lib/integrations";
|
||||
import { logActivity, diffCardFields } from "@/lib/activity-log";
|
||||
import { getOrCreateUser, RoleError } from "@/lib/auth";
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
|
|
@ -44,6 +45,8 @@ export async function PUT(
|
|||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const user = await getOrCreateUser(request.headers);
|
||||
|
||||
const { id } = await params;
|
||||
const card = await prisma.responseCard.findUnique({
|
||||
where: { id },
|
||||
|
|
@ -53,6 +56,15 @@ export async function PUT(
|
|||
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 data: Record<string, unknown> = {};
|
||||
|
||||
|
|
@ -84,6 +96,7 @@ export async function PUT(
|
|||
"ocrStatus",
|
||||
"reviewStatus",
|
||||
"ocrError",
|
||||
"reviewNotes",
|
||||
];
|
||||
for (const field of stringFields) {
|
||||
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.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) {
|
||||
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.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 updated = await prisma.responseCard.update({
|
||||
|
|
@ -116,7 +144,14 @@ export async function PUT(
|
|||
const newCard = updated as unknown as Record<string, unknown>;
|
||||
const changes = diffCardFields(oldCard, newCard);
|
||||
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;
|
||||
|
|
@ -131,6 +166,9 @@ export async function PUT(
|
|||
|
||||
return NextResponse.json(updated);
|
||||
} catch (error) {
|
||||
if (error instanceof RoleError) {
|
||||
return NextResponse.json({ error: error.message }, { status: 403 });
|
||||
}
|
||||
console.error("[cards/[id] PUT]", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to update card" },
|
||||
|
|
@ -140,10 +178,15 @@ export async function PUT(
|
|||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
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 card = await prisma.responseCard.findUnique({
|
||||
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 { prisma } from "@/lib/db";
|
||||
import { getOrCreateUser } from "@/lib/auth";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
|
|
@ -12,9 +13,12 @@ export async function GET(request: NextRequest) {
|
|||
const attendanceDuration = searchParams.get("attendanceDuration") || undefined;
|
||||
const visitType = searchParams.get("visitType") || undefined;
|
||||
const serviceAttended = searchParams.get("serviceAttended") || undefined;
|
||||
const assignedToId = searchParams.get("assignedToId") || undefined;
|
||||
const sortBy = searchParams.get("sortBy") ?? "createdAt";
|
||||
const sortOrder = searchParams.get("sortOrder") ?? "desc";
|
||||
|
||||
const user = await getOrCreateUser(request.headers);
|
||||
|
||||
const validSortFields = [
|
||||
"createdAt",
|
||||
"updatedAt",
|
||||
|
|
@ -24,6 +28,7 @@ export async function GET(request: NextRequest) {
|
|||
"attendanceDuration",
|
||||
"visitType",
|
||||
"serviceAttended",
|
||||
"assignedToId",
|
||||
];
|
||||
const orderByField = validSortFields.includes(sortBy) ? sortBy : "createdAt";
|
||||
const order = sortOrder === "asc" ? "asc" : "desc";
|
||||
|
|
@ -36,6 +41,14 @@ export async function GET(request: NextRequest) {
|
|||
if (visitType) where.visitType = visitType;
|
||||
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) {
|
||||
where.OR = [
|
||||
{ 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 { getOrCreateUser } from "@/lib/auth";
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
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.groupBy({
|
||||
by: ["ocrStatus"],
|
||||
|
|
@ -13,6 +18,15 @@ export async function GET() {
|
|||
by: ["reviewStatus"],
|
||||
_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(
|
||||
|
|
@ -26,6 +40,7 @@ export async function GET() {
|
|||
total,
|
||||
byOcrStatus: ocrStatusCounts,
|
||||
byReviewStatus: reviewStatusCounts,
|
||||
myCards,
|
||||
});
|
||||
} catch (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";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useUserProfile } from "@/lib/user-profile";
|
||||
|
||||
const MESSAGE_TOPIC_OPTIONS = [
|
||||
"Stress/Anxiety", "Marriage", "Hearing God's Voice", "Dealing With Doubt",
|
||||
|
|
@ -99,6 +100,12 @@ type CardData = {
|
|||
ftGuestLetterSent: boolean;
|
||||
firstTimeGuestDate: 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;
|
||||
reviewStatus: string;
|
||||
ocrConfidence: number | null;
|
||||
|
|
@ -118,10 +125,16 @@ type ActivityEntry = {
|
|||
changes: { field: string; from: string | null; to: string | null }[] | null;
|
||||
};
|
||||
|
||||
type AssignableUser = { id: string; displayName: string };
|
||||
|
||||
export default function CardDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
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 [loading, setLoading] = React.useState(true);
|
||||
|
|
@ -134,6 +147,12 @@ export default function CardDetailPage() {
|
|||
const [activityLoading, setActivityLoading] = React.useState(false);
|
||||
const [expandedEntry, setExpandedEntry] = React.useState<string | null>(null);
|
||||
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 () => {
|
||||
setLoading(true);
|
||||
|
|
@ -154,6 +173,34 @@ export default function CardDetailPage() {
|
|||
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(() => {
|
||||
if (card?.ocrStatus !== "processing") return;
|
||||
const interval = setInterval(async () => {
|
||||
|
|
@ -210,21 +257,70 @@ export default function CardDetailPage() {
|
|||
if (Object.keys(edits).length === 0) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: Record<string, unknown> = { ...edits };
|
||||
if (card?.reviewStatus === "assigned") {
|
||||
payload.reviewStatus = "in_review";
|
||||
}
|
||||
const res = await fetch(`/api/cards/${id}`, {
|
||||
method: "PUT",
|
||||
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");
|
||||
await fetchCard();
|
||||
} catch {
|
||||
toast.error("Failed to save");
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Failed to save");
|
||||
} finally {
|
||||
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 () => {
|
||||
await fetch(`/api/cards/${id}`, {
|
||||
method: "PUT",
|
||||
|
|
@ -352,9 +448,26 @@ export default function CardDetailPage() {
|
|||
</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}>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{ocrStatus !== "processing" && (
|
||||
{isAdmin && ocrStatus !== "processing" && (
|
||||
<Button variant="outline" size="sm" className="rounded-xl" onClick={handleReprocess} disabled={reprocessing}>
|
||||
{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...
|
||||
</Badge>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
|
@ -381,17 +495,30 @@ export default function CardDetailPage() {
|
|||
<><LayoutGrid className="mr-1 size-4" /> {card.mondayItemId ? "Update Monday" : "Push to Monday"}</>
|
||||
)}
|
||||
</Button>
|
||||
{reviewStatus !== "reviewed" && (
|
||||
<Button variant="outline" size="sm" className="rounded-xl" onClick={handleMarkReviewed}>
|
||||
<Check className="mr-1 size-4" /> Mark Reviewed
|
||||
)}
|
||||
{isAdmin && users.length > 0 && (
|
||||
<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>
|
||||
)}
|
||||
{reviewStatus !== "exported" && (
|
||||
{isAdmin && reviewStatus !== "exported" && (
|
||||
<Button variant="outline" size="sm" className="rounded-xl" onClick={handleExport}>
|
||||
<Download className="mr-1 size-4" /> Export
|
||||
</Button>
|
||||
)}
|
||||
{hasEdits && (
|
||||
{hasEdits && canEdit && (
|
||||
<Button size="sm" className="rounded-xl" onClick={handleSave} disabled={saving}>
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
|
|
@ -434,22 +561,22 @@ export default function CardDetailPage() {
|
|||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<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="Email" value={getValue("email")} onChange={(v) => setField("email", v)} />
|
||||
<Field label="Cell Phone" value={getValue("cellPhone")} onChange={(v) => setField("cellPhone", v)} />
|
||||
<Field label="Home Phone" value={getValue("homePhone")} onChange={(v) => setField("homePhone", v)} />
|
||||
<SelectField label="Gender" value={getValue("gender")} options={["Male", "Female"]} onChange={(v) => setField("gender", v)} />
|
||||
<Field label="Date of Birth" value={getValue("dateOfBirth")} onChange={(v) => setField("dateOfBirth", v)} />
|
||||
<SelectField label="Marital Status" value={getValue("maritalStatus")} options={["Married", "Single", "Other"]} onChange={(v) => setField("maritalStatus", v)} />
|
||||
<SelectField label="Visit Type" value={getValue("visitType")} options={["First/Second Time Guest", "Update My Information"]} onChange={(v) => setField("visitType", 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)} readOnly={!canEdit} />
|
||||
<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)} readOnly={!canEdit} />
|
||||
<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)} readOnly={!canEdit} />
|
||||
<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)} readOnly={!canEdit} />
|
||||
</div>
|
||||
<Separator className="opacity-50" />
|
||||
<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="Apt #" value={getValue("aptNumber")} onChange={(v) => setField("aptNumber", v)} />
|
||||
<Field label="City" value={getValue("city")} onChange={(v) => setField("city", v)} />
|
||||
<Field label="State" value={getValue("state")} onChange={(v) => setField("state", v)} />
|
||||
<Field label="Zip" value={getValue("zip")} onChange={(v) => setField("zip", 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)} readOnly={!canEdit} />
|
||||
<Field label="City" value={getValue("city")} onChange={(v) => setField("city", v)} readOnly={!canEdit} />
|
||||
<Field label="State" value={getValue("state")} onChange={(v) => setField("state", v)} readOnly={!canEdit} />
|
||||
<Field label="Zip" value={getValue("zip")} onChange={(v) => setField("zip", v)} readOnly={!canEdit} />
|
||||
</div>
|
||||
<Separator className="opacity-50" />
|
||||
<div>
|
||||
|
|
@ -458,11 +585,13 @@ export default function CardDetailPage() {
|
|||
value={getValue("prayerRequests") || ""}
|
||||
onChange={(e) => setField("prayerRequests", e.target.value)}
|
||||
rows={3}
|
||||
readOnly={!canEdit}
|
||||
className={!canEdit ? "opacity-70 cursor-default" : ""}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-6">
|
||||
<BooleanField label="For Prayer Team" value={getBoolValue("prayerForTeam")} onChange={(v) => setField("prayerForTeam", v)} />
|
||||
<BooleanField label="Confidential" value={getBoolValue("prayerConfidential")} onChange={(v) => setField("prayerConfidential", 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)} readOnly={!canEdit} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -481,27 +610,31 @@ export default function CardDetailPage() {
|
|||
value={getArrayValue("messageTopics")}
|
||||
options={MESSAGE_TOPIC_OPTIONS}
|
||||
onChange={(v) => setField("messageTopics", v)}
|
||||
readOnly={!canEdit}
|
||||
/>
|
||||
<MultiSelectField
|
||||
label="Next Steps"
|
||||
value={getArrayValue("nextStep")}
|
||||
options={NEXT_STEP_OPTIONS}
|
||||
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
|
||||
label="Campus Preference"
|
||||
value={getArrayValue("campusPreference")}
|
||||
options={CAMPUS_OPTIONS}
|
||||
onChange={(v) => setField("campusPreference", v)}
|
||||
readOnly={!canEdit}
|
||||
/>
|
||||
<MultiSelectField
|
||||
label="How Heard"
|
||||
value={getArrayValue("howHeard")}
|
||||
options={HOW_HEARD_OPTIONS}
|
||||
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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
|
@ -515,15 +648,15 @@ export default function CardDetailPage() {
|
|||
</CardHeader>
|
||||
<CardContent>
|
||||
<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="Service Time" value={getValue("serviceTime")} onChange={(v) => setField("serviceTime", v)} />
|
||||
<Field label="Planning Center" value={getValue("planningCenter")} onChange={(v) => setField("planningCenter", v)} />
|
||||
<DateField label="First Time Guest Date" value={getDateValue("firstTimeGuestDate")} onChange={(v) => setField("firstTimeGuestDate", v || null)} />
|
||||
<DateField label="Salvation Date" value={getDateValue("salvationDate")} onChange={(v) => setField("salvationDate", v || null)} />
|
||||
<BooleanField label="I Said Yes Book Sent" value={getBoolValue("iSaidYesBookSent")} onChange={(v) => setField("iSaidYesBookSent", v)} />
|
||||
<BooleanField label="FT Guest Letter Sent" value={getBoolValue("ftGuestLetterSent")} onChange={(v) => setField("ftGuestLetterSent", 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)} readOnly={!canEdit} />
|
||||
<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)} readOnly={!canEdit} />
|
||||
<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)} readOnly={!canEdit} />
|
||||
<BooleanField label="FT Guest Letter Sent" value={getBoolValue("ftGuestLetterSent")} onChange={(v) => setField("ftGuestLetterSent", v)} readOnly={!canEdit} />
|
||||
</div>
|
||||
{getValue("notes") && (
|
||||
{(getValue("notes") || canEdit) && (
|
||||
<>
|
||||
<Separator className="my-4 opacity-50" />
|
||||
<div>
|
||||
|
|
@ -532,6 +665,8 @@ export default function CardDetailPage() {
|
|||
value={getValue("notes")}
|
||||
onChange={(e) => setField("notes", e.target.value)}
|
||||
rows={3}
|
||||
readOnly={!canEdit}
|
||||
className={!canEdit ? "opacity-70 cursor-default" : ""}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -651,10 +786,22 @@ export default function CardDetailPage() {
|
|||
<ArrowLeft className="mr-1 size-4" /> All Cards
|
||||
</Button>
|
||||
<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
|
||||
</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" />
|
||||
</Button>
|
||||
</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 (
|
||||
<div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -749,7 +896,15 @@ function formatTimeAgo(dateStr: string): string {
|
|||
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 (
|
||||
<div>
|
||||
<Label className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</Label>
|
||||
|
|
@ -773,15 +928,18 @@ function MultiSelectField({
|
|||
value,
|
||||
options,
|
||||
onChange,
|
||||
readOnly,
|
||||
}: {
|
||||
label: string;
|
||||
value: string[];
|
||||
options: string[];
|
||||
onChange: (v: string[]) => void;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
const selected = new Set(value);
|
||||
|
||||
const toggle = (opt: string) => {
|
||||
if (readOnly) return;
|
||||
const next = new Set(selected);
|
||||
if (next.has(opt)) next.delete(opt);
|
||||
else next.add(opt);
|
||||
|
|
@ -799,11 +957,13 @@ function MultiSelectField({
|
|||
key={opt}
|
||||
type="button"
|
||||
onClick={() => toggle(opt)}
|
||||
disabled={readOnly}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-lg border px-2.5 py-1 text-xs font-medium transition-colors",
|
||||
isOn
|
||||
? "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}
|
||||
|
|
@ -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 (
|
||||
<div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -828,15 +988,17 @@ function BooleanField({
|
|||
label,
|
||||
value,
|
||||
onChange,
|
||||
readOnly,
|
||||
}: {
|
||||
label: string;
|
||||
value: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
readOnly?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch checked={value} onCheckedChange={onChange} size="sm" />
|
||||
<Label className="text-sm cursor-pointer" onClick={() => onChange(!value)}>
|
||||
<Switch checked={value} onCheckedChange={readOnly ? undefined : onChange} size="sm" disabled={readOnly} />
|
||||
<Label className={cn("text-sm", readOnly ? "cursor-default opacity-70" : "cursor-pointer")} onClick={readOnly ? undefined : () => onChange(!value)}>
|
||||
{label}
|
||||
</Label>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { useTheme } from "next-themes";
|
||||
import {
|
||||
|
|
@ -26,6 +27,7 @@ import {
|
|||
|
||||
import { Header } from "@/components/layout/header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUserProfile } from "@/lib/user-profile";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
|
@ -153,6 +155,15 @@ function loadNotificationPrefs(): NotificationPrefs {
|
|||
|
||||
export default function SettingsPage() {
|
||||
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>({
|
||||
ollamaUrl: "",
|
||||
|
|
|
|||
|
|
@ -113,11 +113,19 @@ export type ResponseCard = {
|
|||
firstTimeGuestDate: string | null;
|
||||
salvationDate: 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;
|
||||
reviewStatus: string;
|
||||
ocrConfidence: number | null;
|
||||
frontImageUrl: string | null;
|
||||
backImageUrl: string | null;
|
||||
assignedToName?: string | null;
|
||||
reviewedByName?: string | null;
|
||||
};
|
||||
|
||||
export const COPYABLE_FIELDS: { field: keyof ResponseCard; label: string }[] = [
|
||||
|
|
@ -152,6 +160,10 @@ const ocrStatusVariant: Record<string, string> = {
|
|||
const reviewStatusVariant: Record<string, string> = {
|
||||
unreviewed:
|
||||
"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",
|
||||
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,
|
||||
},
|
||||
{
|
||||
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",
|
||||
header: ({ column }) => (
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { DataTable, getDefaultColumnVisibility, ALL_TOGGLEABLE_COLUMNS } from ".
|
|||
import { SelectionToolbar } from "./selection-toolbar";
|
||||
import { UploadModal, type UploadingFile } from "./upload-modal";
|
||||
import { createColumns, COPYABLE_FIELDS, type ResponseCard } from "./columns";
|
||||
import { useUserProfile } from "@/lib/user-profile";
|
||||
|
||||
const VISIT_TYPE_OPTIONS = [
|
||||
"First/Second Time Guest",
|
||||
|
|
@ -29,6 +30,8 @@ const SERVICE_OPTIONS = ["A", "B", "C", "D"];
|
|||
export function DashboardContent() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { role } = useUserProfile();
|
||||
const isAdmin = role === "admin";
|
||||
|
||||
const page = parseInt(searchParams.get("page") || "1");
|
||||
const limit = parseInt(searchParams.get("limit") || "20");
|
||||
|
|
@ -102,6 +105,7 @@ export function DashboardContent() {
|
|||
|
||||
params.delete("ocrStatus");
|
||||
params.delete("reviewStatus");
|
||||
params.delete("assignedToId");
|
||||
|
||||
if (filter === "complete") {
|
||||
params.set("ocrStatus", "complete");
|
||||
|
|
@ -109,6 +113,8 @@ export function DashboardContent() {
|
|||
params.set("ocrStatus", "error");
|
||||
} else if (filter === "unreviewed") {
|
||||
params.set("reviewStatus", "unreviewed");
|
||||
} else if (filter === "my_cards") {
|
||||
params.set("assignedToId", "me");
|
||||
}
|
||||
|
||||
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 = () => {
|
||||
if (data.length === 0) {
|
||||
toast.error("No data to export");
|
||||
|
|
@ -245,7 +271,8 @@ export function DashboardContent() {
|
|||
() =>
|
||||
createColumns({
|
||||
onViewDetails: (card) => router.push(`/cards/${card.id}`),
|
||||
onMarkReviewed: async (card) => {
|
||||
onMarkReviewed: isAdmin
|
||||
? async (card) => {
|
||||
await fetch(`/api/cards/${card.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
|
@ -253,8 +280,10 @@ export function DashboardContent() {
|
|||
});
|
||||
toast.success("Marked as reviewed");
|
||||
fetchCards();
|
||||
},
|
||||
onReprocess: async (card) => {
|
||||
}
|
||||
: undefined,
|
||||
onReprocess: isAdmin
|
||||
? async (card) => {
|
||||
try {
|
||||
const res = await fetch(`/api/cards/${card.id}/reprocess`, {
|
||||
method: "POST",
|
||||
|
|
@ -270,14 +299,17 @@ export function DashboardContent() {
|
|||
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" });
|
||||
toast.success("Card deleted");
|
||||
fetchCards();
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
}),
|
||||
[router, fetchCards]
|
||||
[router, fetchCards, isAdmin]
|
||||
);
|
||||
|
||||
const handleUploadStart = (files: UploadingFile[]) => {
|
||||
|
|
@ -421,9 +453,10 @@ export function DashboardContent() {
|
|||
attendanceDurationOptions={ATTENDANCE_OPTIONS}
|
||||
serviceAttendedOptions={SERVICE_OPTIONS}
|
||||
onExportCsv={handleExportCsv}
|
||||
onUploadClick={openUpload}
|
||||
onUploadClick={isAdmin ? openUpload : undefined}
|
||||
columnVisibility={columnVisibility}
|
||||
onColumnVisibilityChange={setColumnVisibility}
|
||||
showAssignedToFilter
|
||||
/>
|
||||
|
||||
{/* Data table */}
|
||||
|
|
@ -450,11 +483,12 @@ export function DashboardContent() {
|
|||
<SelectionToolbar
|
||||
selectedIds={selectedIds}
|
||||
selectedRows={selectedRows}
|
||||
onMarkReviewed={(ids) => handleBulkAction(ids, "reviewed")}
|
||||
onMarkExported={(ids) => handleBulkAction(ids, "exported")}
|
||||
onReprocess={handleBulkReprocess}
|
||||
onSyncMonday={handleBulkSyncMonday}
|
||||
onDelete={(ids) => handleBulkAction(ids, "delete")}
|
||||
onMarkReviewed={isAdmin ? (ids) => handleBulkAction(ids, "reviewed") : undefined}
|
||||
onMarkExported={isAdmin ? (ids) => handleBulkAction(ids, "exported") : undefined}
|
||||
onReprocess={isAdmin ? handleBulkReprocess : undefined}
|
||||
onSyncMonday={isAdmin ? handleBulkSyncMonday : undefined}
|
||||
onAssign={isAdmin ? handleAssign : undefined}
|
||||
onDelete={isAdmin ? (ids) => handleBulkAction(ids, "delete") : undefined}
|
||||
onClear={() => setSelectedIds([])}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -93,6 +93,8 @@ const COLUMN_GROUPS: { label: string; columns: { id: string; label: string }[] }
|
|||
{ id: "firstTimeGuestDate", label: "FT Guest Date" },
|
||||
{ id: "salvationDate", label: "Salvation Date" },
|
||||
{ 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",
|
||||
"followUp", "notes", "serviceTime", "planningCenter",
|
||||
"iSaidYesBookSent", "ftGuestLetterSent", "firstTimeGuestDate", "salvationDate", "mondayLinked",
|
||||
"assignedTo", "reviewedBy",
|
||||
];
|
||||
|
||||
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: "Survey", ids: ["attendanceDuration", "serviceAttended", "messageTopics", "nextStep", "campusPreference", "howHeard"] },
|
||||
{ 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> = {};
|
||||
|
|
@ -35,6 +35,8 @@ for (const col of ALL_TOGGLEABLE_COLUMNS) {
|
|||
COL_LABELS[col.id] = col.label;
|
||||
}
|
||||
|
||||
type UserOption = { id: string; displayName: string };
|
||||
|
||||
export type FiltersProps = {
|
||||
search?: string;
|
||||
visitType?: string;
|
||||
|
|
@ -47,6 +49,7 @@ export type FiltersProps = {
|
|||
onUploadClick?: () => void;
|
||||
columnVisibility?: VisibilityState;
|
||||
onColumnVisibilityChange?: (visibility: VisibilityState) => void;
|
||||
showAssignedToFilter?: boolean;
|
||||
};
|
||||
|
||||
export function Filters({
|
||||
|
|
@ -61,10 +64,20 @@ export function Filters({
|
|||
onUploadClick,
|
||||
columnVisibility = {},
|
||||
onColumnVisibilityChange,
|
||||
showAssignedToFilter = false,
|
||||
}: FiltersProps) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
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 visitType = searchParams.get("visitType") ?? initialVisitType;
|
||||
|
|
@ -72,6 +85,7 @@ export function Filters({
|
|||
searchParams.get("attendanceDuration") ?? initialAttendanceDuration;
|
||||
const serviceAttended =
|
||||
searchParams.get("serviceAttended") ?? initialServiceAttended;
|
||||
const assignedToId = searchParams.get("assignedToId") ?? "";
|
||||
|
||||
const updateParams = React.useCallback(
|
||||
(updates: Record<string, string | undefined>) => {
|
||||
|
|
@ -176,6 +190,31 @@ export function Filters({
|
|||
))}
|
||||
</SelectContent>
|
||||
</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>
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
Trash2,
|
||||
X,
|
||||
LayoutGrid,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
|
|
@ -22,6 +23,8 @@ import { Switch } from "@/components/ui/switch";
|
|||
import { cn } from "@/lib/utils";
|
||||
import { COPYABLE_FIELDS, type ResponseCard } from "./columns";
|
||||
|
||||
type AssignableUser = { id: string; displayName: string };
|
||||
|
||||
interface SelectionToolbarProps {
|
||||
selectedIds: string[];
|
||||
selectedRows: ResponseCard[];
|
||||
|
|
@ -29,6 +32,7 @@ interface SelectionToolbarProps {
|
|||
onMarkExported?: (ids: string[]) => void;
|
||||
onReprocess?: (ids: string[]) => void;
|
||||
onSyncMonday?: (ids: string[]) => void;
|
||||
onAssign?: (ids: string[], userId: string) => void;
|
||||
onDelete?: (ids: string[]) => void;
|
||||
onClear: () => void;
|
||||
}
|
||||
|
|
@ -57,6 +61,7 @@ export function SelectionToolbar({
|
|||
onMarkExported,
|
||||
onReprocess,
|
||||
onSyncMonday,
|
||||
onAssign,
|
||||
onDelete,
|
||||
onClear,
|
||||
}: SelectionToolbarProps) {
|
||||
|
|
@ -65,6 +70,26 @@ export function SelectionToolbar({
|
|||
() => new Set(DEFAULT_COPY_FIELDS)
|
||||
);
|
||||
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) => {
|
||||
setCopyFields((prev) => {
|
||||
|
|
@ -169,6 +194,46 @@ export function SelectionToolbar({
|
|||
</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}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
CheckCircle,
|
||||
AlertTriangle,
|
||||
Clock,
|
||||
UserCheck,
|
||||
} from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
|
|
@ -13,9 +14,10 @@ type Stats = {
|
|||
total: number;
|
||||
byOcrStatus: 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 = {
|
||||
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: "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: "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 [
|
||||
|
|
@ -83,6 +86,14 @@ export function StatCards({ activeFilter, onFilterChange }: StatCardsProps) {
|
|||
accentClass: "text-amber-600 bg-amber-500/10 dark:text-amber-400",
|
||||
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]);
|
||||
|
||||
|
|
@ -95,7 +106,7 @@ export function StatCards({ activeFilter, onFilterChange }: StatCardsProps) {
|
|||
};
|
||||
|
||||
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) => {
|
||||
const Icon = card.icon;
|
||||
const isActive = activeFilter === card.filterKey;
|
||||
|
|
|
|||
|
|
@ -37,7 +37,8 @@ export async function logActivity(
|
|||
action: string,
|
||||
source: string,
|
||||
summary: string,
|
||||
changes?: FieldChange[] | null
|
||||
changes?: FieldChange[] | null,
|
||||
userId?: string | null
|
||||
) {
|
||||
try {
|
||||
await prisma.activityLog.create({
|
||||
|
|
@ -47,6 +48,7 @@ export async function logActivity(
|
|||
source,
|
||||
summary,
|
||||
changes: changes && changes.length > 0 ? changes : undefined,
|
||||
userId: userId ?? undefined,
|
||||
},
|
||||
});
|
||||
} 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>,
|
||||
cardId: string
|
||||
) {
|
||||
if (event === "ocr_complete") {
|
||||
if (event === "card_reviewed" || event === "card_exported") {
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ type CreateNotificationInput = {
|
|||
cardId?: string;
|
||||
actionUrl?: string;
|
||||
meta?: Prisma.InputJsonValue;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
export async function createNotification(input: CreateNotificationInput) {
|
||||
|
|
@ -20,6 +21,7 @@ export async function createNotification(input: CreateNotificationInput) {
|
|||
cardId: input.cardId,
|
||||
actionUrl: input.actionUrl,
|
||||
meta: input.meta,
|
||||
userId: input.userId,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@
|
|||
|
||||
import * as React from "react";
|
||||
import type { AuthentikUser } from "@/app/api/auth/me/route";
|
||||
import type { AppUser } from "@/lib/auth";
|
||||
|
||||
export type UserRole = "admin" | "reviewer" | "viewer";
|
||||
|
||||
export type UserProfile = {
|
||||
displayName: string;
|
||||
|
|
@ -28,6 +31,8 @@ type UserProfileContextValue = {
|
|||
updateProfile: (updates: Partial<UserProfile>) => void;
|
||||
initials: string;
|
||||
authentikUser: AuthentikUser | null;
|
||||
dbUser: AppUser | null;
|
||||
role: UserRole;
|
||||
isAuthenticated: boolean;
|
||||
loading: boolean;
|
||||
};
|
||||
|
|
@ -58,6 +63,7 @@ function saveLocalProfile(profile: Partial<UserProfile>) {
|
|||
|
||||
export function UserProfileProvider({ children }: { children: React.ReactNode }) {
|
||||
const [authentikUser, setAuthentikUser] = React.useState<AuthentikUser | null>(null);
|
||||
const [dbUser, setDbUser] = React.useState<AppUser | null>(null);
|
||||
const [localOverrides, setLocalOverrides] = React.useState<Partial<UserProfile>>({});
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
|
@ -72,6 +78,9 @@ export function UserProfileProvider({ children }: { children: React.ReactNode })
|
|||
if (data.authenticated && data.user) {
|
||||
setAuthentikUser(data.user);
|
||||
}
|
||||
if (data.dbUser) {
|
||||
setDbUser(data.dbUser);
|
||||
}
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
|
|
@ -107,10 +116,11 @@ export function UserProfileProvider({ children }: { children: React.ReactNode })
|
|||
const initials = React.useMemo(() => getInitials(profile.displayName), [profile.displayName]);
|
||||
|
||||
const isAuthenticated = !!authentikUser;
|
||||
const role: UserRole = dbUser?.role ?? "admin";
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({ profile, updateProfile, initials, authentikUser, isAuthenticated, loading }),
|
||||
[profile, updateProfile, initials, authentikUser, isAuthenticated, loading]
|
||||
() => ({ profile, updateProfile, initials, authentikUser, dbUser, role, isAuthenticated, loading }),
|
||||
[profile, updateProfile, initials, authentikUser, dbUser, role, isAuthenticated, loading]
|
||||
);
|
||||
|
||||
if (!mounted) return <>{children}</>;
|
||||
|
|
@ -130,6 +140,8 @@ export function useUserProfile() {
|
|||
updateProfile: () => {},
|
||||
initials: "",
|
||||
authentikUser: null,
|
||||
dbUser: null,
|
||||
role: "admin" as UserRole,
|
||||
isAuthenticated: false,
|
||||
loading: false,
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue