From 855cebbf7c24a3f2a4158bc790a8c322c6985755 Mon Sep 17 00:00:00 2001 From: Berget1411 <107999361+Berget1411@users.noreply.github.com> Date: Sun, 12 Apr 2026 21:09:01 +0200 Subject: [PATCH 1/2] Rework app navigation and shared page shells - Add shared page, surface, and row action components - Restructure app navigation into track, review, and manage sections - Refresh client, tracker, reports, and home layouts --- apps/web/src/components/app-page-shell.tsx | 123 +++ apps/web/src/components/archived-badge.tsx | 18 + apps/web/src/components/row-actions.tsx | 94 +++ .../auth/components/oauth-buttons.tsx | 3 +- .../auth/components/settings-dialog.tsx | 222 +++--- .../clients/components/client-row-actions.tsx | 96 +-- .../features/clients/pages/clients-page.tsx | 191 +++-- .../web/src/features/home/pages/home-page.tsx | 23 +- .../src/features/navigation/app-navigation.ts | 196 +++++ .../components/app-layout-shell.tsx | 32 +- .../{nav-main.tsx => app-sidebar-section.tsx} | 29 +- .../navigation/components/app-sidebar.tsx | 91 +-- .../navigation/components/nav-manage.tsx | 45 -- .../navigation/components/settings-dialog.tsx | 365 --------- .../navigation/components/team-switcher.tsx | 13 +- .../organization/components/members-table.tsx | 166 ++-- .../pages/accept-invitation-page.tsx | 2 +- .../organization/pages/teams-page.tsx | 25 +- .../components/project-row-actions.tsx | 100 +-- .../features/projects/pages/projects-page.tsx | 398 +++++----- .../features/tags/components/tags-table.tsx | 144 ++-- .../web/src/features/tags/pages/tags-page.tsx | 22 +- .../src/features/tasks/pages/tasks-page.tsx | 82 +- .../components/activity-inline-editor.tsx | 13 +- .../components/activity-reference-input.tsx | 51 +- .../time-tracker/components/activity-row.tsx | 2 - .../components/billable-badge.tsx | 25 - .../components/calendar-active-timer-card.tsx | 2 +- .../components/calendar-entry-sheet.tsx | 22 +- .../components/calendar-toolbar.tsx | 27 +- .../time-tracker/components/charts.tsx | 224 ++++++ .../components/compact-billable-toggle.tsx | 31 - .../components/dashboard-charts.tsx | 135 ---- .../components/entry-metadata-fields.tsx | 10 - .../components/manual-entry-form.tsx | 13 +- .../components/reports-charts.tsx | 118 --- .../components/timer-entry-form.tsx | 232 +++--- .../time-tracker/constants/calendar.ts | 11 - .../features/time-tracker/constants/index.ts | 2 +- .../time-tracker/pages/calendar-page.tsx | 61 +- .../time-tracker/pages/reports-page.tsx | 263 +++---- .../time-tracker/pages/time-tracker-page.tsx | 146 +++- .../pages/tracker-dashboard-page.tsx | 133 ++-- .../styles/react-big-calendar.css | 10 +- .../features/time-tracker/utils/calendar.ts | 6 +- .../features/time-tracker/utils/dashboard.ts | 20 - .../features/time-tracker/utils/date-time.ts | 3 - .../features/time-tracker/utils/reports.ts | 27 +- apps/web/src/hooks/use-table-selection.ts | 49 ++ apps/web/src/public/logo.svg | 4 + apps/web/src/routeTree.gen.ts | 738 +++++++++++------- apps/web/src/routes/__root.tsx | 4 +- apps/web/src/routes/app._app.clients.tsx | 7 +- apps/web/src/routes/app._app.dashboard.tsx | 7 + apps/web/src/routes/app._app.index.tsx | 4 +- .../src/routes/app._app.manage.clients.tsx | 7 + apps/web/src/routes/app._app.manage.index.tsx | 7 + .../src/routes/app._app.manage.projects.tsx | 7 + apps/web/src/routes/app._app.manage.tags.tsx | 7 + apps/web/src/routes/app._app.manage.teams.tsx | 7 + apps/web/src/routes/app._app.manage.tsx | 9 + apps/web/src/routes/app._app.overview.tsx | 7 + apps/web/src/routes/app._app.projects.tsx | 7 +- apps/web/src/routes/app._app.tags.tsx | 7 +- apps/web/src/routes/app._app.teams.tsx | 7 +- apps/web/src/routes/app._app.tracker.tsx | 8 +- apps/web/src/routes/index.tsx | 5 +- apps/web/src/utils/format.ts | 17 + packages/auth/src/lib/email.ts | 6 +- packages/ui/src/styles/globals.css | 2 - 70 files changed, 2441 insertions(+), 2549 deletions(-) create mode 100644 apps/web/src/components/app-page-shell.tsx create mode 100644 apps/web/src/components/archived-badge.tsx create mode 100644 apps/web/src/components/row-actions.tsx create mode 100644 apps/web/src/features/navigation/app-navigation.ts rename apps/web/src/features/navigation/components/{nav-main.tsx => app-sidebar-section.tsx} (50%) delete mode 100644 apps/web/src/features/navigation/components/nav-manage.tsx delete mode 100644 apps/web/src/features/navigation/components/settings-dialog.tsx delete mode 100644 apps/web/src/features/time-tracker/components/billable-badge.tsx create mode 100644 apps/web/src/features/time-tracker/components/charts.tsx delete mode 100644 apps/web/src/features/time-tracker/components/compact-billable-toggle.tsx delete mode 100644 apps/web/src/features/time-tracker/components/dashboard-charts.tsx delete mode 100644 apps/web/src/features/time-tracker/components/reports-charts.tsx create mode 100644 apps/web/src/hooks/use-table-selection.ts create mode 100644 apps/web/src/public/logo.svg create mode 100644 apps/web/src/routes/app._app.dashboard.tsx create mode 100644 apps/web/src/routes/app._app.manage.clients.tsx create mode 100644 apps/web/src/routes/app._app.manage.index.tsx create mode 100644 apps/web/src/routes/app._app.manage.projects.tsx create mode 100644 apps/web/src/routes/app._app.manage.tags.tsx create mode 100644 apps/web/src/routes/app._app.manage.teams.tsx create mode 100644 apps/web/src/routes/app._app.manage.tsx create mode 100644 apps/web/src/routes/app._app.overview.tsx create mode 100644 apps/web/src/utils/format.ts diff --git a/apps/web/src/components/app-page-shell.tsx b/apps/web/src/components/app-page-shell.tsx new file mode 100644 index 0000000..d963e76 --- /dev/null +++ b/apps/web/src/components/app-page-shell.tsx @@ -0,0 +1,123 @@ +import type { ReactNode } from "react"; + +import { Badge } from "@open-learn/ui/components/badge"; +import { cn } from "@open-learn/ui/lib/utils"; + +export function AppPage({ children, className }: { children: ReactNode; className?: string }) { + return
{children}
; +} + +export function AppPageHeader({ + eyebrow, + title, + description, + actions, + children, + className, +}: { + eyebrow?: string; + title: string; + description?: string; + actions?: ReactNode; + children?: ReactNode; + className?: string; +}) { + return ( +
+
+ {eyebrow ? ( +

+ {eyebrow} +

+ ) : null} +

{title}

+ {description ? ( +

{description}

+ ) : null} + {children ?
{children}
: null} +
+ + {actions ? ( +
{actions}
+ ) : null} +
+ ); +} + +export function AppPageHeaderMeta({ + items, + className, +}: { + items: Array<{ label: string; value: string }>; + className?: string; +}) { + return ( +
+ {items.map((item) => ( + + {item.label} + {item.value} + + ))} +
+ ); +} + +export function AppSurface({ children, className }: { children: ReactNode; className?: string }) { + return ( +
+ {children} +
+ ); +} + +export function AppSurfaceHeader({ + label, + title, + description, + actions, + className, +}: { + label?: string; + title?: string; + description?: string; + actions?: ReactNode; + className?: string; +}) { + return ( +
+
+ {label ? ( +

+ {label} +

+ ) : null} + {title ?

{title}

: null} + {description ?

{description}

: null} +
+ + {actions ? ( +
{actions}
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/archived-badge.tsx b/apps/web/src/components/archived-badge.tsx new file mode 100644 index 0000000..b856de2 --- /dev/null +++ b/apps/web/src/components/archived-badge.tsx @@ -0,0 +1,18 @@ +import { cn } from "@open-learn/ui/lib/utils"; + +interface ArchivedBadgeProps { + className?: string; +} + +export function ArchivedBadge({ className }: ArchivedBadgeProps) { + return ( + + archived + + ); +} diff --git a/apps/web/src/components/row-actions.tsx b/apps/web/src/components/row-actions.tsx new file mode 100644 index 0000000..a002d9b --- /dev/null +++ b/apps/web/src/components/row-actions.tsx @@ -0,0 +1,94 @@ +import type { ReactNode } from "react"; + +import { ArchiveIcon, ArchiveRestoreIcon, MoreVerticalIcon, Trash2Icon } from "lucide-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@open-learn/ui/components/alert-dialog"; +import { Button } from "@open-learn/ui/components/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@open-learn/ui/components/dropdown-menu"; + +interface RowActionsProps { + isArchived: boolean; + onToggleArchive: () => void; + isArchivePending: boolean; + onDelete: () => void; + deleteTitle: string; + deleteDescription: ReactNode; +} + +export function RowActions({ + isArchived, + onToggleArchive, + isArchivePending, + onDelete, + deleteTitle, + deleteDescription, +}: RowActionsProps) { + return ( + + + + + + + + {isArchived ? ( + <> + + Unarchive + + ) : ( + <> + + Archive + + )} + + + + e.preventDefault()} + > + + Delete + + + + + + + + {deleteTitle} + {deleteDescription} + + + Cancel + + Delete + + + + + ); +} diff --git a/apps/web/src/features/auth/components/oauth-buttons.tsx b/apps/web/src/features/auth/components/oauth-buttons.tsx index 61af5cb..3c942e4 100644 --- a/apps/web/src/features/auth/components/oauth-buttons.tsx +++ b/apps/web/src/features/auth/components/oauth-buttons.tsx @@ -33,9 +33,10 @@ export default function OAuthButtons({ invitationId }: { invitationId?: string } } return ( -
+
-
- )} - - + ({ + canSubmit: state.canSubmit, + isSubmitting: state.isSubmitting, + })} + > + {({ canSubmit, isSubmitting }) => ( +
+ +
+ )} +
+ + ) : null} diff --git a/apps/web/src/features/clients/components/client-row-actions.tsx b/apps/web/src/features/clients/components/client-row-actions.tsx index b8b63ef..0504f9a 100644 --- a/apps/web/src/features/clients/components/client-row-actions.tsx +++ b/apps/web/src/features/clients/components/client-row-actions.tsx @@ -1,25 +1,6 @@ import type { Client } from "@open-learn/api/modules/client/client.schema"; -import { MoreVerticalIcon, ArchiveIcon, ArchiveRestoreIcon, Trash2Icon } from "lucide-react"; -import { Button } from "@open-learn/ui/components/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@open-learn/ui/components/dropdown-menu"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@open-learn/ui/components/alert-dialog"; +import { RowActions } from "@/components/row-actions"; import { useDeleteClient, useUpdateClient } from "../services/mutations"; @@ -31,68 +12,19 @@ export function ClientRowActions({ client }: ClientRowActionsProps) { const updateClient = useUpdateClient(); const deleteClient = useDeleteClient(); - function handleToggleArchive() { - updateClient.mutate({ id: client.id, isArchived: !client.isArchived }); - } - - function handleDelete() { - deleteClient.mutate({ id: client.id }); - } - return ( - - - - - - - - {client.isArchived ? ( - <> - - Unarchive - - ) : ( - <> - - Archive - - )} - - - - e.preventDefault()} - > - - Delete - - - - - - - - Delete client - - Are you sure you want to permanently delete{" "} - {client.name}? This action cannot be undone. - - - - Cancel - - Delete - - - - + updateClient.mutate({ id: client.id, isArchived: !client.isArchived })} + isArchivePending={updateClient.isPending} + onDelete={() => deleteClient.mutate({ id: client.id })} + deleteTitle="Delete client" + deleteDescription={ + <> + Are you sure you want to permanently delete{" "} + {client.name}? This action cannot be undone. + + } + /> ); } diff --git a/apps/web/src/features/clients/pages/clients-page.tsx b/apps/web/src/features/clients/pages/clients-page.tsx index 1b98048..ad7a285 100644 --- a/apps/web/src/features/clients/pages/clients-page.tsx +++ b/apps/web/src/features/clients/pages/clients-page.tsx @@ -1,17 +1,25 @@ import type { Client } from "@open-learn/api/modules/client/client.schema"; import { useState } from "react"; -import { PencilIcon, SearchIcon, ChevronDownIcon } from "lucide-react"; +import { ChevronDownIcon, PencilIcon, SearchIcon } from "lucide-react"; + +import { + AppPage, + AppPageHeader, + AppPageHeaderMeta, + AppSurface, + AppSurfaceHeader, +} from "@/components/app-page-shell"; import { Button } from "@open-learn/ui/components/button"; import { Checkbox } from "@open-learn/ui/components/checkbox"; -import { Input } from "@open-learn/ui/components/input"; -import { Skeleton } from "@open-learn/ui/components/skeleton"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@open-learn/ui/components/dropdown-menu"; +import { Input } from "@open-learn/ui/components/input"; +import { Skeleton } from "@open-learn/ui/components/skeleton"; import { Table, TableBody, @@ -21,10 +29,13 @@ import { TableRow, } from "@open-learn/ui/components/table"; -import { useClientsQuery } from "../services/queries"; -import { useCreateClient } from "../services/mutations"; +import { ArchivedBadge } from "@/components/archived-badge"; +import { useTableSelection } from "@/hooks/use-table-selection"; + import { ClientInlineEditor } from "../components/client-inline-editor"; import { ClientRowActions } from "../components/client-row-actions"; +import { useCreateClient } from "../services/mutations"; +import { useClientsQuery } from "../services/queries"; type FilterMode = "active" | "archived" | "all"; @@ -39,99 +50,111 @@ export default function ClientsPage() { const [search, setSearch] = useState(""); const [editingId, setEditingId] = useState(null); const [newName, setNewName] = useState(""); - const [selectedIds, setSelectedIds] = useState>(new Set()); const showArchived = filter === "archived" || filter === "all"; const { data: clients, isLoading } = useClientsQuery(showArchived); const createClient = useCreateClient(); - const filtered = (clients ?? []).filter((c) => { - const matchesSearch = c.name.toLowerCase().includes(search.toLowerCase()); - if (filter === "active") return matchesSearch && !c.isArchived; - if (filter === "archived") return matchesSearch && c.isArchived; + const filtered = (clients ?? []).filter((client) => { + const matchesSearch = client.name.toLowerCase().includes(search.toLowerCase()); + + if (filter === "active") { + return matchesSearch && !client.isArchived; + } + + if (filter === "archived") { + return matchesSearch && client.isArchived; + } + return matchesSearch; }); + const { selectedIds, toggleSelect, toggleSelectAll, allSelected, someSelected } = + useTableSelection({ items: filtered }); + async function handleAdd() { - if (!newName.trim()) return; + if (!newName.trim()) { + return; + } + await createClient.mutateAsync({ name: newName.trim(), currency: "USD" }); setNewName(""); } - function toggleSelect(id: number) { - setSelectedIds((prev) => { - const next = new Set(prev); - next.has(id) ? next.delete(id) : next.add(id); - return next; - }); - } + const activeCount = (clients ?? []).filter((client) => !client.isArchived).length; - function toggleSelectAll() { - if (selectedIds.size === filtered.length) { - setSelectedIds(new Set()); - } else { - setSelectedIds(new Set(filtered.map((c) => c.id))); - } - } + return ( + + + + - const allSelected = filtered.length > 0 && selectedIds.size === filtered.length; - const someSelected = selectedIds.size > 0 && selectedIds.size < filtered.length; + + - return ( -
-

Clients

- - {/* Toolbar */} -
-
- {/* Filter dropdown */} - - - - - - setFilter("active")}>Show active - setFilter("archived")}> - Show archived - - setFilter("all")}>Show all - - - - {/* Search */} -
- +
+
+ + + + + + setFilter("active")}>Show active + setFilter("archived")}> + Show archived + + setFilter("all")}>Show all + + + +
+ + setSearch(event.target.value)} + placeholder="Search by name" + className="w-56 pl-8" + /> +
+
+ +
setSearch(e.target.value)} - placeholder="Search by name" - className="pl-8 w-56" + value={newName} + onChange={(event) => setNewName(event.target.value)} + placeholder="Add new client" + className="w-52" + onKeyDown={(event) => { + if (event.key === "Enter") { + void handleAdd(); + } + }} /> +
- {/* Add new client */} -
- setNewName(e.target.value)} - placeholder="Add new client" - className="w-52" - onKeyDown={(e) => { - if (e.key === "Enter") handleAdd(); - }} - /> - -
-
- - {/* Table */} -
@@ -178,8 +201,8 @@ export default function ClientsPage() { )}
-
-
+ + ); } @@ -202,11 +225,7 @@ function ClientTableRow({ client, selected, onToggleSelect, onEdit }: ClientTabl {client.name} - {client.isArchived && ( - - archived - - )} + {client.isArchived ? : null} {client.address ?? ""} {client.currency} @@ -231,8 +250,8 @@ function ClientTableRow({ client, selected, onToggleSelect, onEdit }: ClientTabl function ClientTableSkeleton() { return ( <> - {[1, 2, 3].map((i) => ( - + {[1, 2, 3].map((item) => ( + diff --git a/apps/web/src/features/home/pages/home-page.tsx b/apps/web/src/features/home/pages/home-page.tsx index 3ab58ae..2a50bc5 100644 --- a/apps/web/src/features/home/pages/home-page.tsx +++ b/apps/web/src/features/home/pages/home-page.tsx @@ -1,7 +1,8 @@ import { useState } from "react"; -import { ArrowUpRightIcon, ChevronRightIcon, Clock3Icon, ImageOffIcon } from "lucide-react"; +import { ArrowUpRightIcon, ChevronRightIcon, ImageOffIcon } from "lucide-react"; import { LazyMotion, domAnimation, m } from "motion/react"; +import { Badge } from "@open-learn/ui/components/badge"; import { Button } from "@open-learn/ui/components/button"; import { Link } from "@tanstack/react-router"; @@ -10,6 +11,7 @@ import heroAvif832 from "@/public/example-hero-832.avif"; import heroWebp from "@/public/example-hero.webp"; import heroWebp832 from "@/public/example-hero-832.webp"; import heroPng from "@/public/example-hero.png"; +import logoSvg from "@/public/logo.svg"; const GITHUB_URL = "https://github.com/Berget1411/open-clock"; @@ -41,16 +43,19 @@ export default function HomePage() {
- Open Clock + better clock + + Beta +