This commit is contained in:
phaichayon
2026-06-11 12:46:57 +07:00
parent 1993d88306
commit 7a390cf0df
720 changed files with 100919 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import { assetByIdQueryOptions, assetOptionsQueryOptions } from '@/features/assets/api/queries';
import { AssetActionForm } from '@/features/assets/components/asset-action-form';
import { getQueryClient } from '@/lib/query-client';
type PageProps = {
params: Promise<{ assetId: string }>;
};
export default async function AssetAssignPage({ params }: PageProps) {
const { assetId } = await params;
const session = await auth();
const canAssign =
session?.user?.systemRole === 'super_admin' ||
session?.user?.activePermissions.includes('asset:assign');
const queryClient = getQueryClient();
void queryClient.prefetchQuery(assetByIdQueryOptions(assetId));
void queryClient.prefetchQuery(assetOptionsQueryOptions());
return (
<PageContainer pageTitle='Asset Assignment' pageDescription='Assign this asset to an employee.' access={!!canAssign}>
<HydrationBoundary state={dehydrate(queryClient)}>
<AssetActionForm action='assign' assetId={assetId} />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,35 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import { assetByIdQueryOptions, assetOptionsQueryOptions } from '@/features/assets/api/queries';
import AssetForm from '@/features/assets/components/asset-form';
import { getQueryClient } from '@/lib/query-client';
type PageProps = {
params: Promise<{ assetId: string }>;
};
export default async function EditAssetPage({ params }: PageProps) {
const { assetId } = await params;
const session = await auth();
const canUpdate =
session?.user?.systemRole === 'super_admin' ||
session?.user?.activePermissions.includes('asset:update');
const queryClient = getQueryClient();
const assetQuery = assetByIdQueryOptions(assetId);
void queryClient.prefetchQuery(assetQuery);
void queryClient.prefetchQuery(assetOptionsQueryOptions());
const assetData = await queryClient.ensureQueryData(assetQuery);
return (
<PageContainer
pageTitle='Edit Asset'
pageDescription='Update asset information.'
access={!!canUpdate}
>
<HydrationBoundary state={dehydrate(queryClient)}>
<AssetForm initialData={assetData.asset} pageTitle='Edit Asset' />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,32 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import { assetByIdQueryOptions } from '@/features/assets/api/queries';
import { AssetDetailView } from '@/features/assets/components/asset-detail-view';
import { getQueryClient } from '@/lib/query-client';
type PageProps = {
params: Promise<{ assetId: string }>;
};
export default async function AssetDetailPage({ params }: PageProps) {
const { assetId } = await params;
const session = await auth();
const canRead =
session?.user?.systemRole === 'super_admin' ||
session?.user?.activePermissions.includes('asset:read');
const queryClient = getQueryClient();
void queryClient.prefetchQuery(assetByIdQueryOptions(assetId));
return (
<PageContainer
pageTitle='Asset Detail'
pageDescription='View asset details and history.'
access={!!canRead}
>
<HydrationBoundary state={dehydrate(queryClient)}>
<AssetDetailView assetId={assetId} />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,29 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import { assetByIdQueryOptions } from '@/features/assets/api/queries';
import { AssetActionForm } from '@/features/assets/components/asset-action-form';
import { getQueryClient } from '@/lib/query-client';
type PageProps = {
params: Promise<{ assetId: string }>;
};
export default async function AssetReturnPage({ params }: PageProps) {
const { assetId } = await params;
const session = await auth();
const canReturn =
session?.user?.systemRole === 'super_admin' ||
session?.user?.activePermissions.includes('asset:return');
const queryClient = getQueryClient();
void queryClient.prefetchQuery(assetByIdQueryOptions(assetId));
return (
<PageContainer pageTitle='Asset Return' pageDescription='Return this asset back into available stock.' access={!!canReturn}>
<HydrationBoundary state={dehydrate(queryClient)}>
<AssetActionForm action='return' assetId={assetId} />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,30 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import { assetByIdQueryOptions, assetOptionsQueryOptions } from '@/features/assets/api/queries';
import { AssetActionForm } from '@/features/assets/components/asset-action-form';
import { getQueryClient } from '@/lib/query-client';
type PageProps = {
params: Promise<{ assetId: string }>;
};
export default async function AssetTransferPage({ params }: PageProps) {
const { assetId } = await params;
const session = await auth();
const canTransfer =
session?.user?.systemRole === 'super_admin' ||
session?.user?.activePermissions.includes('asset:transfer');
const queryClient = getQueryClient();
void queryClient.prefetchQuery(assetByIdQueryOptions(assetId));
void queryClient.prefetchQuery(assetOptionsQueryOptions());
return (
<PageContainer pageTitle='Asset Transfer' pageDescription='Transfer this asset to another owner or location.' access={!!canTransfer}>
<HydrationBoundary state={dehydrate(queryClient)}>
<AssetActionForm action='transfer' assetId={assetId} />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,54 @@
import { redirect } from 'next/navigation';
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import { assetByIdQueryOptions, assetOptionsQueryOptions } from '@/features/assets/api/queries';
import { AssetActionForm } from '@/features/assets/components/asset-action-form';
import { getQueryClient } from '@/lib/query-client';
import type { SearchParams } from 'nuqs/server';
type PageProps = {
searchParams: Promise<SearchParams>;
};
export default async function AssignAssetPage({ searchParams }: PageProps) {
const params = await searchParams;
const assetId = typeof params.assetId === 'string' ? params.assetId : '';
if (assetId) {
redirect(`/dashboard/assets/${assetId}/assign`);
}
const session = await auth();
const canAssign =
session?.user?.systemRole === 'super_admin' ||
session?.user?.activePermissions.includes('asset:assign');
if (!assetId) {
return (
<PageContainer pageTitle='Assign Asset'>
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
Select an asset to assign.
</div>
</PageContainer>
);
}
const queryClient = getQueryClient();
void queryClient.prefetchQuery(assetByIdQueryOptions(assetId));
void queryClient.prefetchQuery(assetOptionsQueryOptions());
return (
<PageContainer pageTitle='Assign Asset' pageDescription='Assign an asset to an employee.'>
<HydrationBoundary state={dehydrate(queryClient)}>
{canAssign ? (
<AssetActionForm action='assign' assetId={assetId} />
) : (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to assign assets.
</div>
)}
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,27 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import { assetHistoryQueryOptions } from '@/features/assets/api/queries';
import { AssetHistoryView } from '@/features/assets/components/asset-history-view';
import { getQueryClient } from '@/lib/query-client';
export default async function AssetHistoryPage() {
const session = await auth();
const canRead =
session?.user?.systemRole === 'super_admin' ||
session?.user?.activePermissions.includes('asset:read');
const queryClient = getQueryClient();
void queryClient.prefetchQuery(assetHistoryQueryOptions());
return (
<PageContainer
pageTitle='Asset History'
pageDescription='Track all asset lifecycle movements.'
access={!!canRead}
>
<HydrationBoundary state={dehydrate(queryClient)}>
<AssetHistoryView />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,40 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import AssetForm from '@/features/assets/components/asset-form';
import { assetOptionsQueryOptions } from '@/features/assets/api/queries';
import { getQueryClient } from '@/lib/query-client';
export const metadata = {
title: 'Dashboard: New Asset'
};
export default async function NewAssetPage() {
const session = await auth();
const hasActiveOrganization = !!session?.user?.activeOrganizationId;
const canCreate =
session?.user?.systemRole === 'super_admin' ||
session?.user?.activePermissions.includes('asset:create');
const queryClient = getQueryClient();
if (canCreate && hasActiveOrganization) {
void queryClient.prefetchQuery(assetOptionsQueryOptions());
}
return (
<PageContainer
pageTitle='Create Asset'
pageDescription='Register a new IT asset.'
access={!!canCreate && hasActiveOrganization}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
Select an active workspace with asset-create access before creating assets.
</div>
}
>
<HydrationBoundary state={dehydrate(queryClient)}>
<AssetForm initialData={null} pageTitle='Create Asset' />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,48 @@
import Link from 'next/link';
import { SearchParams } from 'nuqs/server';
import { auth } from '@/auth';
import { Icons } from '@/components/icons';
import PageContainer from '@/components/layout/page-container';
import { buttonVariants } from '@/components/ui/button';
import AssetListingPage from '@/features/assets/components/asset-listing';
import { searchParamsCache } from '@/lib/searchparams';
import { cn } from '@/lib/utils';
export const metadata = {
title: 'Dashboard: Assets'
};
type PageProps = {
searchParams: Promise<SearchParams>;
};
export default async function AssetsPage(props: PageProps) {
const searchParams = await props.searchParams;
const session = await auth();
const canReadAssets =
session?.user?.systemRole === 'super_admin' || session?.user?.activePermissions.includes('asset:read');
searchParamsCache.parse(searchParams);
return (
<PageContainer
pageTitle='Asset List'
pageDescription='Canonical Phase 1 asset register with search, filters, sorting, and pagination.'
access={!!canReadAssets}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to asset management.
</div>
}
pageHeaderAction={
session?.user?.activePermissions.includes('asset:create') || session?.user?.systemRole === 'super_admin' ? (
<Link href='/dashboard/assets/new' className={cn(buttonVariants(), 'text-xs md:text-sm')}>
<Icons.add className='mr-2 h-4 w-4' /> Add Asset
</Link>
) : null
}
>
<AssetListingPage />
</PageContainer>
);
}

View File

@@ -0,0 +1,48 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import { assetByIdQueryOptions, assetOptionsQueryOptions } from '@/features/assets/api/queries';
import { AssetActionForm } from '@/features/assets/components/asset-action-form';
import { getQueryClient } from '@/lib/query-client';
import type { SearchParams } from 'nuqs/server';
type PageProps = {
searchParams: Promise<SearchParams>;
};
export default async function RepairAssetPage({ searchParams }: PageProps) {
const params = await searchParams;
const assetId = typeof params.assetId === 'string' ? params.assetId : '';
const session = await auth();
const canRepair =
session?.user?.systemRole === 'super_admin' ||
session?.user?.activePermissions.includes('asset:repair');
if (!assetId) {
return (
<PageContainer pageTitle='Repair Asset'>
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
Select an asset to repair.
</div>
</PageContainer>
);
}
const queryClient = getQueryClient();
void queryClient.prefetchQuery(assetByIdQueryOptions(assetId));
void queryClient.prefetchQuery(assetOptionsQueryOptions());
return (
<PageContainer pageTitle='Repair Asset' pageDescription='Record repair history for an asset.'>
<HydrationBoundary state={dehydrate(queryClient)}>
{canRepair ? (
<AssetActionForm action='repair' assetId={assetId} />
) : (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to repair assets.
</div>
)}
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,54 @@
import { redirect } from 'next/navigation';
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import { assetByIdQueryOptions, assetOptionsQueryOptions } from '@/features/assets/api/queries';
import { AssetActionForm } from '@/features/assets/components/asset-action-form';
import { getQueryClient } from '@/lib/query-client';
import type { SearchParams } from 'nuqs/server';
type PageProps = {
searchParams: Promise<SearchParams>;
};
export default async function ReturnAssetPage({ searchParams }: PageProps) {
const params = await searchParams;
const assetId = typeof params.assetId === 'string' ? params.assetId : '';
if (assetId) {
redirect(`/dashboard/assets/${assetId}/return`);
}
const session = await auth();
const canReturn =
session?.user?.systemRole === 'super_admin' ||
session?.user?.activePermissions.includes('asset:return');
if (!assetId) {
return (
<PageContainer pageTitle='Return Asset'>
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
Select an asset to return.
</div>
</PageContainer>
);
}
const queryClient = getQueryClient();
void queryClient.prefetchQuery(assetByIdQueryOptions(assetId));
void queryClient.prefetchQuery(assetOptionsQueryOptions());
return (
<PageContainer pageTitle='Return Asset' pageDescription='Return an asset back to stock or retirement flow.'>
<HydrationBoundary state={dehydrate(queryClient)}>
{canReturn ? (
<AssetActionForm action='return' assetId={assetId} />
) : (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to return assets.
</div>
)}
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,33 @@
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import { MasterDataManager } from '@/features/assets/components/master-data-manager';
import { masterDataLabels } from '@/features/assets/constants';
import type { MasterDataEntity } from '@/features/assets/api/types';
type PageProps = {
params: Promise<{ entity: string }>;
};
export default async function AssetSettingsEntityPage({ params }: PageProps) {
const { entity: rawEntity } = await params;
const entity = (['sites', 'departments', 'locations', 'employees'] as const).includes(
rawEntity as MasterDataEntity
)
? (rawEntity as MasterDataEntity)
: 'sites';
const session = await auth();
const canManage =
session?.user?.systemRole === 'super_admin' ||
session?.user?.activePermissions.includes('master_data:manage');
const title = masterDataLabels[entity] ?? 'Master Data';
return (
<PageContainer
pageTitle={title}
pageDescription={`Manage ${title.toLowerCase()} for the active company.`}
access={!!canManage}
>
<MasterDataManager entity={entity} />
</PageContainer>
);
}

View File

@@ -0,0 +1,54 @@
import { redirect } from 'next/navigation';
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import { assetByIdQueryOptions, assetOptionsQueryOptions } from '@/features/assets/api/queries';
import { AssetActionForm } from '@/features/assets/components/asset-action-form';
import { getQueryClient } from '@/lib/query-client';
import type { SearchParams } from 'nuqs/server';
type PageProps = {
searchParams: Promise<SearchParams>;
};
export default async function TransferAssetPage({ searchParams }: PageProps) {
const params = await searchParams;
const assetId = typeof params.assetId === 'string' ? params.assetId : '';
if (assetId) {
redirect(`/dashboard/assets/${assetId}/transfer`);
}
const session = await auth();
const canTransfer =
session?.user?.systemRole === 'super_admin' ||
session?.user?.activePermissions.includes('asset:transfer');
if (!assetId) {
return (
<PageContainer pageTitle='Transfer Asset'>
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
Select an asset to transfer.
</div>
</PageContainer>
);
}
const queryClient = getQueryClient();
void queryClient.prefetchQuery(assetByIdQueryOptions(assetId));
void queryClient.prefetchQuery(assetOptionsQueryOptions());
return (
<PageContainer pageTitle='Transfer Asset' pageDescription='Transfer an asset to a new owner or location.'>
<HydrationBoundary state={dehydrate(queryClient)}>
{canTransfer ? (
<AssetActionForm action='transfer' assetId={assetId} />
) : (
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to transfer assets.
</div>
)}
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,70 @@
'use client';
import PageContainer from '@/components/layout/page-container';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Icons } from '@/components/icons';
import { billingInfoContent } from '@/config/infoconfig';
import { useSession } from 'next-auth/react';
export default function BillingPage() {
const { data: session, status } = useSession();
const activeOrganization = session?.user?.organizations.find(
(organization) => organization.id === session?.user?.activeOrganizationId
);
return (
<PageContainer
isLoading={status === 'loading'}
access={!!activeOrganization}
accessFallback={
<div className='flex min-h-[400px] items-center justify-center'>
<div className='space-y-2 text-center'>
<h2 className='text-2xl font-semibold'>No Workspace Selected</h2>
<p className='text-muted-foreground'>
Please select or create a workspace to view billing placeholders.
</p>
</div>
</div>
}
infoContent={billingInfoContent}
pageTitle='Billing & Plans'
pageDescription={`Review the current plan state for ${activeOrganization?.name ?? 'your workspace'}`}
>
<div className='space-y-6'>
<Alert>
<Icons.info className='h-4 w-4' />
<AlertDescription>
Clerk Billing has been removed from this route. Plan enforcement now reads app-owned
workspace data, and payment provider integration can be added later.
</AlertDescription>
</Alert>
<Card>
<CardHeader>
<CardTitle>Current plan</CardTitle>
<CardDescription>
This page is intentionally lightweight while billing is no longer vendor-managed.
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='rounded-lg border p-4'>
<div className='text-muted-foreground text-sm'>Workspace</div>
<div className='text-lg font-semibold'>{activeOrganization?.name}</div>
</div>
<div className='rounded-lg border p-4'>
<div className='text-muted-foreground text-sm'>Plan</div>
<div className='text-lg font-semibold capitalize'>
{activeOrganization?.plan ?? 'free'}
</div>
</div>
<div className='text-muted-foreground rounded-lg border border-dashed p-4 text-sm'>
Add subscriptions, invoices, and provider sync in a later pass. The `organizations`
table is already the source of truth for plan gates.
</div>
</CardContent>
</Card>
</div>
</PageContainer>
);
}

View File

@@ -0,0 +1,9 @@
import ChatViewPage from '@/features/chat/components/chat-view-page';
export const metadata = {
title: 'Dashboard: Chat'
};
export default function Page() {
return <ChatViewPage />;
}

View File

@@ -0,0 +1,9 @@
import IconsViewPage from '@/features/elements/components/icons-view-page';
export const metadata = {
title: 'Example Dashboard: Icons'
};
export default function ExampleIconsPage() {
return <IconsViewPage />;
}

View File

@@ -0,0 +1,61 @@
import PageContainer from '@/components/layout/page-container';
import { auth } from '@/auth';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Icons } from '@/components/icons';
import Link from 'next/link';
export default async function ExclusivePage() {
const session = await auth();
const activeOrganizationName = session?.user?.activeOrganizationName;
const activePlan = session?.user?.activeOrganizationPlan;
return (
<PageContainer>
{activePlan !== 'pro' ? (
<div className='flex h-full items-center justify-center'>
<Alert>
<Icons.lock className='h-5 w-5 text-yellow-600' />
<AlertDescription>
<div className='mb-1 text-lg font-semibold'>Pro Plan Required</div>
<div className='text-muted-foreground'>
This page is only available to workspaces on the <span className='font-semibold'>Pro</span>{' '}
plan.
<br />
Upgrade your workspace later through the app-owned{' '}
<Link className='underline' href='/dashboard/billing'>
Billing &amp; Plans
</Link>{' '}
flow.
</div>
</AlertDescription>
</Alert>
</div>
) : (
<div className='space-y-6'>
<div>
<h1 className='flex items-center gap-2 text-3xl font-bold tracking-tight'>
<Icons.badgeCheck className='h-7 w-7 text-green-600' />
Exclusive Area
</h1>
<p className='text-muted-foreground'>
Welcome, <span className='font-semibold'>{activeOrganizationName}</span>! This page
remains gated by the workspace plan stored in your own database.
</p>
</div>
<Card>
<CardHeader>
<CardTitle>Exclusive content enabled</CardTitle>
<CardDescription>
Your active workspace currently satisfies the `pro` plan gate.
</CardDescription>
</CardHeader>
<CardContent>
<div className='text-lg'>Have a wonderful day!</div>
</CardContent>
</Card>
</div>
)}
</PageContainer>
);
}

View File

@@ -0,0 +1,17 @@
import PageContainer from '@/components/layout/page-container';
import AdvancedFormPatterns from '@/features/forms/components/advanced-form-patterns';
export const metadata = {
title: 'Dashboard: Advanced Form Patterns'
};
export default function Page() {
return (
<PageContainer
pageTitle='Advanced Form Patterns'
pageDescription='Linked fields, async validation, dynamic rows, nested objects, cross-field validation, and form-level errors.'
>
<AdvancedFormPatterns />
</PageContainer>
);
}

View File

@@ -0,0 +1,17 @@
import PageContainer from '@/components/layout/page-container';
import DemoForm from '@/components/forms/demo-form';
export const metadata = {
title: 'Dashboard: Basic Form'
};
export default function Page() {
return (
<PageContainer
pageTitle='Basic Form'
pageDescription='A comprehensive form demo with all field types.'
>
<DemoForm />
</PageContainer>
);
}

View File

@@ -0,0 +1,14 @@
import PageContainer from '@/components/layout/page-container';
import FormsShowcasePage from '@/features/forms/components/forms-showcase-page';
export const metadata = {
title: 'Dashboard: Multi-Step Form'
};
export default function Page() {
return (
<PageContainer pageTitle='Multi-Step Form' pageDescription='Multi-step wizard form pattern.'>
<FormsShowcasePage />
</PageContainer>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function Page() {
redirect('/dashboard/forms/basic');
}

View File

@@ -0,0 +1,17 @@
import PageContainer from '@/components/layout/page-container';
import SheetFormDemo from '@/features/forms/components/sheet-form-demo';
export const metadata = {
title: 'Dashboard: Sheet Form'
};
export default function Page() {
return (
<PageContainer
pageTitle='Sheet & Dialog Forms'
pageDescription='Form patterns inside sheets and dialogs with external submit buttons.'
>
<SheetFormDemo />
</PageContainer>
);
}

View File

@@ -0,0 +1,9 @@
import KanbanViewPage from '@/features/kanban/components/kanban-view-page';
export const metadata = {
title: 'Dashboard : Kanban view'
};
export default function page() {
return <KanbanViewPage />;
}

View File

@@ -0,0 +1,14 @@
import { ExampleDashboardShell } from '@/features/example-dashboard/components/example-shell';
export const metadata = {
title: 'Example Dashboard',
description: 'Public showcase routes for the template starter.'
};
export default function ExampleDashboardLayout({
children
}: {
children: React.ReactNode;
}) {
return <ExampleDashboardShell>{children}</ExampleDashboardShell>;
}

View File

@@ -0,0 +1,9 @@
import NotificationsPage from '@/features/notifications/components/notifications-page';
export const metadata = {
title: 'Dashboard: Notifications'
};
export default function Page() {
return <NotificationsPage />;
}

View File

@@ -0,0 +1,3 @@
export default function Default() {
return null;
}

View File

@@ -0,0 +1,14 @@
'use client';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Icons } from '@/components/icons';
export default function AreaStatsError({ error }: { error: Error }) {
return (
<Alert variant='destructive'>
<Icons.alertCircle className='h-4 w-4' />
<AlertTitle>Error</AlertTitle>
<AlertDescription>Failed to load area statistics: {error.message}</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,5 @@
import { AreaGraphSkeleton } from '@/features/overview/components/area-graph-skeleton';
export default function Loading() {
return <AreaGraphSkeleton />;
}

View File

@@ -0,0 +1,7 @@
import { delay } from '@/constants/mock-api';
import { AreaGraph } from '@/features/overview/components/area-graph';
export default async function AreaStats() {
await delay(2000);
return <AreaGraph />;
}

View File

@@ -0,0 +1,3 @@
export default function Default() {
return null;
}

View File

@@ -0,0 +1,60 @@
'use client';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader } from '@/components/ui/card';
import { Icons } from '@/components/icons';
import { useRouter } from 'next/navigation';
import { useEffect, useTransition } from 'react';
import * as Sentry from '@sentry/nextjs';
interface StatsErrorProps {
error: Error;
reset: () => void; // Add reset function from error boundary
}
export default function StatsError({ error, reset }: StatsErrorProps) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
useEffect(() => {
Sentry.captureException(error);
}, [error]);
// the reload fn ensures the refresh is deffered until the next render phase allowing react to handle any pending states before processing
const reload = () => {
startTransition(() => {
router.refresh();
reset();
});
};
return (
<Card className='border-red-500'>
<CardHeader className='flex flex-col items-stretch space-y-0 border-b p-0 sm:flex-row'>
<div className='flex flex-1 flex-col justify-center gap-1 px-6 py-5 sm:py-6'>
<Alert variant='destructive' className='border-none'>
<Icons.alertCircle className='h-4 w-4' />
<AlertTitle>Error</AlertTitle>
<AlertDescription className='mt-2'>
Failed to load statistics: {error.message}
</AlertDescription>
</Alert>
</div>
</CardHeader>
<CardContent className='flex h-[316px] items-center justify-center p-6'>
<div className='text-center'>
<p className='text-muted-foreground mb-4 text-sm'>
Unable to display statistics at this time
</p>
<Button
onClick={() => reload()}
variant='outline'
className='min-w-[120px]'
disabled={isPending}
>
Try again
</Button>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,5 @@
import { BarGraphSkeleton } from '@/features/overview/components/bar-graph-skeleton';
export default function Loading() {
return <BarGraphSkeleton />;
}

View File

@@ -0,0 +1,8 @@
import { delay } from '@/constants/mock-api';
import { BarGraph } from '@/features/overview/components/bar-graph';
export default async function BarStats() {
await delay(1000);
return <BarGraph />;
}

View File

@@ -0,0 +1,3 @@
export default function Default() {
return null;
}

View File

@@ -0,0 +1,14 @@
'use client';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Icons } from '@/components/icons';
export default function PieStatsError({ error }: { error: Error }) {
return (
<Alert variant='destructive'>
<Icons.alertCircle className='h-4 w-4' />
<AlertTitle>Error</AlertTitle>
<AlertDescription>Failed to load pie statistics: {error.message}</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,5 @@
import { PieGraphSkeleton } from '@/features/overview/components/pie-graph-skeleton';
export default function Loading() {
return <PieGraphSkeleton />;
}

View File

@@ -0,0 +1,7 @@
import { delay } from '@/constants/mock-api';
import { PieGraph } from '@/features/overview/components/pie-graph';
export default async function Stats() {
await delay(1000);
return <PieGraph />;
}

View File

@@ -0,0 +1,3 @@
export default function Default() {
return null;
}

View File

@@ -0,0 +1,14 @@
'use client';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Icons } from '@/components/icons';
export default function SalesError({ error }: { error: Error }) {
return (
<Alert variant='destructive'>
<Icons.alertCircle className='h-4 w-4' />
<AlertTitle>Error</AlertTitle>
<AlertDescription>Failed to load sales data: {error.message}</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,6 @@
import { RecentSalesSkeleton } from '@/features/overview/components/recent-sales-skeleton';
import React from 'react';
export default function Loading() {
return <RecentSalesSkeleton />;
}

View File

@@ -0,0 +1,7 @@
import { delay } from '@/constants/mock-api';
import { RecentSales } from '@/features/overview/components/recent-sales';
export default async function Sales() {
await delay(3000);
return <RecentSales />;
}

View File

@@ -0,0 +1,14 @@
'use client';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Icons } from '@/components/icons';
export default function OverviewError({ error }: { error: Error }) {
return (
<Alert variant='destructive'>
<Icons.alertCircle className='h-4 w-4' />
<AlertTitle>Error</AlertTitle>
<AlertDescription>Failed to load statistics: {error.message}</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,126 @@
import PageContainer from '@/components/layout/page-container';
import { Badge } from '@/components/ui/badge';
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardAction,
CardFooter
} from '@/components/ui/card';
import { Icons } from '@/components/icons';
import React from 'react';
export default function OverViewLayout({
sales,
pie_stats,
bar_stats,
area_stats
}: {
sales: React.ReactNode;
pie_stats: React.ReactNode;
bar_stats: React.ReactNode;
area_stats: React.ReactNode;
}) {
return (
<PageContainer>
<div className='flex flex-1 flex-col space-y-2'>
<div className='flex items-center justify-between'>
<h2 className='text-2xl font-bold tracking-tight'>Hi, Welcome back 👋</h2>
</div>
<div className='*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-4 *:data-[slot=card]:bg-gradient-to-t *:data-[slot=card]:shadow-xs md:grid-cols-2 lg:grid-cols-4'>
<Card className='@container/card'>
<CardHeader>
<CardDescription>Total Revenue</CardDescription>
<CardTitle className='text-2xl font-semibold tabular-nums @[250px]/card:text-3xl'>
$1,250.00
</CardTitle>
<CardAction>
<Badge variant='outline'>
<Icons.trendingUp />
+12.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className='flex-col items-start gap-1.5 text-sm'>
<div className='line-clamp-1 flex gap-2 font-medium'>
Trending up this month <Icons.trendingUp className='size-4' />
</div>
<div className='text-muted-foreground'>Visitors for the last 6 months</div>
</CardFooter>
</Card>
<Card className='@container/card'>
<CardHeader>
<CardDescription>New Customers</CardDescription>
<CardTitle className='text-2xl font-semibold tabular-nums @[250px]/card:text-3xl'>
1,234
</CardTitle>
<CardAction>
<Badge variant='outline'>
<Icons.trendingDown />
-20%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className='flex-col items-start gap-1.5 text-sm'>
<div className='line-clamp-1 flex gap-2 font-medium'>
Down 20% this period <Icons.trendingDown className='size-4' />
</div>
<div className='text-muted-foreground'>Acquisition needs attention</div>
</CardFooter>
</Card>
<Card className='@container/card'>
<CardHeader>
<CardDescription>Active Accounts</CardDescription>
<CardTitle className='text-2xl font-semibold tabular-nums @[250px]/card:text-3xl'>
45,678
</CardTitle>
<CardAction>
<Badge variant='outline'>
<Icons.trendingUp />
+12.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className='flex-col items-start gap-1.5 text-sm'>
<div className='line-clamp-1 flex gap-2 font-medium'>
Strong user retention <Icons.trendingUp className='size-4' />
</div>
<div className='text-muted-foreground'>Engagement exceed targets</div>
</CardFooter>
</Card>
<Card className='@container/card'>
<CardHeader>
<CardDescription>Growth Rate</CardDescription>
<CardTitle className='text-2xl font-semibold tabular-nums @[250px]/card:text-3xl'>
4.5%
</CardTitle>
<CardAction>
<Badge variant='outline'>
<Icons.trendingUp />
+4.5%
</Badge>
</CardAction>
</CardHeader>
<CardFooter className='flex-col items-start gap-1.5 text-sm'>
<div className='line-clamp-1 flex gap-2 font-medium'>
Steady performance increase <Icons.trendingUp className='size-4' />
</div>
<div className='text-muted-foreground'>Meets growth projections</div>
</CardFooter>
</Card>
</div>
<div className='grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-7'>
<div className='col-span-4'>{bar_stats}</div>
<div className='col-span-4 md:col-span-3'>
{/* sales arallel routes */}
{sales}
</div>
<div className='col-span-4'>{area_stats}</div>
<div className='col-span-4 min-h-0 md:col-span-3'>{pie_stats}</div>
</div>
</div>
</PageContainer>
);
}

View File

@@ -0,0 +1,9 @@
import OverViewPage from '@/features/overview/components/overview';
export const metadata = {
title: 'Example Dashboard: Overview'
};
export default function ExampleOverviewPage() {
return <OverViewPage />;
}

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default function ExampleDashboardIndexPage() {
redirect('/example/dashboard/overview');
}

View File

@@ -0,0 +1,30 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import { productByIdOptions } from '@/features/products/api/queries';
import PageContainer from '@/components/layout/page-container';
import ProductViewPage from '@/features/products/components/product-view-page';
export const metadata = {
title: 'Dashboard : Product View'
};
type PageProps = { params: Promise<{ productId: string }> };
export default async function Page(props: PageProps) {
const params = await props.params;
const queryClient = getQueryClient();
if (params.productId !== 'new') {
void queryClient.prefetchQuery(productByIdOptions(Number(params.productId)));
}
return (
<PageContainer>
<div className='flex-1 space-y-4'>
<HydrationBoundary state={dehydrate(queryClient)}>
<ProductViewPage productId={params.productId} />
</HydrationBoundary>
</div>
</PageContainer>
);
}

View File

@@ -0,0 +1,47 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { getQueryClient } from '@/lib/query-client';
import { searchParamsCache } from '@/lib/searchparams';
import type { SearchParams } from 'nuqs/server';
import { exampleProductsQueryOptions } from '@/features/example-dashboard/api';
import { ExampleProductTable } from '@/features/example-dashboard/components/example-product-table';
type PageProps = {
searchParams: Promise<SearchParams>;
};
export const metadata = {
title: 'Example Dashboard: Products'
};
export default async function ExampleProductsPage(props: PageProps) {
const searchParams = await props.searchParams;
searchParamsCache.parse(searchParams);
const page = searchParamsCache.get('page');
const search = searchParamsCache.get('name');
const pageLimit = searchParamsCache.get('perPage');
const categories = searchParamsCache.get('category');
const sort = searchParamsCache.get('sort');
const filters = {
page,
limit: pageLimit,
...(search && { search }),
...(categories && { categories }),
...(sort && { sort })
};
const queryClient = getQueryClient();
void queryClient.prefetchQuery(exampleProductsQueryOptions(filters));
return (
<PageContainer
pageTitle='Products'
pageDescription='Public demo of the table + filter + React Query pattern used by the template.'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<ExampleProductTable />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,9 @@
import ProfileViewPage from '@/features/profile/components/profile-view-page';
export const metadata = {
title: 'Dashboard : Profile'
};
export default async function Page() {
return <ProfileViewPage />;
}

View File

@@ -0,0 +1,29 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { Suspense } from 'react';
import PageContainer from '@/components/layout/page-container';
import { getQueryClient } from '@/lib/query-client';
import { pokemonOptions } from '@/features/react-query-demo/api/queries';
import { PokemonInfo } from '@/features/react-query-demo/components/pokemon-info';
import { PokemonSkeleton } from '@/features/react-query-demo/components/pokemon-skeleton';
export const metadata = {
title: 'Example Dashboard: React Query'
};
export default function ExampleReactQueryPage() {
const queryClient = getQueryClient();
void queryClient.prefetchQuery(pokemonOptions(25));
return (
<PageContainer
pageTitle='React Query'
pageDescription='Server prefetch + hydration + suspense query pattern in a public example route.'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<Suspense fallback={<PokemonSkeleton />}>
<PokemonInfo />
</Suspense>
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,47 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { getQueryClient } from '@/lib/query-client';
import { searchParamsCache } from '@/lib/searchparams';
import type { SearchParams } from 'nuqs/server';
import { exampleUsersQueryOptions } from '@/features/example-dashboard/api';
import { ExampleUserTable } from '@/features/example-dashboard/components/example-user-table';
type PageProps = {
searchParams: Promise<SearchParams>;
};
export const metadata = {
title: 'Example Dashboard: Users'
};
export default async function ExampleUsersPage(props: PageProps) {
const searchParams = await props.searchParams;
searchParamsCache.parse(searchParams);
const page = searchParamsCache.get('page');
const search = searchParamsCache.get('name');
const pageLimit = searchParamsCache.get('perPage');
const roles = searchParamsCache.get('role');
const sort = searchParamsCache.get('sort');
const filters = {
page,
limit: pageLimit,
...(search && { search }),
...(roles && { roles }),
...(sort && { sort })
};
const queryClient = getQueryClient();
void queryClient.prefetchQuery(exampleUsersQueryOptions(filters));
return (
<PageContainer
pageTitle='Users'
pageDescription='Public demo of the organization-aware user listing pattern without requiring auth.'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<ExampleUserTable />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,194 @@
'use client';
import { useEffect, useState } from 'react';
import PageContainer from '@/components/layout/page-container';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { workspacesInfoContent } from '@/config/infoconfig';
import { useSession } from 'next-auth/react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
type OrganizationSummary = {
id: string;
name: string;
slug: string;
plan: string;
imageUrl: string | null;
role: string;
canManageUsers: boolean;
};
export default function WorkspacesPage() {
const { data: session, status, update } = useSession();
const router = useRouter();
const [workspaceName, setWorkspaceName] = useState('');
const [isCreating, setIsCreating] = useState(false);
const [organizations, setOrganizations] = useState<OrganizationSummary[]>([]);
const [isLoadingOrganizations, setIsLoadingOrganizations] = useState(false);
const canManageOrganizations = session?.user?.systemRole === 'super_admin';
useEffect(() => {
if (!canManageOrganizations) {
setOrganizations([]);
return;
}
let cancelled = false;
async function loadOrganizations() {
setIsLoadingOrganizations(true);
try {
const response = await fetch('/api/organizations');
const data = (await response.json()) as {
organizations?: OrganizationSummary[];
message?: string;
};
if (!response.ok) {
throw new Error(data.message ?? 'Unable to load organizations');
}
if (!cancelled) {
setOrganizations(data.organizations ?? []);
}
} catch (error) {
if (!cancelled) {
toast.error(error instanceof Error ? error.message : 'Unable to load organizations');
}
} finally {
if (!cancelled) {
setIsLoadingOrganizations(false);
}
}
}
void loadOrganizations();
return () => {
cancelled = true;
};
}, [canManageOrganizations, session?.user?.activeOrganizationId]);
const createWorkspace = async () => {
if (!workspaceName.trim()) {
toast.error('Workspace name is required');
return;
}
setIsCreating(true);
try {
const response = await fetch('/api/organizations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: workspaceName })
});
if (!response.ok) {
const data = (await response.json().catch(() => null)) as { message?: string } | null;
throw new Error(data?.message ?? 'Unable to create workspace');
}
setWorkspaceName('');
await update();
router.refresh();
toast.success('Workspace created');
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Unable to create workspace');
} finally {
setIsCreating(false);
}
};
return (
<PageContainer
pageTitle='Workspaces'
pageDescription='Manage your app-owned workspaces and switch your active organization.'
infoContent={workspacesInfoContent}
isLoading={status === 'loading' || (canManageOrganizations && isLoadingOrganizations)}
access={canManageOrganizations}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
Only super admins can manage workspaces.
</div>
}
>
<div className='grid gap-6 xl:grid-cols-[1.2fr_0.8fr]'>
<Card>
<CardHeader>
<CardTitle>Your workspaces</CardTitle>
<CardDescription>
The active workspace controls organization-scoped product data and access checks.
</CardDescription>
</CardHeader>
<CardContent className='space-y-3'>
{organizations.length ? (
organizations.map((organization) => {
const isActive = organization.id === session?.user?.activeOrganizationId;
return (
<button
key={organization.id}
type='button'
onClick={() => router.push('/dashboard/workspaces/team')}
aria-label={`Open workspace ${organization.name}`}
className={`w-full rounded-lg border p-4 text-left transition ${
isActive ? 'border-primary bg-primary/5' : 'hover:bg-accent'
}`}
>
<div className='flex items-center justify-between gap-4'>
<div>
<div className='font-semibold'>{organization.name}</div>
<div className='text-muted-foreground text-sm'>
Role: {organization.role} | Plan: {organization.plan}
</div>
</div>
<div className='text-xs font-medium uppercase'>
{isActive ? 'Active' : 'Open'}
</div>
</div>
</button>
);
})
) : (
<div className='text-muted-foreground rounded-lg border border-dashed p-6 text-sm'>
No workspace found yet. Create your first workspace to unlock organization-scoped
product management.
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Create workspace</CardTitle>
<CardDescription>
This flow is limited to super admins in the organization RBAC model.
</CardDescription>
</CardHeader>
<CardContent className='space-y-4'>
<div className='space-y-2'>
<label htmlFor='workspace-name' className='text-sm font-medium'>
Workspace name
</label>
<Input
id='workspace-name'
value={workspaceName}
onChange={(event) => setWorkspaceName(event.target.value)}
placeholder='Acme Studio'
disabled={isCreating}
/>
</div>
<Button className='w-full' isLoading={isCreating} onClick={createWorkspace}>
Create workspace
</Button>
</CardContent>
</Card>
</div>
</PageContainer>
);
}

View File

@@ -0,0 +1,55 @@
'use client';
import PageContainer from '@/components/layout/page-container';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { teamInfoContent } from '@/config/infoconfig';
import { useSession } from 'next-auth/react';
export default function TeamPage() {
const { data: session, status } = useSession();
const activeOrganization = session?.user?.organizations.find(
(organization) => organization.id === session?.user?.activeOrganizationId
);
const canManageTeam =
session?.user?.systemRole === 'super_admin' ||
(session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes('users:manage')));
return (
<PageContainer
pageTitle='Team Management'
pageDescription='App-owned team and role management is scaffolded here for the Auth.js migration.'
infoContent={teamInfoContent}
isLoading={status === 'loading'}
access={!!activeOrganization && !!canManageTeam}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
Select a workspace where you are an admin to manage team settings.
</div>
}
>
<Card>
<CardHeader>
<CardTitle>{activeOrganization?.name}</CardTitle>
<CardDescription>
This placeholder keeps the route alive while the full membership CRUD flow is still
being migrated off Clerk Organizations.
</CardDescription>
</CardHeader>
<CardContent className='space-y-4 text-sm'>
<div className='rounded-lg border p-4'>
<div className='font-medium'>Current role</div>
<div className='text-muted-foreground mt-1'>
{session?.user?.activeMembershipRole ?? 'member'}
</div>
</div>
<div className='rounded-lg border border-dashed p-4 text-muted-foreground'>
Next iteration can add invites, membership edits, and permission management against the
`memberships` table introduced in this migration.
</div>
</CardContent>
</Card>
</PageContainer>
);
}