init
This commit is contained in:
30
src/app/example/dashboard/assets/[assetId]/assign/page.tsx
Normal file
30
src/app/example/dashboard/assets/[assetId]/assign/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/app/example/dashboard/assets/[assetId]/edit/page.tsx
Normal file
35
src/app/example/dashboard/assets/[assetId]/edit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
32
src/app/example/dashboard/assets/[assetId]/page.tsx
Normal file
32
src/app/example/dashboard/assets/[assetId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/app/example/dashboard/assets/[assetId]/return/page.tsx
Normal file
29
src/app/example/dashboard/assets/[assetId]/return/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/app/example/dashboard/assets/[assetId]/transfer/page.tsx
Normal file
30
src/app/example/dashboard/assets/[assetId]/transfer/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/app/example/dashboard/assets/assign/page.tsx
Normal file
54
src/app/example/dashboard/assets/assign/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
src/app/example/dashboard/assets/history/page.tsx
Normal file
27
src/app/example/dashboard/assets/history/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/app/example/dashboard/assets/new/page.tsx
Normal file
40
src/app/example/dashboard/assets/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/app/example/dashboard/assets/page.tsx
Normal file
48
src/app/example/dashboard/assets/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
src/app/example/dashboard/assets/repair/page.tsx
Normal file
48
src/app/example/dashboard/assets/repair/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/app/example/dashboard/assets/return/page.tsx
Normal file
54
src/app/example/dashboard/assets/return/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
src/app/example/dashboard/assets/settings/[entity]/page.tsx
Normal file
33
src/app/example/dashboard/assets/settings/[entity]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
src/app/example/dashboard/assets/transfer/page.tsx
Normal file
54
src/app/example/dashboard/assets/transfer/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
70
src/app/example/dashboard/billing/page.tsx
Normal file
70
src/app/example/dashboard/billing/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
src/app/example/dashboard/chat/page.tsx
Normal file
9
src/app/example/dashboard/chat/page.tsx
Normal 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 />;
|
||||
}
|
||||
9
src/app/example/dashboard/elements/icons/page.tsx
Normal file
9
src/app/example/dashboard/elements/icons/page.tsx
Normal 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 />;
|
||||
}
|
||||
61
src/app/example/dashboard/exclusive/page.tsx
Normal file
61
src/app/example/dashboard/exclusive/page.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
17
src/app/example/dashboard/forms/advanced/page.tsx
Normal file
17
src/app/example/dashboard/forms/advanced/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
17
src/app/example/dashboard/forms/basic/page.tsx
Normal file
17
src/app/example/dashboard/forms/basic/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
src/app/example/dashboard/forms/multi-step/page.tsx
Normal file
14
src/app/example/dashboard/forms/multi-step/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/app/example/dashboard/forms/page.tsx
Normal file
5
src/app/example/dashboard/forms/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function Page() {
|
||||
redirect('/dashboard/forms/basic');
|
||||
}
|
||||
17
src/app/example/dashboard/forms/sheet-form/page.tsx
Normal file
17
src/app/example/dashboard/forms/sheet-form/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
src/app/example/dashboard/kanban/page.tsx
Normal file
9
src/app/example/dashboard/kanban/page.tsx
Normal 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 />;
|
||||
}
|
||||
14
src/app/example/dashboard/layout.tsx
Normal file
14
src/app/example/dashboard/layout.tsx
Normal 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>;
|
||||
}
|
||||
9
src/app/example/dashboard/notifications/page.tsx
Normal file
9
src/app/example/dashboard/notifications/page.tsx
Normal 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 />;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Default() {
|
||||
return null;
|
||||
}
|
||||
14
src/app/example/dashboard/overview/@area_stats/error.tsx
Normal file
14
src/app/example/dashboard/overview/@area_stats/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { AreaGraphSkeleton } from '@/features/overview/components/area-graph-skeleton';
|
||||
|
||||
export default function Loading() {
|
||||
return <AreaGraphSkeleton />;
|
||||
}
|
||||
7
src/app/example/dashboard/overview/@area_stats/page.tsx
Normal file
7
src/app/example/dashboard/overview/@area_stats/page.tsx
Normal 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 />;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Default() {
|
||||
return null;
|
||||
}
|
||||
60
src/app/example/dashboard/overview/@bar_stats/error.tsx
Normal file
60
src/app/example/dashboard/overview/@bar_stats/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { BarGraphSkeleton } from '@/features/overview/components/bar-graph-skeleton';
|
||||
|
||||
export default function Loading() {
|
||||
return <BarGraphSkeleton />;
|
||||
}
|
||||
8
src/app/example/dashboard/overview/@bar_stats/page.tsx
Normal file
8
src/app/example/dashboard/overview/@bar_stats/page.tsx
Normal 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 />;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default function Default() {
|
||||
return null;
|
||||
}
|
||||
14
src/app/example/dashboard/overview/@pie_stats/error.tsx
Normal file
14
src/app/example/dashboard/overview/@pie_stats/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PieGraphSkeleton } from '@/features/overview/components/pie-graph-skeleton';
|
||||
|
||||
export default function Loading() {
|
||||
return <PieGraphSkeleton />;
|
||||
}
|
||||
7
src/app/example/dashboard/overview/@pie_stats/page.tsx
Normal file
7
src/app/example/dashboard/overview/@pie_stats/page.tsx
Normal 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 />;
|
||||
}
|
||||
3
src/app/example/dashboard/overview/@sales/default.tsx
Normal file
3
src/app/example/dashboard/overview/@sales/default.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function Default() {
|
||||
return null;
|
||||
}
|
||||
14
src/app/example/dashboard/overview/@sales/error.tsx
Normal file
14
src/app/example/dashboard/overview/@sales/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
src/app/example/dashboard/overview/@sales/loading.tsx
Normal file
6
src/app/example/dashboard/overview/@sales/loading.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { RecentSalesSkeleton } from '@/features/overview/components/recent-sales-skeleton';
|
||||
import React from 'react';
|
||||
|
||||
export default function Loading() {
|
||||
return <RecentSalesSkeleton />;
|
||||
}
|
||||
7
src/app/example/dashboard/overview/@sales/page.tsx
Normal file
7
src/app/example/dashboard/overview/@sales/page.tsx
Normal 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 />;
|
||||
}
|
||||
14
src/app/example/dashboard/overview/error.tsx
Normal file
14
src/app/example/dashboard/overview/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
src/app/example/dashboard/overview/layout.tsx
Normal file
126
src/app/example/dashboard/overview/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
src/app/example/dashboard/overview/page.tsx
Normal file
9
src/app/example/dashboard/overview/page.tsx
Normal 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 />;
|
||||
}
|
||||
5
src/app/example/dashboard/page.tsx
Normal file
5
src/app/example/dashboard/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
export default function ExampleDashboardIndexPage() {
|
||||
redirect('/example/dashboard/overview');
|
||||
}
|
||||
30
src/app/example/dashboard/product/[productId]/page.tsx
Normal file
30
src/app/example/dashboard/product/[productId]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/app/example/dashboard/product/page.tsx
Normal file
47
src/app/example/dashboard/product/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
29
src/app/example/dashboard/react-query/page.tsx
Normal file
29
src/app/example/dashboard/react-query/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
src/app/example/dashboard/users/page.tsx
Normal file
47
src/app/example/dashboard/users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
194
src/app/example/dashboard/workspaces/page.tsx
Normal file
194
src/app/example/dashboard/workspaces/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user