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,26 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { approvalsQueryOptions, crmReferenceQueryOptions } from '@/features/crm/api/queries';
import { ApprovalsPage } from '@/features/crm/components/approvals-page';
import { getQueryClient } from '@/lib/query-client';
export const metadata = {
title: 'Dashboard: CRM Approvals'
};
export default function ApprovalsRoute() {
const queryClient = getQueryClient();
void queryClient.prefetchQuery(crmReferenceQueryOptions());
void queryClient.prefetchQuery(approvalsQueryOptions({ page: 1, limit: 10 }));
return (
<PageContainer
pageTitle='Approvals'
pageDescription='Pending approval list, approver level, branch, amount และ approve/reject dialog'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<ApprovalsPage />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,24 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { customerByIdOptions } from '@/features/crm/api/queries';
import { CustomerDetailPage } from '@/features/crm/components/customer-detail';
import { getQueryClient } from '@/lib/query-client';
type PageProps = { params: Promise<{ id: string }> };
export default async function CustomerDetailRoute({ params }: PageProps) {
const { id } = await params;
const queryClient = getQueryClient();
void queryClient.prefetchQuery(customerByIdOptions(id));
return (
<PageContainer
pageTitle='Customer Detail'
pageDescription='Overview, contacts, shared contacts, enquiries, quotations, and activity log'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<CustomerDetailPage id={id} />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,49 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import type { SearchParams } from 'nuqs/server';
import PageContainer from '@/components/layout/page-container';
import { crmReferenceQueryOptions, customersQueryOptions } from '@/features/crm/api/queries';
import { CustomersTablePage } from '@/features/crm/components/customers-table';
import { getQueryClient } from '@/lib/query-client';
import { searchParamsCache } from '@/lib/searchparams';
export const metadata = {
title: 'Dashboard: CRM Customers'
};
export default async function CustomersRoute({
searchParams
}: {
searchParams: Promise<SearchParams>;
}) {
searchParamsCache.parse(await searchParams);
const page = searchParamsCache.get('page');
const perPage = searchParamsCache.get('perPage');
const search = searchParamsCache.get('name');
const status = searchParamsCache.get('customerStatus');
const branch = searchParamsCache.get('branch');
const sort = searchParamsCache.get('sort');
const queryClient = getQueryClient();
void queryClient.prefetchQuery(crmReferenceQueryOptions());
void queryClient.prefetchQuery(
customersQueryOptions({
page,
limit: perPage,
...(search && { search }),
...(status && { status }),
...(branch && { branch }),
...(sort && { sort })
})
);
return (
<PageContainer
pageTitle='Customers'
pageDescription='Customer master พร้อม status, branch, contacts และ drawer สำหรับ create/edit'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<CustomersTablePage />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,24 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { enquiryByIdOptions } from '@/features/crm/api/queries';
import { EnquiryDetailPage } from '@/features/crm/components/enquiry-detail';
import { getQueryClient } from '@/lib/query-client';
type PageProps = { params: Promise<{ id: string }> };
export default async function EnquiryDetailRoute({ params }: PageProps) {
const { id } = await params;
const queryClient = getQueryClient();
void queryClient.prefetchQuery(enquiryByIdOptions(id));
return (
<PageContainer
pageTitle='Enquiry Detail'
pageDescription='Header summary, customer/contact, requirement summary, timeline, and related quotations'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<EnquiryDetailPage id={id} />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,55 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import type { SearchParams } from 'nuqs/server';
import PageContainer from '@/components/layout/page-container';
import { crmReferenceQueryOptions, enquiriesQueryOptions } from '@/features/crm/api/queries';
import { EnquiriesTablePage } from '@/features/crm/components/enquiries-table';
import { getQueryClient } from '@/lib/query-client';
import { searchParamsCache } from '@/lib/searchparams';
export const metadata = {
title: 'Dashboard: CRM Enquiries'
};
export default async function EnquiriesRoute({
searchParams
}: {
searchParams: Promise<SearchParams>;
}) {
searchParamsCache.parse(await searchParams);
const page = searchParamsCache.get('page');
const perPage = searchParamsCache.get('perPage');
const search = searchParamsCache.get('name');
const status = searchParamsCache.get('status');
const productType = searchParamsCache.get('productType');
const salesman = searchParamsCache.get('salesman');
const dateFrom = searchParamsCache.get('dateFrom');
const dateTo = searchParamsCache.get('dateTo');
const sort = searchParamsCache.get('sort');
const queryClient = getQueryClient();
void queryClient.prefetchQuery(crmReferenceQueryOptions());
void queryClient.prefetchQuery(
enquiriesQueryOptions({
page,
limit: perPage,
...(search && { search }),
...(status && { status }),
...(productType && { productType }),
...(salesman && { salesman }),
...(dateFrom && { dateFrom }),
...(dateTo && { dateTo }),
...(sort && { sort })
})
);
return (
<PageContainer
pageTitle='Enquiries'
pageDescription='List ของ sales opportunity พร้อม filter, date range และ convert to quotation'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<EnquiriesTablePage />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,25 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { getQueryClient } from '@/lib/query-client';
import { crmDashboardQueryOptions } from '@/features/crm/api/queries';
import { CrmDashboardPage } from '@/features/crm/components/crm-dashboard';
export const metadata = {
title: 'Dashboard: CRM'
};
export default function CrmDashboardRoute() {
const queryClient = getQueryClient();
void queryClient.prefetchQuery(crmDashboardQueryOptions());
return (
<PageContainer
pageTitle='CRM Dashboard'
pageDescription='ภาพรวม workflow ของ Customer → Enquiry → Quotation → Approval → Won/Lost'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<CrmDashboardPage />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,24 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { quotationByIdOptions } from '@/features/crm/api/queries';
import { QuotationDetailPage } from '@/features/crm/components/quotation-detail';
import { getQueryClient } from '@/lib/query-client';
type PageProps = { params: Promise<{ id: string }> };
export default async function QuotationDetailRoute({ params }: PageProps) {
const { id } = await params;
const queryClient = getQueryClient();
void queryClient.prefetchQuery(quotationByIdOptions(id));
return (
<PageContainer
pageTitle='Quotation Detail'
pageDescription='Header, status, revision, customer roles, item table, approval timeline, and preview panel'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<QuotationDetailPage id={id} />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,57 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import type { SearchParams } from 'nuqs/server';
import PageContainer from '@/components/layout/page-container';
import { crmReferenceQueryOptions, quotationsQueryOptions } from '@/features/crm/api/queries';
import { QuotationsTablePage } from '@/features/crm/components/quotations-table';
import { getQueryClient } from '@/lib/query-client';
import { searchParamsCache } from '@/lib/searchparams';
export const metadata = {
title: 'Dashboard: CRM Quotations'
};
export default async function QuotationsRoute({
searchParams
}: {
searchParams: Promise<SearchParams>;
}) {
searchParamsCache.parse(await searchParams);
const page = searchParamsCache.get('page');
const perPage = searchParamsCache.get('perPage');
const search = searchParamsCache.get('name');
const status = searchParamsCache.get('status');
const quotationType = searchParamsCache.get('quotationType');
const salesman = searchParamsCache.get('salesman');
const hot = searchParamsCache.get('hot');
const dateFrom = searchParamsCache.get('dateFrom');
const dateTo = searchParamsCache.get('dateTo');
const sort = searchParamsCache.get('sort');
const queryClient = getQueryClient();
void queryClient.prefetchQuery(crmReferenceQueryOptions());
void queryClient.prefetchQuery(
quotationsQueryOptions({
page,
limit: perPage,
...(search && { search }),
...(status && { status }),
...(quotationType && { quotationType }),
...(salesman && { salesman }),
...(hot && { hot }),
...(dateFrom && { dateFrom }),
...(dateTo && { dateTo }),
...(sort && { sort })
})
);
return (
<PageContainer
pageTitle='Quotations'
pageDescription='Quotation table with filters, hot project indicator, and kanban toggle'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<QuotationsTablePage />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,21 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { crmReferenceQueryOptions } from '@/features/crm/api/queries';
import { DocumentSequencesPage } from '@/features/crm/components/settings-page';
import { getQueryClient } from '@/lib/query-client';
export default function DocumentSequencesRoute() {
const queryClient = getQueryClient();
void queryClient.prefetchQuery(crmReferenceQueryOptions());
return (
<PageContainer
pageTitle='Document Sequences'
pageDescription='Mock document sequence แยกตาม branch และ document type'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<DocumentSequencesPage />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,21 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { crmReferenceQueryOptions } from '@/features/crm/api/queries';
import { MasterOptionsPage } from '@/features/crm/components/settings-page';
import { getQueryClient } from '@/lib/query-client';
export default function MasterOptionsRoute() {
const queryClient = getQueryClient();
void queryClient.prefetchQuery(crmReferenceQueryOptions());
return (
<PageContainer
pageTitle='Master Options'
pageDescription='Mock UI สำหรับ status options, product types, payment term, currency และ tax rate'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<MasterOptionsPage />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,21 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import PageContainer from '@/components/layout/page-container';
import { crmReferenceQueryOptions } from '@/features/crm/api/queries';
import { TemplatesPage } from '@/features/crm/components/settings-page';
import { getQueryClient } from '@/lib/query-client';
export default function TemplatesRoute() {
const queryClient = getQueryClient();
void queryClient.prefetchQuery(crmReferenceQueryOptions());
return (
<PageContainer
pageTitle='Quotation Templates'
pageDescription='Mock quotation template, version และ placeholder mappings สำหรับต่อยอด PDF preview'
>
<HydrationBoundary state={dehydrate(queryClient)}>
<TemplatesPage />
</HydrationBoundary>
</PageContainer>
);
}

View File

@@ -0,0 +1,26 @@
import type { ReactNode } from 'react';
import AppSidebar from '@/components/layout/app-sidebar';
import Header from '@/components/layout/header';
import { InfoSidebar } from '@/components/layout/info-sidebar';
import KBar from '@/components/kbar';
import { InfobarProvider } from '@/components/ui/infobar';
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
export default function DashboardLayout({ children }: { children: ReactNode }) {
return (
<KBar>
<InfobarProvider defaultOpen={false}>
<SidebarProvider defaultOpen>
<AppSidebar />
<SidebarInset>
<Header />
<div className='flex flex-1 overflow-hidden'>
<div className='flex min-w-0 flex-1 flex-col'>{children}</div>
<InfoSidebar side='right' />
</div>
</SidebarInset>
</SidebarProvider>
</InfobarProvider>
</KBar>
);
}

View File

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

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>
);
}