mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
Add company user profile page
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
aed04c2ba0
commit
73008eacb7
12 files changed
+1158
No files matched your search
@@ -310,6 +310,14 @@ export type {
|
||||
LiveEvent,
|
||||
DashboardSummary,
|
||||
ActivityEvent,
|
||||
UserProfileActivitySummary,
|
||||
UserProfileAgentUsage,
|
||||
UserProfileDailyPoint,
|
||||
UserProfileIdentity,
|
||||
UserProfileIssueSummary,
|
||||
UserProfileProviderUsage,
|
||||
UserProfileResponse,
|
||||
UserProfileWindowStats,
|
||||
SidebarBadges,
|
||||
SidebarOrderPreference,
|
||||
InboxDismissal,
|
||||
|
||||
@@ -169,6 +169,16 @@ export type {
|
||||
export type { LiveEvent } from "./live.js";
|
||||
export type { DashboardSummary } from "./dashboard.js";
|
||||
export type { ActivityEvent } from "./activity.js";
|
||||
export type {
|
||||
UserProfileActivitySummary,
|
||||
UserProfileAgentUsage,
|
||||
UserProfileDailyPoint,
|
||||
UserProfileIdentity,
|
||||
UserProfileIssueSummary,
|
||||
UserProfileProviderUsage,
|
||||
UserProfileResponse,
|
||||
UserProfileWindowStats,
|
||||
} from "./user-profile.js";
|
||||
export type { SidebarBadges } from "./sidebar-badges.js";
|
||||
export type { SidebarOrderPreference } from "./sidebar-preferences.js";
|
||||
export type { InboxDismissal } from "./inbox-dismissal.js";
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { IssuePriority, IssueStatus } from "../constants.js";
|
||||
|
||||
export interface UserProfileIdentity {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
image: string | null;
|
||||
membershipRole: string | null;
|
||||
membershipStatus: string;
|
||||
joinedAt: Date;
|
||||
}
|
||||
|
||||
export interface UserProfileWindowStats {
|
||||
key: "last7" | "last30" | "all";
|
||||
label: string;
|
||||
touchedIssues: number;
|
||||
createdIssues: number;
|
||||
completedIssues: number;
|
||||
assignedOpenIssues: number;
|
||||
commentCount: number;
|
||||
activityCount: number;
|
||||
costCents: number;
|
||||
inputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
costEventCount: number;
|
||||
}
|
||||
|
||||
export interface UserProfileDailyPoint {
|
||||
date: string;
|
||||
activityCount: number;
|
||||
completedIssues: number;
|
||||
costCents: number;
|
||||
inputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
}
|
||||
|
||||
export interface UserProfileIssueSummary {
|
||||
id: string;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
status: IssueStatus;
|
||||
priority: IssuePriority;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
updatedAt: Date;
|
||||
completedAt: Date | null;
|
||||
}
|
||||
|
||||
export interface UserProfileActivitySummary {
|
||||
id: string;
|
||||
action: string;
|
||||
entityType: string;
|
||||
entityId: string;
|
||||
details: Record<string, unknown> | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface UserProfileAgentUsage {
|
||||
agentId: string;
|
||||
agentName: string | null;
|
||||
costCents: number;
|
||||
inputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
}
|
||||
|
||||
export interface UserProfileProviderUsage {
|
||||
provider: string;
|
||||
biller: string;
|
||||
model: string;
|
||||
costCents: number;
|
||||
inputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
}
|
||||
|
||||
export interface UserProfileResponse {
|
||||
user: UserProfileIdentity;
|
||||
stats: UserProfileWindowStats[];
|
||||
daily: UserProfileDailyPoint[];
|
||||
recentIssues: UserProfileIssueSummary[];
|
||||
recentActivity: UserProfileActivitySummary[];
|
||||
topAgents: UserProfileAgentUsage[];
|
||||
topProviders: UserProfileProviderUsage[];
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import express from "express";
|
||||
import request from "supertest";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
authUsers,
|
||||
companies,
|
||||
companyMemberships,
|
||||
costEvents,
|
||||
createDb,
|
||||
issueComments,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import { errorHandler } from "../middleware/index.js";
|
||||
import { userProfileRoutes } from "../routes/user-profiles.js";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
|
||||
const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
if (!embeddedPostgresSupport.supported) {
|
||||
console.warn(
|
||||
`Skipping embedded Postgres user profile route tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`,
|
||||
);
|
||||
}
|
||||
|
||||
describeEmbeddedPostgres("GET /companies/:companyId/users/:userSlug/profile", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
let companyId!: string;
|
||||
let userId!: string;
|
||||
let agentId!: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tempDb = await startEmbeddedPostgresTestDatabase("paperclip-user-profile-route-");
|
||||
db = createDb(tempDb.connectionString);
|
||||
}, 20_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
companyId = randomUUID();
|
||||
userId = randomUUID();
|
||||
agentId = randomUUID();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
name: "Paperclip",
|
||||
issuePrefix: `U${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`,
|
||||
requireBoardApprovalForNewAgents: false,
|
||||
});
|
||||
await db.insert(authUsers).values({
|
||||
id: userId,
|
||||
name: "Dotta",
|
||||
email: "dotta@example.com",
|
||||
emailVerified: true,
|
||||
image: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await db.insert(companyMemberships).values({
|
||||
companyId,
|
||||
principalType: "user",
|
||||
principalId: userId,
|
||||
status: "active",
|
||||
membershipRole: "owner",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await db.insert(agents).values({
|
||||
id: agentId,
|
||||
companyId,
|
||||
name: "Coder",
|
||||
role: "engineer",
|
||||
adapterType: "process",
|
||||
adapterConfig: {},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(costEvents);
|
||||
await db.delete(issueComments);
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issues);
|
||||
await db.delete(agents);
|
||||
await db.delete(companyMemberships);
|
||||
await db.delete(authUsers);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
function createApp() {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
(req as any).actor = {
|
||||
type: "board",
|
||||
source: "local_implicit",
|
||||
userId,
|
||||
companyIds: [companyId],
|
||||
};
|
||||
next();
|
||||
});
|
||||
app.use("/api", userProfileRoutes(db));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
it("resolves a user slug and returns issue, activity, and attributed cost stats", async () => {
|
||||
const doneIssueId = randomUUID();
|
||||
const openIssueId = randomUUID();
|
||||
const now = new Date();
|
||||
const older = new Date(now.getTime() - 60_000);
|
||||
|
||||
await db.insert(issues).values([
|
||||
{
|
||||
id: doneIssueId,
|
||||
companyId,
|
||||
title: "Ship profile page",
|
||||
status: "done",
|
||||
priority: "high",
|
||||
createdByUserId: userId,
|
||||
identifier: "USR-1",
|
||||
completedAt: now,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
{
|
||||
id: openIssueId,
|
||||
companyId,
|
||||
title: "Review profile copy",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
assigneeUserId: userId,
|
||||
identifier: "USR-2",
|
||||
createdAt: older,
|
||||
updatedAt: older,
|
||||
},
|
||||
]);
|
||||
await db.insert(issueComments).values({
|
||||
companyId,
|
||||
issueId: openIssueId,
|
||||
authorUserId: userId,
|
||||
body: "Looks good.",
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
await db.insert(activityLog).values({
|
||||
companyId,
|
||||
actorType: "user",
|
||||
actorId: userId,
|
||||
action: "issue.updated",
|
||||
entityType: "issue",
|
||||
entityId: doneIssueId,
|
||||
createdAt: now,
|
||||
});
|
||||
await db.insert(costEvents).values({
|
||||
companyId,
|
||||
agentId,
|
||||
issueId: doneIssueId,
|
||||
provider: "openai",
|
||||
biller: "openai",
|
||||
billingType: "metered_api",
|
||||
model: "gpt-test",
|
||||
inputTokens: 120,
|
||||
cachedInputTokens: 30,
|
||||
outputTokens: 40,
|
||||
costCents: 42,
|
||||
occurredAt: now,
|
||||
});
|
||||
|
||||
const response = await request(createApp()).get(`/api/companies/${companyId}/users/dotta/profile`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.user.slug).toBe("dotta");
|
||||
expect(response.body.user.membershipRole).toBe("owner");
|
||||
expect(response.body.stats).toHaveLength(3);
|
||||
|
||||
const all = response.body.stats.find((entry: { key: string }) => entry.key === "all");
|
||||
expect(all).toMatchObject({
|
||||
touchedIssues: 2,
|
||||
createdIssues: 1,
|
||||
completedIssues: 1,
|
||||
assignedOpenIssues: 1,
|
||||
commentCount: 1,
|
||||
activityCount: 1,
|
||||
costCents: 42,
|
||||
inputTokens: 120,
|
||||
cachedInputTokens: 30,
|
||||
outputTokens: 40,
|
||||
costEventCount: 1,
|
||||
});
|
||||
expect(response.body.recentIssues.map((issue: { identifier: string }) => issue.identifier)).toEqual(["USR-1", "USR-2"]);
|
||||
expect(response.body.recentActivity[0].action).toBe("issue.updated");
|
||||
expect(response.body.topAgents[0]).toMatchObject({ agentId, agentName: "Coder", costCents: 42 });
|
||||
expect(response.body.topProviders[0]).toMatchObject({ provider: "openai", model: "gpt-test", costCents: 42 });
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import { secretRoutes } from "./routes/secrets.js";
|
||||
import { costRoutes } from "./routes/costs.js";
|
||||
import { activityRoutes } from "./routes/activity.js";
|
||||
import { dashboardRoutes } from "./routes/dashboard.js";
|
||||
import { userProfileRoutes } from "./routes/user-profiles.js";
|
||||
import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
|
||||
import { sidebarPreferenceRoutes } from "./routes/sidebar-preferences.js";
|
||||
import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js";
|
||||
@@ -190,6 +191,7 @@ export async function createApp(
|
||||
api.use(costRoutes(db));
|
||||
api.use(activityRoutes(db));
|
||||
api.use(dashboardRoutes(db));
|
||||
api.use(userProfileRoutes(db));
|
||||
api.use(sidebarBadgeRoutes(db));
|
||||
api.use(sidebarPreferenceRoutes(db));
|
||||
api.use(inboxDismissalRoutes(db));
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
import { Router } from "express";
|
||||
import { and, desc, eq, gte, isNull, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
authUsers,
|
||||
companyMemberships,
|
||||
costEvents,
|
||||
issueComments,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
UserProfileDailyPoint,
|
||||
UserProfileIdentity,
|
||||
UserProfileResponse,
|
||||
UserProfileWindowStats,
|
||||
} from "@paperclipai/shared";
|
||||
import { notFound } from "../errors.js";
|
||||
import { assertCompanyAccess } from "./authz.js";
|
||||
|
||||
type CompanyUserRow = {
|
||||
id: string;
|
||||
principalId: string;
|
||||
status: string;
|
||||
membershipRole: string | null;
|
||||
createdAt: Date;
|
||||
userId: string | null;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
image: string | null;
|
||||
};
|
||||
|
||||
const PROFILE_WINDOWS = [
|
||||
{ key: "last7", label: "Last 7 days", days: 7 },
|
||||
{ key: "last30", label: "Last 30 days", days: 30 },
|
||||
{ key: "all", label: "All time", days: null },
|
||||
] as const;
|
||||
|
||||
function slugifyUserPart(value: string | null | undefined) {
|
||||
const normalized = value
|
||||
?.trim()
|
||||
.toLowerCase()
|
||||
.replace(/['"]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function userSlugCandidates(row: CompanyUserRow) {
|
||||
const candidates = new Set<string>();
|
||||
const add = (value: string | null | undefined) => {
|
||||
const slug = slugifyUserPart(value);
|
||||
if (slug) candidates.add(slug);
|
||||
};
|
||||
add(row.name);
|
||||
add(row.email?.split("@")[0]);
|
||||
add(row.email);
|
||||
add(row.principalId);
|
||||
return [...candidates];
|
||||
}
|
||||
|
||||
async function resolveCompanyUser(db: Db, companyId: string, rawSlug: string): Promise<CompanyUserRow | null> {
|
||||
const slug = slugifyUserPart(rawSlug);
|
||||
if (!slug) return null;
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: companyMemberships.id,
|
||||
principalId: companyMemberships.principalId,
|
||||
status: companyMemberships.status,
|
||||
membershipRole: companyMemberships.membershipRole,
|
||||
createdAt: companyMemberships.createdAt,
|
||||
userId: authUsers.id,
|
||||
name: authUsers.name,
|
||||
email: authUsers.email,
|
||||
image: authUsers.image,
|
||||
})
|
||||
.from(companyMemberships)
|
||||
.leftJoin(authUsers, eq(authUsers.id, companyMemberships.principalId))
|
||||
.where(
|
||||
and(
|
||||
eq(companyMemberships.companyId, companyId),
|
||||
eq(companyMemberships.principalType, "user"),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(companyMemberships.updatedAt));
|
||||
|
||||
return rows.find((row) => userSlugCandidates(row).includes(slug)) ?? null;
|
||||
}
|
||||
|
||||
function userIssueInvolvementSql(companyId: string, userId: string) {
|
||||
return sql<boolean>`
|
||||
(
|
||||
${issues.createdByUserId} = ${userId}
|
||||
OR ${issues.assigneeUserId} = ${userId}
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM ${issueComments}
|
||||
WHERE ${issueComments.companyId} = ${companyId}
|
||||
AND ${issueComments.issueId} = ${issues.id}
|
||||
AND ${issueComments.authorUserId} = ${userId}
|
||||
)
|
||||
)
|
||||
`;
|
||||
}
|
||||
|
||||
function windowStart(days: number | null) {
|
||||
if (!days) return null;
|
||||
return new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
function startOfUtcDay(date: Date) {
|
||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()));
|
||||
}
|
||||
|
||||
function isoDay(date: Date) {
|
||||
return startOfUtcDay(date).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function dayKeyExpr(dateSql: ReturnType<typeof sql>) {
|
||||
return sql<string>`to_char(date_trunc('day', ${dateSql}), 'YYYY-MM-DD')`;
|
||||
}
|
||||
|
||||
function sumNumber(column: typeof costEvents.costCents | typeof costEvents.inputTokens | typeof costEvents.cachedInputTokens | typeof costEvents.outputTokens) {
|
||||
return sql<number>`coalesce(sum(${column}), 0)::double precision`;
|
||||
}
|
||||
|
||||
async function loadWindowStats(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
userId: string,
|
||||
key: UserProfileWindowStats["key"],
|
||||
label: string,
|
||||
from: Date | null,
|
||||
): Promise<UserProfileWindowStats> {
|
||||
const involvement = userIssueInvolvementSql(companyId, userId);
|
||||
const openStatuses = ["backlog", "todo", "in_progress", "in_review", "blocked"];
|
||||
const fromIso = from?.toISOString();
|
||||
|
||||
const [issueStats] = await db
|
||||
.select({
|
||||
touchedIssues: sql<number>`count(distinct case when ${involvement} ${fromIso ? sql`and ${issues.updatedAt} >= ${fromIso}` : sql``} then ${issues.id} end)::int`,
|
||||
createdIssues: sql<number>`count(distinct case when ${issues.createdByUserId} = ${userId} ${fromIso ? sql`and ${issues.createdAt} >= ${fromIso}` : sql``} then ${issues.id} end)::int`,
|
||||
completedIssues: sql<number>`count(distinct case when ${involvement} and ${issues.status} = 'done' ${fromIso ? sql`and ${issues.completedAt} >= ${fromIso}` : sql``} then ${issues.id} end)::int`,
|
||||
assignedOpenIssues: sql<number>`count(distinct case when ${issues.assigneeUserId} = ${userId} and ${issues.status} in (${sql.join(openStatuses.map((status) => sql`${status}`), sql`, `)}) then ${issues.id} end)::int`,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.companyId, companyId), isNull(issues.hiddenAt)));
|
||||
|
||||
const commentConditions = [
|
||||
eq(issueComments.companyId, companyId),
|
||||
eq(issueComments.authorUserId, userId),
|
||||
];
|
||||
if (from) commentConditions.push(gte(issueComments.createdAt, from));
|
||||
const [commentStats] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(issueComments)
|
||||
.where(and(...commentConditions));
|
||||
|
||||
const activityConditions = [
|
||||
eq(activityLog.companyId, companyId),
|
||||
eq(activityLog.actorType, "user"),
|
||||
eq(activityLog.actorId, userId),
|
||||
];
|
||||
if (from) activityConditions.push(gte(activityLog.createdAt, from));
|
||||
const [activityStats] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(activityLog)
|
||||
.where(and(...activityConditions));
|
||||
|
||||
const costConditions = [
|
||||
eq(costEvents.companyId, companyId),
|
||||
userIssueInvolvementSql(companyId, userId),
|
||||
];
|
||||
if (from) costConditions.push(gte(costEvents.occurredAt, from));
|
||||
const [costStats] = await db
|
||||
.select({
|
||||
costCents: sumNumber(costEvents.costCents),
|
||||
inputTokens: sumNumber(costEvents.inputTokens),
|
||||
cachedInputTokens: sumNumber(costEvents.cachedInputTokens),
|
||||
outputTokens: sumNumber(costEvents.outputTokens),
|
||||
costEventCount: sql<number>`count(${costEvents.id})::int`,
|
||||
})
|
||||
.from(costEvents)
|
||||
.innerJoin(issues, and(eq(issues.id, costEvents.issueId), eq(issues.companyId, costEvents.companyId)))
|
||||
.where(and(...costConditions));
|
||||
|
||||
return {
|
||||
key,
|
||||
label,
|
||||
touchedIssues: Number(issueStats?.touchedIssues ?? 0),
|
||||
createdIssues: Number(issueStats?.createdIssues ?? 0),
|
||||
completedIssues: Number(issueStats?.completedIssues ?? 0),
|
||||
assignedOpenIssues: Number(issueStats?.assignedOpenIssues ?? 0),
|
||||
commentCount: Number(commentStats?.count ?? 0),
|
||||
activityCount: Number(activityStats?.count ?? 0),
|
||||
costCents: Number(costStats?.costCents ?? 0),
|
||||
inputTokens: Number(costStats?.inputTokens ?? 0),
|
||||
cachedInputTokens: Number(costStats?.cachedInputTokens ?? 0),
|
||||
outputTokens: Number(costStats?.outputTokens ?? 0),
|
||||
costEventCount: Number(costStats?.costEventCount ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadDailyStats(db: Db, companyId: string, userId: string): Promise<UserProfileDailyPoint[]> {
|
||||
const firstDay = startOfUtcDay(new Date(Date.now() - 13 * 24 * 60 * 60 * 1000));
|
||||
const points = new Map<string, UserProfileDailyPoint>();
|
||||
for (let index = 0; index < 14; index += 1) {
|
||||
const date = new Date(firstDay.getTime() + index * 24 * 60 * 60 * 1000);
|
||||
points.set(isoDay(date), {
|
||||
date: isoDay(date),
|
||||
activityCount: 0,
|
||||
completedIssues: 0,
|
||||
costCents: 0,
|
||||
inputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
outputTokens: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const activityDay = dayKeyExpr(sql`${activityLog.createdAt}`);
|
||||
const activityRows = await db
|
||||
.select({
|
||||
date: activityDay,
|
||||
count: sql<number>`count(*)::int`,
|
||||
})
|
||||
.from(activityLog)
|
||||
.where(
|
||||
and(
|
||||
eq(activityLog.companyId, companyId),
|
||||
eq(activityLog.actorType, "user"),
|
||||
eq(activityLog.actorId, userId),
|
||||
gte(activityLog.createdAt, firstDay),
|
||||
),
|
||||
)
|
||||
.groupBy(activityDay);
|
||||
|
||||
for (const row of activityRows) {
|
||||
const point = points.get(row.date);
|
||||
if (point) point.activityCount = Number(row.count);
|
||||
}
|
||||
|
||||
const completedDay = dayKeyExpr(sql`${issues.completedAt}`);
|
||||
const completedRows = await db
|
||||
.select({
|
||||
date: completedDay,
|
||||
count: sql<number>`count(distinct ${issues.id})::int`,
|
||||
})
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
isNull(issues.hiddenAt),
|
||||
eq(issues.status, "done"),
|
||||
gte(issues.completedAt, firstDay),
|
||||
userIssueInvolvementSql(companyId, userId),
|
||||
),
|
||||
)
|
||||
.groupBy(completedDay);
|
||||
|
||||
for (const row of completedRows) {
|
||||
const point = points.get(row.date);
|
||||
if (point) point.completedIssues = Number(row.count);
|
||||
}
|
||||
|
||||
const costDay = dayKeyExpr(sql`${costEvents.occurredAt}`);
|
||||
const costRows = await db
|
||||
.select({
|
||||
date: costDay,
|
||||
costCents: sumNumber(costEvents.costCents),
|
||||
inputTokens: sumNumber(costEvents.inputTokens),
|
||||
cachedInputTokens: sumNumber(costEvents.cachedInputTokens),
|
||||
outputTokens: sumNumber(costEvents.outputTokens),
|
||||
})
|
||||
.from(costEvents)
|
||||
.innerJoin(issues, and(eq(issues.id, costEvents.issueId), eq(issues.companyId, costEvents.companyId)))
|
||||
.where(
|
||||
and(
|
||||
eq(costEvents.companyId, companyId),
|
||||
gte(costEvents.occurredAt, firstDay),
|
||||
userIssueInvolvementSql(companyId, userId),
|
||||
),
|
||||
)
|
||||
.groupBy(costDay);
|
||||
|
||||
for (const row of costRows) {
|
||||
const point = points.get(row.date);
|
||||
if (!point) continue;
|
||||
point.costCents = Number(row.costCents);
|
||||
point.inputTokens = Number(row.inputTokens);
|
||||
point.cachedInputTokens = Number(row.cachedInputTokens);
|
||||
point.outputTokens = Number(row.outputTokens);
|
||||
}
|
||||
|
||||
return [...points.values()];
|
||||
}
|
||||
|
||||
export function userProfileRoutes(db: Db) {
|
||||
const router = Router();
|
||||
|
||||
router.get("/companies/:companyId/users/:userSlug/profile", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
const userSlug = req.params.userSlug as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
|
||||
const row = await resolveCompanyUser(db, companyId, userSlug);
|
||||
if (!row) throw notFound("User not found");
|
||||
const canonicalSlug = userSlugCandidates(row)[0] ?? row.principalId;
|
||||
const userId = row.userId ?? row.principalId;
|
||||
|
||||
const [stats, daily, recentIssues, recentActivity, topAgents, topProviders] = await Promise.all([
|
||||
Promise.all(
|
||||
PROFILE_WINDOWS.map((entry) =>
|
||||
loadWindowStats(db, companyId, userId, entry.key, entry.label, windowStart(entry.days)),
|
||||
),
|
||||
),
|
||||
loadDailyStats(db, companyId, userId),
|
||||
db
|
||||
.select({
|
||||
id: issues.id,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
priority: issues.priority,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
updatedAt: issues.updatedAt,
|
||||
completedAt: issues.completedAt,
|
||||
})
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
isNull(issues.hiddenAt),
|
||||
userIssueInvolvementSql(companyId, userId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(issues.updatedAt))
|
||||
.limit(8),
|
||||
db
|
||||
.select({
|
||||
id: activityLog.id,
|
||||
action: activityLog.action,
|
||||
entityType: activityLog.entityType,
|
||||
entityId: activityLog.entityId,
|
||||
details: activityLog.details,
|
||||
createdAt: activityLog.createdAt,
|
||||
})
|
||||
.from(activityLog)
|
||||
.where(
|
||||
and(
|
||||
eq(activityLog.companyId, companyId),
|
||||
eq(activityLog.actorType, "user"),
|
||||
eq(activityLog.actorId, userId),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(activityLog.createdAt))
|
||||
.limit(12),
|
||||
db
|
||||
.select({
|
||||
agentId: costEvents.agentId,
|
||||
agentName: agents.name,
|
||||
costCents: sumNumber(costEvents.costCents),
|
||||
inputTokens: sumNumber(costEvents.inputTokens),
|
||||
cachedInputTokens: sumNumber(costEvents.cachedInputTokens),
|
||||
outputTokens: sumNumber(costEvents.outputTokens),
|
||||
})
|
||||
.from(costEvents)
|
||||
.innerJoin(issues, and(eq(issues.id, costEvents.issueId), eq(issues.companyId, costEvents.companyId)))
|
||||
.leftJoin(agents, eq(agents.id, costEvents.agentId))
|
||||
.where(and(eq(costEvents.companyId, companyId), userIssueInvolvementSql(companyId, userId)))
|
||||
.groupBy(costEvents.agentId, agents.name)
|
||||
.orderBy(desc(sumNumber(costEvents.costCents)))
|
||||
.limit(5),
|
||||
db
|
||||
.select({
|
||||
provider: costEvents.provider,
|
||||
biller: costEvents.biller,
|
||||
model: costEvents.model,
|
||||
costCents: sumNumber(costEvents.costCents),
|
||||
inputTokens: sumNumber(costEvents.inputTokens),
|
||||
cachedInputTokens: sumNumber(costEvents.cachedInputTokens),
|
||||
outputTokens: sumNumber(costEvents.outputTokens),
|
||||
})
|
||||
.from(costEvents)
|
||||
.innerJoin(issues, and(eq(issues.id, costEvents.issueId), eq(issues.companyId, costEvents.companyId)))
|
||||
.where(and(eq(costEvents.companyId, companyId), userIssueInvolvementSql(companyId, userId)))
|
||||
.groupBy(costEvents.provider, costEvents.biller, costEvents.model)
|
||||
.orderBy(desc(sumNumber(costEvents.costCents)))
|
||||
.limit(5),
|
||||
]);
|
||||
|
||||
const user: UserProfileIdentity = {
|
||||
id: userId,
|
||||
slug: canonicalSlug,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
image: row.image,
|
||||
membershipRole: row.membershipRole,
|
||||
membershipStatus: row.status,
|
||||
joinedAt: row.createdAt,
|
||||
};
|
||||
|
||||
const payload: UserProfileResponse = {
|
||||
user,
|
||||
stats,
|
||||
daily,
|
||||
recentIssues: recentIssues.map((issue) => ({
|
||||
...issue,
|
||||
status: issue.status as UserProfileResponse["recentIssues"][number]["status"],
|
||||
priority: issue.priority as UserProfileResponse["recentIssues"][number]["priority"],
|
||||
})),
|
||||
recentActivity,
|
||||
topAgents: topAgents.map((entry) => ({
|
||||
...entry,
|
||||
costCents: Number(entry.costCents),
|
||||
inputTokens: Number(entry.inputTokens),
|
||||
cachedInputTokens: Number(entry.cachedInputTokens),
|
||||
outputTokens: Number(entry.outputTokens),
|
||||
})),
|
||||
topProviders: topProviders.map((entry) => ({
|
||||
...entry,
|
||||
costCents: Number(entry.costCents),
|
||||
inputTokens: Number(entry.inputTokens),
|
||||
cachedInputTokens: Number(entry.cachedInputTokens),
|
||||
outputTokens: Number(entry.outputTokens),
|
||||
})),
|
||||
};
|
||||
|
||||
res.json(payload);
|
||||
});
|
||||
|
||||
return router;
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import { Issues } from "./pages/Issues";
|
||||
import { IssueDetail } from "./pages/IssueDetail";
|
||||
import { Routines } from "./pages/Routines";
|
||||
import { RoutineDetail } from "./pages/RoutineDetail";
|
||||
import { UserProfile } from "./pages/UserProfile";
|
||||
import { ExecutionWorkspaceDetail } from "./pages/ExecutionWorkspaceDetail";
|
||||
import { Goals } from "./pages/Goals";
|
||||
import { GoalDetail } from "./pages/GoalDetail";
|
||||
@@ -117,6 +118,7 @@ function boardRoutes() {
|
||||
<Route path="inbox/all" element={<Inbox />} />
|
||||
<Route path="inbox/requests" element={<JoinRequestQueue />} />
|
||||
<Route path="inbox/new" element={<Navigate to="/inbox/mine" replace />} />
|
||||
<Route path="u/:userSlug" element={<UserProfile />} />
|
||||
<Route path="design-guide" element={<DesignGuide />} />
|
||||
<Route path="tests/ux/chat" element={<IssueChatUxLab />} />
|
||||
<Route path="tests/ux/invites" element={<InviteUxLab />} />
|
||||
@@ -277,6 +279,7 @@ export function App() {
|
||||
<Route path="issues/:issueId" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="routines" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="routines/:routineId" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="u/:userSlug" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="skills/*" element={<UnprefixedBoardRedirect />} />
|
||||
<Route path="settings" element={<LegacySettingsRedirect />} />
|
||||
<Route path="settings/*" element={<LegacySettingsRedirect />} />
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { UserProfileResponse } from "@paperclipai/shared";
|
||||
import { api } from "./client";
|
||||
|
||||
export const userProfilesApi = {
|
||||
get: (companyId: string, userSlug: string) =>
|
||||
api.get<UserProfileResponse>(
|
||||
`/companies/${companyId}/users/${encodeURIComponent(userSlug)}/profile`,
|
||||
),
|
||||
};
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type LucideIcon,
|
||||
Moon,
|
||||
Settings,
|
||||
UserRound,
|
||||
Sun,
|
||||
UserRoundPen,
|
||||
} from "lucide-react";
|
||||
@@ -45,6 +46,20 @@ function deriveInitials(name: string) {
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function deriveUserSlug(name: string | null | undefined, email: string | null | undefined, id: string | null | undefined) {
|
||||
const candidates = [name, email?.split("@")[0], email, id];
|
||||
for (const candidate of candidates) {
|
||||
const slug = candidate
|
||||
?.trim()
|
||||
.toLowerCase()
|
||||
.replace(/['"]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
if (slug) return slug;
|
||||
}
|
||||
return "me";
|
||||
}
|
||||
|
||||
function MenuAction({ label, description, icon: Icon, onClick, href, external = false }: MenuActionProps) {
|
||||
const className =
|
||||
"flex w-full items-start gap-3 rounded-xl px-3 py-3 text-left transition-colors hover:bg-accent/60";
|
||||
@@ -112,6 +127,7 @@ export function SidebarAccountMenu({
|
||||
session?.user.email?.trim() || (deploymentMode === "authenticated" ? "Signed in" : "Local workspace board");
|
||||
const accountBadge = deploymentMode === "authenticated" ? "Account" : "Local";
|
||||
const initials = deriveInitials(displayName);
|
||||
const profileHref = `/u/${deriveUserSlug(session?.user.name, session?.user.email, session?.user.id)}`;
|
||||
|
||||
function closeNavigationChrome() {
|
||||
setOpen(false);
|
||||
@@ -164,6 +180,13 @@ export function SidebarAccountMenu({
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-1">
|
||||
<MenuAction
|
||||
label="View profile"
|
||||
description="Open your activity, task, and usage ledger."
|
||||
icon={UserRound}
|
||||
href={profileHref}
|
||||
onClick={closeNavigationChrome}
|
||||
/>
|
||||
<MenuAction
|
||||
label="Edit profile"
|
||||
description="Update your display name and avatar."
|
||||
|
||||
@@ -15,6 +15,7 @@ const BOARD_ROUTE_ROOTS = new Set([
|
||||
"usage",
|
||||
"activity",
|
||||
"inbox",
|
||||
"u",
|
||||
"design-guide",
|
||||
]);
|
||||
|
||||
|
||||
@@ -120,6 +120,8 @@ export const queryKeys = {
|
||||
providers: (companyId: string) => ["secret-providers", companyId] as const,
|
||||
},
|
||||
dashboard: (companyId: string) => ["dashboard", companyId] as const,
|
||||
userProfile: (companyId: string, userSlug: string) =>
|
||||
["user-profile", companyId, userSlug] as const,
|
||||
sidebarBadges: (companyId: string) => ["sidebar-badges", companyId] as const,
|
||||
inboxDismissals: (companyId: string) => ["inbox-dismissals", companyId] as const,
|
||||
activity: (companyId: string) => ["activity", companyId] as const,
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
BarChart3,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
Coins,
|
||||
Hash,
|
||||
MessageSquareText,
|
||||
Ticket,
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type { UserProfileDailyPoint, UserProfileWindowStats } from "@paperclipai/shared";
|
||||
import { Link, useParams } from "@/lib/router";
|
||||
import { userProfilesApi } from "../api/userProfiles";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "../components/ui/avatar";
|
||||
import { Badge } from "../components/ui/badge";
|
||||
import { EmptyState } from "../components/EmptyState";
|
||||
import { PageSkeleton } from "../components/PageSkeleton";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { useCompany } from "../context/CompanyContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { cn, formatCents, formatDate, formatShortDate, formatTokens, issueUrl, providerDisplayName, relativeTime } from "../lib/utils";
|
||||
|
||||
const NO_COMPANY = "__none__";
|
||||
|
||||
function initials(name: string | null | undefined) {
|
||||
const value = name?.trim() || "User";
|
||||
const parts = value.split(/\s+/).filter(Boolean);
|
||||
if (parts.length > 1) return `${parts[0]?.[0] ?? ""}${parts[parts.length - 1]?.[0] ?? ""}`.toUpperCase();
|
||||
return value.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
function totalTokens(stats: Pick<UserProfileWindowStats, "inputTokens" | "cachedInputTokens" | "outputTokens">) {
|
||||
return stats.inputTokens + stats.cachedInputTokens + stats.outputTokens;
|
||||
}
|
||||
|
||||
function completionRate(stats: UserProfileWindowStats) {
|
||||
if (stats.touchedIssues === 0) return "0%";
|
||||
return `${Math.round((stats.completedIssues / stats.touchedIssues) * 100)}%`;
|
||||
}
|
||||
|
||||
function StatPill({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
tone = "default",
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
value: string;
|
||||
tone?: "default" | "green" | "amber" | "blue";
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"min-w-0 border border-border bg-background p-3",
|
||||
tone === "green" && "border-emerald-500/30 bg-emerald-500/5",
|
||||
tone === "amber" && "border-amber-500/30 bg-amber-500/5",
|
||||
tone === "blue" && "border-sky-500/30 bg-sky-500/5",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[11px] font-medium uppercase text-muted-foreground">
|
||||
<Icon className="size-3.5 shrink-0" />
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
<div className="mt-2 truncate text-xl font-semibold tabular-nums">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WindowPanel({ stats }: { stats: UserProfileWindowStats }) {
|
||||
return (
|
||||
<div className="border border-border bg-card p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-sm font-semibold">{stats.label}</h2>
|
||||
<Badge variant="outline" className="font-mono">{formatTokens(totalTokens(stats))}</Badge>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||
<StatPill icon={Ticket} label="Touched" value={String(stats.touchedIssues)} />
|
||||
<StatPill icon={CheckCircle2} label="Done" value={String(stats.completedIssues)} tone="green" />
|
||||
<StatPill icon={MessageSquareText} label="Comments" value={String(stats.commentCount)} tone="blue" />
|
||||
<StatPill icon={Coins} label="Spend" value={formatCents(stats.costCents)} tone="amber" />
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-3 divide-x divide-border border border-border text-center">
|
||||
<div className="p-2">
|
||||
<div className="text-xs text-muted-foreground">Created</div>
|
||||
<div className="text-sm font-semibold tabular-nums">{stats.createdIssues}</div>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<div className="text-xs text-muted-foreground">Open</div>
|
||||
<div className="text-sm font-semibold tabular-nums">{stats.assignedOpenIssues}</div>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<div className="text-xs text-muted-foreground">Rate</div>
|
||||
<div className="text-sm font-semibold tabular-nums">{completionRate(stats)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageChart({ points }: { points: UserProfileDailyPoint[] }) {
|
||||
const maxTokens = Math.max(1, ...points.map((point) => totalTokens(point)));
|
||||
const maxActivity = Math.max(1, ...points.map((point) => point.activityCount + point.completedIssues));
|
||||
|
||||
return (
|
||||
<section className="border border-border bg-card p-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">Two-week signal</h2>
|
||||
<p className="text-sm text-muted-foreground">Token pressure, user actions, and completed work by day.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span className="inline-flex items-center gap-1.5"><span className="size-2 bg-sky-500" /> tokens</span>
|
||||
<span className="inline-flex items-center gap-1.5"><span className="size-2 bg-emerald-500" /> activity</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 grid h-44 grid-cols-[repeat(14,minmax(0,1fr))] items-end gap-1 sm:gap-2">
|
||||
{points.map((point) => {
|
||||
const tokenHeight = Math.max(6, Math.round((totalTokens(point) / maxTokens) * 100));
|
||||
const activityHeight = Math.max(4, Math.round(((point.activityCount + point.completedIssues) / maxActivity) * 72));
|
||||
return (
|
||||
<div key={point.date} className="group flex h-full min-w-0 flex-col justify-end gap-1">
|
||||
<div className="flex h-full items-end justify-center gap-1">
|
||||
<div
|
||||
className="w-full max-w-5 border border-sky-500/40 bg-sky-500/20 transition-colors group-hover:bg-sky-500/35"
|
||||
style={{ height: `${tokenHeight}%` }}
|
||||
title={`${formatShortDate(point.date)}: ${formatTokens(totalTokens(point))} tokens`}
|
||||
/>
|
||||
<div
|
||||
className="w-full max-w-3 border border-emerald-500/40 bg-emerald-500/25 transition-colors group-hover:bg-emerald-500/40"
|
||||
style={{ height: `${activityHeight}%` }}
|
||||
title={`${formatShortDate(point.date)}: ${point.activityCount} actions, ${point.completedIssues} completed`}
|
||||
/>
|
||||
</div>
|
||||
<div className="truncate text-center text-[10px] text-muted-foreground">{new Date(point.date).getUTCDate()}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function UsageList({
|
||||
title,
|
||||
empty,
|
||||
rows,
|
||||
}: {
|
||||
title: string;
|
||||
empty: string;
|
||||
rows: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
sublabel: string;
|
||||
costCents: number;
|
||||
inputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
outputTokens: number;
|
||||
}>;
|
||||
}) {
|
||||
return (
|
||||
<section className="border border-border bg-card p-5">
|
||||
<h2 className="text-base font-semibold">{title}</h2>
|
||||
<div className="mt-4 divide-y divide-border border border-border">
|
||||
{rows.length === 0 ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">{empty}</div>
|
||||
) : rows.map((row) => (
|
||||
<div key={row.key} className="grid gap-3 p-3 sm:grid-cols-[1fr_auto] sm:items-center">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{row.label}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{row.sublabel}</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 text-xs tabular-nums sm:justify-end">
|
||||
<Badge variant="outline">{formatTokens(totalTokens(row))}</Badge>
|
||||
<Badge variant="secondary">{formatCents(row.costCents)}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserProfile() {
|
||||
const { userSlug = "" } = useParams<{ userSlug: string }>();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const { setBreadcrumbs } = useBreadcrumbs();
|
||||
const companyId = selectedCompanyId ?? NO_COMPANY;
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: queryKeys.userProfile(companyId, userSlug),
|
||||
queryFn: () => userProfilesApi.get(companyId, userSlug),
|
||||
enabled: !!selectedCompanyId && !!userSlug,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([{ label: "Users" }, { label: data?.user.name ?? userSlug }]);
|
||||
}, [data?.user.name, setBreadcrumbs, userSlug]);
|
||||
|
||||
const allTime = data?.stats.find((entry) => entry.key === "all");
|
||||
const last7 = data?.stats.find((entry) => entry.key === "last7");
|
||||
const displayName = data?.user.name?.trim() || data?.user.email?.split("@")[0] || "User";
|
||||
|
||||
const agentUsageRows = useMemo(
|
||||
() =>
|
||||
(data?.topAgents ?? []).map((row) => ({
|
||||
key: row.agentId,
|
||||
label: row.agentName ?? row.agentId.slice(0, 8),
|
||||
sublabel: "Issue-linked token usage",
|
||||
costCents: row.costCents,
|
||||
inputTokens: row.inputTokens,
|
||||
cachedInputTokens: row.cachedInputTokens,
|
||||
outputTokens: row.outputTokens,
|
||||
})),
|
||||
[data?.topAgents],
|
||||
);
|
||||
|
||||
const providerUsageRows = useMemo(
|
||||
() =>
|
||||
(data?.topProviders ?? []).map((row) => ({
|
||||
key: `${row.provider}:${row.biller}:${row.model}`,
|
||||
label: `${providerDisplayName(row.provider)} / ${row.model}`,
|
||||
sublabel: `Billed through ${providerDisplayName(row.biller)}`,
|
||||
costCents: row.costCents,
|
||||
inputTokens: row.inputTokens,
|
||||
cachedInputTokens: row.cachedInputTokens,
|
||||
outputTokens: row.outputTokens,
|
||||
})),
|
||||
[data?.topProviders],
|
||||
);
|
||||
|
||||
if (!selectedCompanyId) {
|
||||
return <EmptyState icon={UserRound} message="Select a company to view user profiles." />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return <PageSkeleton variant="dashboard" />;
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return <EmptyState icon={AlertCircle} message="User profile not found for this company." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<section className="border border-border bg-[linear-gradient(180deg,hsl(var(--card))_0%,hsl(var(--background))_100%)]">
|
||||
<div className="grid gap-6 p-5 lg:grid-cols-[minmax(0,1fr)_360px] lg:p-7">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-start gap-5">
|
||||
<Avatar className="size-20 border border-border" size="lg">
|
||||
{data.user.image ? <AvatarImage src={data.user.image} alt={displayName} /> : null}
|
||||
<AvatarFallback className="text-xl font-semibold">{initials(displayName)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="min-w-0 truncate text-3xl font-semibold tracking-normal">{displayName}</h1>
|
||||
<Badge variant="outline">@{data.user.slug}</Badge>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-sm text-muted-foreground">
|
||||
{data.user.email ? <span className="truncate">{data.user.email}</span> : null}
|
||||
<span>{data.user.membershipRole ?? "member"}</span>
|
||||
<span>{data.user.membershipStatus}</span>
|
||||
<span>joined {formatDate(data.user.joinedAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-7 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<StatPill icon={Coins} label="All-time tokens" value={formatTokens(allTime ? totalTokens(allTime) : 0)} tone="blue" />
|
||||
<StatPill icon={CheckCircle2} label="Completed" value={String(allTime?.completedIssues ?? 0)} tone="green" />
|
||||
<StatPill icon={Clock3} label="Open assigned" value={String(allTime?.assignedOpenIssues ?? 0)} tone="amber" />
|
||||
<StatPill icon={Activity} label="7-day actions" value={String(last7?.activityCount ?? 0)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border border-border bg-background p-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Hash className="size-4 text-muted-foreground" />
|
||||
Profile ledger
|
||||
</div>
|
||||
<div className="mt-4 space-y-3 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">All-time spend</span>
|
||||
<span className="font-semibold tabular-nums">{formatCents(allTime?.costCents ?? 0)}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">Attributed cost events</span>
|
||||
<span className="font-semibold tabular-nums">{allTime?.costEventCount ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">Comments</span>
|
||||
<span className="font-semibold tabular-nums">{allTime?.commentCount ?? 0}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-muted-foreground">Completion rate</span>
|
||||
<span className="font-semibold tabular-nums">{allTime ? completionRate(allTime) : "0%"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-3">
|
||||
{data.stats.map((entry) => <WindowPanel key={entry.key} stats={entry} />)}
|
||||
</div>
|
||||
|
||||
<UsageChart points={data.daily} />
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<section className="border border-border bg-card p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold">Recent tasks</h2>
|
||||
<Badge variant="outline">{data.recentIssues.length}</Badge>
|
||||
</div>
|
||||
<div className="mt-4 divide-y divide-border border border-border">
|
||||
{data.recentIssues.length === 0 ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">No touched tasks yet.</div>
|
||||
) : data.recentIssues.map((issue) => (
|
||||
<Link
|
||||
key={issue.id}
|
||||
to={issueUrl(issue)}
|
||||
className="grid gap-2 p-3 transition-colors hover:bg-accent/50 sm:grid-cols-[1fr_auto] sm:items-center"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{issue.identifier ?? issue.id.slice(0, 8)}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">{issue.title}</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 sm:justify-end">
|
||||
<StatusBadge status={issue.status} />
|
||||
<span className="text-xs text-muted-foreground">{relativeTime(issue.updatedAt)}</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="border border-border bg-card p-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold">Recent activity</h2>
|
||||
<Badge variant="outline">{data.recentActivity.length}</Badge>
|
||||
</div>
|
||||
<div className="mt-4 divide-y divide-border border border-border">
|
||||
{data.recentActivity.length === 0 ? (
|
||||
<div className="p-4 text-sm text-muted-foreground">No direct user actions recorded yet.</div>
|
||||
) : data.recentActivity.map((event) => (
|
||||
<div key={event.id} className="grid gap-2 p-3 sm:grid-cols-[1fr_auto] sm:items-center">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{event.action.replaceAll("_", " ")}</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{event.entityType} - {event.entityId.slice(0, 12)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{relativeTime(event.createdAt)}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-2">
|
||||
<UsageList title="Agent attribution" empty="No issue-linked token usage yet." rows={agentUsageRows} />
|
||||
<UsageList title="Provider mix" empty="No provider usage attributed yet." rows={providerUsageRows} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user