update
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "users" ADD COLUMN "full_name" VARCHAR(255);
|
||||||
@@ -10,6 +10,7 @@ datasource db {
|
|||||||
model User {
|
model User {
|
||||||
id String @id @db.VarChar(255)
|
id String @id @db.VarChar(255)
|
||||||
username String @db.VarChar(255)
|
username String @db.VarChar(255)
|
||||||
|
fullName String? @db.VarChar(255) @map("full_name")
|
||||||
email String @db.VarChar(255)
|
email String @db.VarChar(255)
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ export async function getUserInfo(tokenSet: TokenSet): Promise<AuthenticatedUser
|
|||||||
const id = String(claims.sub);
|
const id = String(claims.sub);
|
||||||
const username = String(userInfo.preferred_username || claims.preferred_username || claims.name || id);
|
const username = String(userInfo.preferred_username || claims.preferred_username || claims.name || id);
|
||||||
const email = String(userInfo.email || claims.email || '');
|
const email = String(userInfo.email || claims.email || '');
|
||||||
|
const fullName = String(userInfo.name || claims.name || '') || null;
|
||||||
|
|
||||||
if (!email) {
|
if (!email) {
|
||||||
throw new Error('Email not provided by OIDC provider');
|
throw new Error('Email not provided by OIDC provider');
|
||||||
@@ -100,6 +101,7 @@ export async function getUserInfo(tokenSet: TokenSet): Promise<AuthenticatedUser
|
|||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
username,
|
username,
|
||||||
|
fullName,
|
||||||
email,
|
email,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,11 +32,13 @@ export async function syncUser(user: AuthenticatedUser): Promise<void> {
|
|||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
update: {
|
update: {
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
fullName: user.fullName,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
username: user.username,
|
username: user.username,
|
||||||
|
fullName: user.fullName,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Request } from 'express';
|
|||||||
export interface AuthenticatedUser {
|
export interface AuthenticatedUser {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
fullName: string | null;
|
||||||
email: string;
|
email: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ export function Layout() {
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="min-h-screen bg-gray-50">
|
||||||
<Navbar />
|
<Navbar />
|
||||||
<main className="pt-4 pb-24 px-4 sm:px-6 lg:px-8">
|
<main className="pt-4 pb-24">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -114,8 +114,8 @@ export function Navbar() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-4">
|
<div className="flex items-center space-x-4">
|
||||||
<span className="text-sm text-gray-600 hidden sm:block">
|
<span className="text-sm font-medium text-gray-600 hidden sm:block">
|
||||||
{user?.username}
|
{user?.fullName || user?.username}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
|
|||||||
@@ -80,8 +80,8 @@ export function TimerWidget() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 p-4 shadow-lg">
|
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 py-4 shadow-lg">
|
||||||
<div className="max-w-7xl mx-auto flex items-center justify-center">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex items-center justify-center">
|
||||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
|
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary-600"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,8 +89,8 @@ export function TimerWidget() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 p-4 shadow-lg z-50">
|
<div className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 py-4 shadow-lg z-50">
|
||||||
<div className="max-w-7xl mx-auto flex flex-wrap sm:flex-nowrap items-center gap-2 sm:justify-between">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex flex-wrap sm:flex-nowrap items-center gap-2 sm:justify-between">
|
||||||
{ongoingTimer ? (
|
{ongoingTimer ? (
|
||||||
<>
|
<>
|
||||||
{/* Row 1 (mobile): timer + stop side by side. On sm+ dissolves into the parent flex row via contents. */}
|
{/* Row 1 (mobile): timer + stop side by side. On sm+ dissolves into the parent flex row via contents. */}
|
||||||
@@ -183,7 +183,7 @@ export function TimerWidget() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="max-w-7xl mx-auto mt-2">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-2">
|
||||||
<p className="text-sm text-red-600">{error}</p>
|
<p className="text-sm text-red-600">{error}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -19,14 +19,20 @@ export function StatisticsPage() {
|
|||||||
const { clients } = useClients();
|
const { clients } = useClients();
|
||||||
const { projects } = useProjects();
|
const { projects } = useProjects();
|
||||||
|
|
||||||
|
const filteredProjects = filters.clientId
|
||||||
|
? (projects ?? []).filter((p) => p.clientId === filters.clientId)
|
||||||
|
: projects;
|
||||||
|
|
||||||
const handleFilterChange = (
|
const handleFilterChange = (
|
||||||
key: keyof StatisticsFilters,
|
key: keyof StatisticsFilters,
|
||||||
value: string | undefined,
|
value: string | undefined,
|
||||||
) => {
|
) => {
|
||||||
setFilters((prev) => ({
|
setFilters((prev) => {
|
||||||
...prev,
|
const next = { ...prev, [key]: value || undefined };
|
||||||
[key]: value || undefined,
|
// When client changes, clear any project selection that may belong to a different client
|
||||||
}));
|
if (key === 'clientId') next.projectId = undefined;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearFilters = () => {
|
const clearFilters = () => {
|
||||||
@@ -132,7 +138,7 @@ export function StatisticsPage() {
|
|||||||
className="input"
|
className="input"
|
||||||
>
|
>
|
||||||
<option value="">All Projects</option>
|
<option value="">All Projects</option>
|
||||||
{projects?.map((project) => (
|
{filteredProjects?.map((project) => (
|
||||||
<option key={project.id} value={project.id}>
|
<option key={project.id} value={project.id}>
|
||||||
{project.name}
|
{project.name}
|
||||||
</option>
|
</option>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
|
fullName: string | null;
|
||||||
email: string;
|
email: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user