diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md index 2f9a6bc..898a8b2 100644 --- a/API_DOCUMENTATION.md +++ b/API_DOCUMENTATION.md @@ -17,34 +17,57 @@ http://localhost:3000 #### Get All Customers by Branch ``` -GET /api/customers/:branch +GET /api/customers ``` -**Parameters:** +**Note:** Branch is automatically determined from authentication context. -- `branch` (path parameter, required): Branch identifier - - Examples: `branch-01`, `branch-02`, `head-office` -- `status` (query parameter, optional): Filter by customer status +**Query Parameters:** + +- `status` (optional): Filter by customer status - Values: `active`, `inactive`, `pending` +- `page` (optional): Page number for pagination + - Default: `1` +- `limit` (optional): Number of items per page + - Default: `20` + - Maximum: `100` +- `sortBy` (optional): Field to sort by + - Default: `createdAt` + - Examples: `name`, `createdAt`, `customerStatus` +- `sortOrder` (optional): Sort direction + - Default: `desc` + - Values: `asc`, `desc` **Examples:** -1. Get all customers from branch-01: +1. Get all customers (default page 1, 20 per page): ```bash -curl http://localhost:3000/api/customers/branch-01 +curl http://localhost:3000/api/customers ``` -2. Get active customers from branch-02: +2. Get page 2 with 10 items per page: ```bash -curl "http://localhost:3000/api/customers/branch-02?status=active" +curl "http://localhost:3000/api/customers?page=2&limit=10" ``` -3. Get pending customers from head-office: +3. Get active customers sorted by name ascending: ```bash -curl "http://localhost:3000/api/customers/head-office?status=pending" +curl "http://localhost:3000/api/customers?status=active&sortBy=name&sortOrder=asc" +``` + +4. Get recent customers (sorted by createdAt descending): + +```bash +curl "http://localhost:3000/api/customers?sortBy=createdAt&sortOrder=desc" +``` + +5. Combined: filtered, paginated, and sorted: + +```bash +curl "http://localhost:3000/api/customers?status=active&page=1&limit=15&sortBy=name&sortOrder=asc" ``` **Response Format:** @@ -55,22 +78,42 @@ curl "http://localhost:3000/api/customers/head-office?status=pending" "data": [ { "id": "cust-001", - "branch": "branch-01", + "branchId": "branch-01", "name": "สมชาย ใจดี", "email": "somchai@example.com", "phone": "081-234-5678", "company": "บริษัท ไทยธุรกิจ จำกัด", "address": "123 ถนนสุขุมวิท แขวงคลองตัน เขตคลองเตย กรุงเทพฯ 10110", - "status": "active", + "customerStatus": "active", + "customerType": "corporate", + "crmCustomerCode": "CRM-2024-001", + "erpCustomerCode": null, + "taxId": "0105551234567", "createdAt": "2024-01-15T09:00:00Z", "updatedAt": "2024-01-15T09:00:00Z" } ], - "count": 1, - "message": "Found 1 customer(s) for branch: branch-01" + "pagination": { + "page": 1, + "limit": 20, + "total": 45, + "totalPages": 3, + "hasNext": true, + "hasPrev": false + }, + "message": "Found 45 customer(s) (page 1 of 3)" } ``` +**Pagination Response Fields:** + +- `page`: Current page number +- `limit`: Number of items per page +- `total`: Total number of items matching the query +- `totalPages`: Total number of pages +- `hasNext`: Whether there is a next page +- `hasPrev`: Whether there is a previous page + #### Get Single Customer by ID ``` diff --git a/package-lock.json b/package-lock.json index be8b264..51b7afe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,9 +12,13 @@ "@elysiajs/eden": "^1.4.9", "@hugeicons/core-free-icons": "^4.1.1", "@hugeicons/react": "^1.1.6", + "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-tooltip": "^1.2.8", "@sentry/nextjs": "^10.49.0", "@tabler/icons-react": "^3.41.1", + "@tanstack/react-form": "^1.29.1", + "@tanstack/react-query": "^5.100.5", + "@tanstack/react-query-devtools": "^5.100.5", "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -41,6 +45,7 @@ "react": "19.2.4", "react-day-picker": "^9.14.0", "react-dom": "19.2.4", + "react-dropzone": "^15.0.0", "react-resizable-panels": "^4.10.0", "recharts": "^3.8.0", "shadcn": "^4.3.0", @@ -4051,6 +4056,15 @@ } } }, + "node_modules/@radix-ui/react-icons": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-icons/-/react-icons-1.3.2.tgz", + "integrity": "sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==", + "license": "MIT", + "peerDependencies": { + "react": "^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/@radix-ui/react-id": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", @@ -6346,6 +6360,143 @@ "tailwindcss": "4.2.2" } }, + "node_modules/@tanstack/devtools-event-client": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@tanstack/devtools-event-client/-/devtools-event-client-0.4.3.tgz", + "integrity": "sha512-OZI6QyULw0FI0wjgmeYzCIfbgPsOEzwJtCpa69XrfLMtNXLGnz3d/dIabk7frg0TmHo+Ah49w5I4KC7Tufwsvw==", + "license": "MIT", + "bin": { + "intent": "bin/intent.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/form-core": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/@tanstack/form-core/-/form-core-1.29.1.tgz", + "integrity": "sha512-NIYPO36eEu7nSWvMpbFDQaBWyVtnH/C8fsZ3/XpJUT4uOWgmxsiUvHGbTbDNIQTXAKIkhwEl0sUrqBNn2SfUnw==", + "license": "MIT", + "dependencies": { + "@tanstack/devtools-event-client": "^0.4.1", + "@tanstack/pacer-lite": "^0.1.1", + "@tanstack/store": "^0.9.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/pacer-lite": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@tanstack/pacer-lite/-/pacer-lite-0.1.1.tgz", + "integrity": "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.100.5", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.5.tgz", + "integrity": "sha512-t20KrhKkf0HXzqQkPbJ5erhFesup68BAbwFgYmTrS7bxMF7O5MdmL8jUkik4thsG7Hg00fblz30h6yF1d5TxGg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/query-devtools": { + "version": "5.100.5", + "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.100.5.tgz", + "integrity": "sha512-SuCkVCqqliRYJvm+LEL2U/TcFv92zTnHj6OGrJFHp1v/RsiwamI+ZDgQzbeUrLsJb8/Nj/52aIw0NyDMcVHl4A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-form": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/@tanstack/react-form/-/react-form-1.29.1.tgz", + "integrity": "sha512-hVHk4g0phd0HxRsv2ry6Xt8BqmalT55Q3cokhJBCC1St0hcGZhgwJJbohm9atao45BPG9e55DGvtbwExqZe35g==", + "license": "MIT", + "dependencies": { + "@tanstack/form-core": "1.29.1", + "@tanstack/react-store": "^0.9.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@tanstack/react-start": { + "optional": true + } + } + }, + "node_modules/@tanstack/react-form/node_modules/@tanstack/react-store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz", + "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==", + "license": "MIT", + "dependencies": { + "@tanstack/store": "0.9.3", + "use-sync-external-store": "^1.6.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.100.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.5.tgz", + "integrity": "sha512-aNwj1mi2v2bQ9IxkyR1grLOUkv3BYWoykHy9KDyLNbjC3tsahbOHJibK+Wjtr1wRhG59/AvJhiJG5OlthaCgJA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.100.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@tanstack/react-query-devtools": { + "version": "5.100.5", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.100.5.tgz", + "integrity": "sha512-bItQERx7dJoiI0WEoS4tIrvNnmk4kUYsaQLdIpm4o9Kttmsi5B6xlY6JBDkavstR3hH/R2+VT5dr3L5LBFPW4g==", + "license": "MIT", + "dependencies": { + "@tanstack/query-devtools": "5.100.5" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.100.5", + "react": "^18 || ^19" + } + }, "node_modules/@tanstack/react-table": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", @@ -6366,6 +6517,16 @@ "react-dom": ">=16.8" } }, + "node_modules/@tanstack/store": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz", + "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@tanstack/table-core": { "version": "8.21.3", "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", @@ -7811,6 +7972,15 @@ "node": ">= 0.4" } }, + "node_modules/attr-accept": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", + "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -10094,6 +10264,18 @@ "node": ">=16.0.0" } }, + "node_modules/file-selector": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", + "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", + "license": "MIT", + "dependencies": { + "tslib": "^2.7.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/file-type": { "version": "22.0.1", "resolved": "https://registry.npmjs.org/file-type/-/file-type-22.0.1.tgz", @@ -13749,6 +13931,23 @@ "react": "^19.2.4" } }, + "node_modules/react-dropzone": { + "version": "15.0.0", + "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-15.0.0.tgz", + "integrity": "sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg==", + "license": "MIT", + "dependencies": { + "attr-accept": "^2.2.4", + "file-selector": "^2.1.0", + "prop-types": "^15.8.1" + }, + "engines": { + "node": ">= 10.13" + }, + "peerDependencies": { + "react": ">= 16.8 || 18.0.0" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/package.json b/package.json index 4b27811..647ce53 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,13 @@ "@elysiajs/eden": "^1.4.9", "@hugeicons/core-free-icons": "^4.1.1", "@hugeicons/react": "^1.1.6", + "@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-tooltip": "^1.2.8", "@sentry/nextjs": "^10.49.0", "@tabler/icons-react": "^3.41.1", + "@tanstack/react-form": "^1.29.1", + "@tanstack/react-query": "^5.100.5", + "@tanstack/react-query-devtools": "^5.100.5", "@tanstack/react-table": "^8.21.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -45,6 +49,7 @@ "react": "19.2.4", "react-day-picker": "^9.14.0", "react-dom": "19.2.4", + "react-dropzone": "^15.0.0", "react-resizable-panels": "^4.10.0", "recharts": "^3.8.0", "shadcn": "^4.3.0", diff --git a/src/app/[branch]/customers/page.tsx b/src/app/[branch]/customers/page.tsx index 6097b45..73ba09a 100644 --- a/src/app/[branch]/customers/page.tsx +++ b/src/app/[branch]/customers/page.tsx @@ -1,28 +1,30 @@ import PageContainer from "@/components/layout/page-container"; -import { buttonVariants } from "@/components/ui/button"; -import { Heading } from "@/components/ui/heading"; -import { Separator } from "@/components/ui/separator"; -import { cn } from "@/lib/utils"; -import { IconPlus } from "@tabler/icons-react"; -import Link from "next/link"; +import CustomerListingPage from "@/features/customers/components/customer-listing"; +import { CustomerFormSheetTrigger } from "@/features/customers/components/customer-form-sheet"; +import { searchParamsCache } from "@/lib/searchparams"; +import type { SearchParams } from "nuqs/server"; + +export const metadata = { + title: "Customers", +}; + +type PageProps = { + params: Promise<{ branch: string }>; + searchParams: Promise; +}; + +export default async function CustomersPage(props: PageProps) { + const searchParams = await props.searchParams; + searchParamsCache.parse(searchParams); -export default async function Page({ params }) { - const { branch } = await params; - console.log("branch", branch); return ( - -
-
- - - เพิ่มลูกค้า - -
- -
+ } + > + ); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3a60750..663f011 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -10,6 +10,7 @@ import NextTopLoader from "nextjs-toploader"; import { NuqsAdapter } from "nuqs/adapters/next/app"; import "./globals.css"; import "./theme.css"; +import QueryProvider from "@/components/layout/query-provider"; const META_THEME_COLORS = { light: "#ffffff", @@ -66,12 +67,14 @@ export default async function RootLayout({ disableTransitionOnChange enableColorScheme > - - - - {children} - - + + + + + {children} + + + diff --git a/src/components/file-uploader.tsx b/src/components/file-uploader.tsx index 842b8e3..359a818 100644 --- a/src/components/file-uploader.tsx +++ b/src/components/file-uploader.tsx @@ -1,22 +1,21 @@ -'use client'; +"use client"; -import { IconX, IconUpload } from '@tabler/icons-react'; -import Image from 'next/image'; -import * as React from 'react'; +import { IconX, IconUpload } from "@tabler/icons-react"; +import Image from "next/image"; +import * as React from "react"; import Dropzone, { type DropzoneProps, - type FileRejection -} from 'react-dropzone'; -import { toast } from 'sonner'; + type FileRejection, +} from "react-dropzone"; +import { toast } from "sonner"; -import { Button } from '@/components/ui/button'; -import { Progress } from '@/components/ui/progress'; -import { ScrollArea } from '@/components/ui/scroll-area'; -import { useControllableState } from '@/hooks/use-controllable-state'; -import { cn, formatBytes } from '@/lib/utils'; +import { Button } from "@/components/ui/button"; +import { Progress } from "@/components/ui/progress"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { useControllableState } from "@/hooks/use-controllable-state"; +import { cn } from "@/lib/utils"; -export interface FileUploaderProps - extends React.HTMLAttributes { +export interface FileUploaderProps extends React.HTMLAttributes { /** * Value of the uploader. * @type File[] @@ -58,7 +57,7 @@ export interface FileUploaderProps * ``` * @example accept={["image/png", "image/jpeg"]} */ - accept?: DropzoneProps['accept']; + accept?: DropzoneProps["accept"]; /** * Maximum file size for the uploader. @@ -66,7 +65,7 @@ export interface FileUploaderProps * @default 1024 * 1024 * 2 // 2MB * @example maxSize={1024 * 1024 * 2} // 2MB */ - maxSize?: DropzoneProps['maxSize']; + maxSize?: DropzoneProps["maxSize"]; /** * Maximum number of files for the uploader. @@ -74,7 +73,7 @@ export interface FileUploaderProps * @default 1 * @example maxFiles={5} */ - maxFiles?: DropzoneProps['maxFiles']; + maxFiles?: DropzoneProps["maxFiles"]; /** * Whether the uploader should accept multiple files. @@ -99,7 +98,7 @@ export function FileUploader(props: FileUploaderProps) { onValueChange, onUpload, progresses, - accept = { 'image/*': [] }, + accept = { "image/*": [] }, maxSize = 1024 * 1024 * 2, maxFiles = 1, multiple = false, @@ -110,13 +109,13 @@ export function FileUploader(props: FileUploaderProps) { const [files, setFiles] = useControllableState({ prop: valueProp, - onChange: onValueChange + onChange: onValueChange, }); const onDrop = React.useCallback( (acceptedFiles: File[], rejectedFiles: FileRejection[]) => { if (!multiple && maxFiles === 1 && acceptedFiles.length > 1) { - toast.error('Cannot upload more than 1 file at a time'); + toast.error("Cannot upload more than 1 file at a time"); return; } @@ -127,8 +126,8 @@ export function FileUploader(props: FileUploaderProps) { const newFiles = acceptedFiles.map((file) => Object.assign(file, { - preview: URL.createObjectURL(file) - }) + preview: URL.createObjectURL(file), + }), ); const updatedFiles = files ? [...files, ...newFiles] : newFiles; @@ -155,12 +154,12 @@ export function FileUploader(props: FileUploaderProps) { setFiles([]); return `${target} uploaded`; }, - error: `Failed to upload ${target}` + error: `Failed to upload ${target}`, }); } }, - [files, maxFiles, multiple, onUpload, setFiles] + [files, maxFiles, multiple, onUpload, setFiles], ); function onRemove(index: number) { @@ -186,7 +185,7 @@ export function FileUploader(props: FileUploaderProps) { const isDisabled = disabled || (files?.length ?? 0) >= maxFiles; return ( -
+
{isDragActive ? ( -
-
+
+
-

+

Drop the files here

) : ( -
-
+
+
-
-

+

+

Drag {`'n'`} drop files here, or click to select files

-

+

You can upload {maxFiles > 1 - ? ` ${maxFiles === Infinity ? 'multiple' : maxFiles} - files (up to ${formatBytes(maxSize)} each)` - : ` a file with ${formatBytes(maxSize)}`} + ? ` ${maxFiles === Infinity ? "multiple" : maxFiles} + files (up to ${maxSize} each)` + : ` a file with ${maxSize}`}

@@ -246,8 +245,8 @@ export function FileUploader(props: FileUploaderProps) { )} {files?.length ? ( - -
+ +
{files?.map((file, index) => ( -
+
+
{isFileWithPreview(file) ? ( {file.name} ) : null} -
-
-

+

+
+

{file.name}

-

- {formatBytes(file.size)} -

+

{file.size}

{progress ? : null}
-
+
@@ -313,5 +310,5 @@ function FileCard({ file, progress, onRemove }: FileCardProps) { } function isFileWithPreview(file: File): file is File & { preview: string } { - return 'preview' in file && typeof file.preview === 'string'; + return "preview" in file && typeof file.preview === "string"; } diff --git a/src/components/layout/page-container.tsx b/src/components/layout/page-container.tsx index fea411f..aebdfdb 100644 --- a/src/components/layout/page-container.tsx +++ b/src/components/layout/page-container.tsx @@ -1,22 +1,39 @@ -import React from 'react'; -import { ScrollArea } from '@/components/ui/scroll-area'; +import React from "react"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Heading } from "@/components/ui/heading"; +import { Separator } from "@/components/ui/separator"; export default function PageContainer({ children, - scrollable = true + scrollable = true, + pageTitle, + pageDescription, + pageHeaderAction, }: { children: React.ReactNode; scrollable?: boolean; + pageTitle?: string; + pageDescription?: string; + pageHeaderAction?: React.ReactNode; }) { return ( - <> +
+ {(pageTitle || pageHeaderAction) && ( +
+ {pageTitle && ( + + )} + {pageHeaderAction} +
+ )} + {pageTitle && } {scrollable ? ( - -
{children}
+ +
{children}
) : ( -
{children}
+
{children}
)} - +
); } diff --git a/src/features/customers/api/mutations.ts b/src/features/customers/api/mutations.ts new file mode 100644 index 0000000..1eb1da2 --- /dev/null +++ b/src/features/customers/api/mutations.ts @@ -0,0 +1,35 @@ +import { mutationOptions } from "@tanstack/react-query"; +import { getQueryClient } from "@/lib/query-client"; +import { createCustomer, updateCustomer, deleteCustomer } from "./service"; +import { customerKeys } from "./queries"; +import type { CustomerMutationPayload } from "./types"; + +export const createCustomerMutation = mutationOptions({ + mutationFn: (data: CustomerMutationPayload) => createCustomer(data), + onSuccess: () => { + getQueryClient().invalidateQueries({ queryKey: customerKeys.all }); + }, +}); + +export const updateCustomerMutation = mutationOptions({ + mutationFn: ({ + branch, + id, + values, + }: { + branch: string; + id: string; + values: CustomerMutationPayload; + }) => updateCustomer(branch, id, values), + onSuccess: () => { + getQueryClient().invalidateQueries({ queryKey: customerKeys.all }); + }, +}); + +export const deleteCustomerMutation = mutationOptions({ + mutationFn: ({ branch, id }: { branch: string; id: string }) => + deleteCustomer(branch, id), + onSuccess: () => { + getQueryClient().invalidateQueries({ queryKey: customerKeys.all }); + }, +}); diff --git a/src/features/customers/api/queries.ts b/src/features/customers/api/queries.ts new file mode 100644 index 0000000..5a74daa --- /dev/null +++ b/src/features/customers/api/queries.ts @@ -0,0 +1,27 @@ +import { queryOptions } from "@tanstack/react-query"; +import { getCustomers, getCustomerById } from "./service"; +import type { Customer, CustomerFilters } from "./types"; + +export type { Customer }; + +export const customerKeys = { + all: ["customers"] as const, + list: (filters: CustomerFilters) => + [...customerKeys.all, "list", filters] as const, + detail: (branch: string, id: string) => + [...customerKeys.all, "detail", branch, id] as const, +}; + +export const customersQueryOptions = (filters: CustomerFilters) => + queryOptions({ + queryKey: customerKeys.list(filters), + queryFn: () => getCustomers(filters), + staleTime: 60000, // 1 minute + }); + +export const customerByIdOptions = (branch: string, id: string) => + queryOptions({ + queryKey: customerKeys.detail(branch, id), + queryFn: () => getCustomerById(branch, id), + staleTime: 60000, // 1 minute + }); diff --git a/src/features/customers/api/service.ts b/src/features/customers/api/service.ts new file mode 100644 index 0000000..fbb3b7c --- /dev/null +++ b/src/features/customers/api/service.ts @@ -0,0 +1,110 @@ +import type { + Customer, + CustomerFilters, + CustomersResponse, + CustomerMutationPayload, +} from "./types"; + +const BASE_URL = "/api/customers"; + +export async function getCustomers( + filters: CustomerFilters, +): Promise { + const params = new URLSearchParams(); + + if (filters.page) params.append("page", String(filters.page)); + if (filters.limit) params.append("limit", String(filters.limit)); + if (filters.status) params.append("status", filters.status); + if (filters.sortBy) params.append("sortBy", filters.sortBy); + if (filters.sortOrder) params.append("sortOrder", filters.sortOrder); + + const url = params.toString() ? `${BASE_URL}?${params.toString()}` : BASE_URL; + + const response = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch customers: ${response.statusText}`); + } + + return response.json(); +} + +export async function getCustomerById( + branch: string, + id: string, +): Promise { + const response = await fetch(`${BASE_URL}/${branch}/${id}`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch customer: ${response.statusText}`); + } + + const data = await response.json(); + return data.data; +} + +export async function createCustomer( + data: CustomerMutationPayload, +): Promise { + const response = await fetch(BASE_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + throw new Error(`Failed to create customer: ${response.statusText}`); + } + + const result = await response.json(); + return result.data; +} + +export async function updateCustomer( + branch: string, + id: string, + data: CustomerMutationPayload, +): Promise { + const response = await fetch(`${BASE_URL}/${branch}/${id}`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }); + + if (!response.ok) { + throw new Error(`Failed to update customer: ${response.statusText}`); + } + + const result = await response.json(); + return result.data; +} + +export async function deleteCustomer( + branch: string, + id: string, +): Promise { + const response = await fetch(`${BASE_URL}/${branch}/${id}`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`Failed to delete customer: ${response.statusText}`); + } +} diff --git a/src/features/customers/api/types.ts b/src/features/customers/api/types.ts new file mode 100644 index 0000000..eeba477 --- /dev/null +++ b/src/features/customers/api/types.ts @@ -0,0 +1,51 @@ +export type Customer = { + id: string; + branchId: string; + name: string; + email: string; + phone: string; + company: string; + address: string; + customerStatus: "active" | "inactive" | "pending"; + customerType: "individual" | "corporate"; + crmCustomerCode: string; + erpCustomerCode: string | null; + taxId: string; + createdAt: string; + updatedAt: string; +}; + +export type CustomerFilters = { + page?: number; + limit?: number; + status?: "active" | "inactive" | "pending"; + sortBy?: string; + sortOrder?: "asc" | "desc"; +}; + +export type CustomersResponse = { + success: boolean; + data: Customer[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + hasNext: boolean; + hasPrev: boolean; + }; + message: string; +}; + +export type CustomerMutationPayload = { + name: string; + email: string; + phone: string; + company: string; + address: string; + customerStatus: "active" | "inactive" | "pending"; + customerType: "individual" | "corporate"; + crmCustomerCode: string; + erpCustomerCode?: string | null; + taxId: string; +}; diff --git a/src/features/customers/components/customer-form-sheet.tsx b/src/features/customers/components/customer-form-sheet.tsx new file mode 100644 index 0000000..0260d4f --- /dev/null +++ b/src/features/customers/components/customer-form-sheet.tsx @@ -0,0 +1,222 @@ +"use client"; + +import { useState } from "react"; +import { useAppForm, useFormFields } from "@/components/ui/tanstack-form"; +import { Button } from "@/components/ui/button"; +import { + Sheet, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet"; +import { Icons } from "@/components/icons"; +import { useMutation } from "@tanstack/react-query"; +import { + createCustomerMutation, + updateCustomerMutation, +} from "../api/mutations"; +import type { Customer } from "../api/types"; +import { toast } from "sonner"; +import { customerSchema, type CustomerFormValues } from "../schemas/customer"; +import { + CUSTOMER_STATUS_OPTIONS, + CUSTOMER_TYPE_OPTIONS, +} from "../constants/status-options"; + +interface CustomerFormSheetProps { + customer?: Customer; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function CustomerFormSheet({ + customer, + open, + onOpenChange, +}: CustomerFormSheetProps) { + const isEdit = !!customer; + + const createMutation = useMutation({ + ...createCustomerMutation, + onSuccess: () => { + toast.success("Customer created successfully"); + onOpenChange(false); + form.reset(); + }, + onError: () => toast.error("Failed to create customer"), + }); + + const updateMutation = useMutation({ + ...updateCustomerMutation, + onSuccess: () => { + toast.success("Customer updated successfully"); + onOpenChange(false); + }, + onError: () => toast.error("Failed to update customer"), + }); + + const form = useAppForm({ + defaultValues: { + name: customer?.name ?? "", + email: customer?.email ?? "", + phone: customer?.phone ?? "", + company: customer?.company ?? "", + address: customer?.address ?? "", + customerStatus: customer?.customerStatus ?? "active", + customerType: customer?.customerType ?? "corporate", + crmCustomerCode: customer?.crmCustomerCode ?? "", + erpCustomerCode: customer?.erpCustomerCode ?? null, + taxId: customer?.taxId ?? "", + } as CustomerFormValues, + validators: { + onSubmit: customerSchema, + }, + onSubmit: async ({ value }) => { + if (isEdit && customer) { + await updateMutation.mutateAsync({ + branch: customer.branchId, + id: customer.id, + values: value, + }); + } else { + await createMutation.mutateAsync(value); + } + }, + }); + + const { FormTextField, FormSelectField, FormTextareaField } = + useFormFields(); + + const isPending = createMutation.isPending || updateMutation.isPending; + const isLoading = createMutation.isPending || updateMutation.isPending; + + return ( + + + + {isEdit ? "Edit Customer" : "New Customer"} + + {isEdit + ? "Update the customer details below." + : "Fill in the details to create a new customer."} + + + +
+ + + + + + + + + + + + +
+ + + +
+ + + + + + +
+
+
+ + + + + +
+
+ ); +} + +export function CustomerFormSheetTrigger() { + const [open, setOpen] = useState(false); + + return ( + <> + + + + ); +} diff --git a/src/features/customers/components/customer-listing.tsx b/src/features/customers/components/customer-listing.tsx new file mode 100644 index 0000000..a1db131 --- /dev/null +++ b/src/features/customers/components/customer-listing.tsx @@ -0,0 +1,39 @@ +import { HydrationBoundary, dehydrate } from "@tanstack/react-query"; +import { getQueryClient } from "@/lib/query-client"; +import { searchParamsCache } from "@/lib/searchparams"; +import { customersQueryOptions } from "../api/queries"; +import { + CustomersTable, + CustomersTableSkeleton, +} from "./customers-table/customers-table"; +import { Suspense } from "react"; + +export default function CustomerListingPage() { + const page = searchParamsCache.get("page"); + const name = searchParamsCache.get("name"); + const pageLimit = searchParamsCache.get("perPage"); + const sort = searchParamsCache.get("sort"); + const status = searchParamsCache.get("status"); + + const filters = { + page, + limit: pageLimit, + ...(name && { name }), + ...(sort && { sort }), + ...(status && + ["active", "inactive", "pending"].includes(status) && { + status: status as "active" | "inactive" | "pending", + }), + }; + + const queryClient = getQueryClient(); + void queryClient.prefetchQuery(customersQueryOptions(filters)); + + return ( + + }> + + + + ); +} diff --git a/src/features/customers/components/customers-table/columns.tsx b/src/features/customers/components/customers-table/columns.tsx new file mode 100644 index 0000000..1ce592b --- /dev/null +++ b/src/features/customers/components/customers-table/columns.tsx @@ -0,0 +1,154 @@ +import { ColumnDef } from "@tanstack/react-table"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { DataTableColumnHeader } from "@/components/ui/table/data-table-column-header"; +import { Icons } from "@/components/icons"; +import { Customer } from "../../api/types"; + +export const columns: ColumnDef[] = [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-[2px]" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-[2px]" + /> + ), + enableSorting: false, + enableHiding: false, + }, + { + accessorKey: "name", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+
+ +
+
{row.getValue("name")}
+
+ ), + }, + { + accessorKey: "email", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
{row.getValue("email")}
+ ), + }, + { + accessorKey: "phone", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.getValue("phone")}
, + }, + { + accessorKey: "company", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
{row.getValue("company")}
+ ), + }, + { + accessorKey: "customerStatus", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const status = row.getValue("customerStatus") as string; + + return ( +
+ + {status.charAt(0).toUpperCase() + status.slice(1)} + +
+ ); + }, + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)); + }, + }, + { + accessorKey: "customerType", + header: ({ column }) => ( + + ), + cell: ({ row }) => { + const type = row.getValue("customerType") as string; + return ( +
+ {type === "corporate" ? ( + + ) : ( + + )} + {type} +
+ ); + }, + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)); + }, + }, + { + accessorKey: "crmCustomerCode", + header: ({ column }) => ( + + ), + cell: ({ row }) => ( +
+ {row.getValue("crmCustomerCode")} +
+ ), + }, + { + id: "actions", + cell: ({ row }) => ( +
+ + +
+ ), + }, +]; diff --git a/src/features/customers/components/customers-table/customers-table.tsx b/src/features/customers/components/customers-table/customers-table.tsx new file mode 100644 index 0000000..e9e0596 --- /dev/null +++ b/src/features/customers/components/customers-table/customers-table.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { useState } from "react"; +import { DataTable } from "@/components/ui/table/data-table"; +import { DataTableToolbar } from "@/components/ui/table/data-table-toolbar"; +import { DataTableSkeleton } from "@/components/ui/table/data-table-skeleton"; +import { Button } from "@/components/ui/button"; +import { Icons } from "@/components/icons"; +import { columns } from "./columns"; +import { useDataTable } from "@/hooks/use-data-table"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { parseAsInteger, parseAsString, useQueryStates } from "nuqs"; +import { getSortingStateParser } from "@/lib/parsers"; +import { customersQueryOptions } from "../../api/queries"; +import { + CustomerFormSheet, + CustomerFormSheetTrigger, +} from "../customer-form-sheet"; +import type { Customer } from "../../api/types"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { deleteCustomerMutation } from "../../api/mutations"; +import { toast } from "sonner"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { useParams } from "next/navigation"; + +const columnIds = columns.map((c) => c.id).filter(Boolean) as string[]; + +export function CustomersTable() { + const queryClient = useQueryClient(); + const param = useParams(); + const branch = param?.branch; + const [selectedCustomer, setSelectedCustomer] = useState< + Customer | undefined + >(); + const [isEditSheetOpen, setIsEditSheetOpen] = useState(false); + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [customerToDelete, setCustomerToDelete] = useState(null); + + const [params] = useQueryStates({ + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + name: parseAsString, + status: parseAsString, + sort: getSortingStateParser(columnIds).withDefault([]), + }); + + const filters = { + page: params.page, + limit: params.perPage, + ...(params.name && { search: params.name }), + ...(params.status && + ["active", "inactive", "pending"].includes(params.status) && { + status: params.status as "active" | "inactive" | "pending", + }), + ...(params.sort.length > 0 && { sort: JSON.stringify(params.sort) }), + }; + + const { data } = useSuspenseQuery(customersQueryOptions(filters)); + + const pageCount = Math.ceil((data.pagination?.total ?? 0) / params.perPage); + + const { table } = useDataTable({ + data: data.data || [], + columns, + pageCount, + shallow: true, + debounceMs: 500, + initialState: { + columnPinning: { right: ["actions"] }, + }, + }); + + const deleteMutation = useMutation({ + ...deleteCustomerMutation, + onSuccess: () => { + toast.success("Customer deleted successfully"); + queryClient.invalidateQueries({ queryKey: ["customers"] }); + setDeleteDialogOpen(false); + setCustomerToDelete(null); + }, + onError: () => toast.error("Failed to delete customer"), + }); + + const handleEdit = (customer: Customer) => { + setSelectedCustomer(customer); + setIsEditSheetOpen(true); + }; + + const handleDelete = (customerId: string) => { + setCustomerToDelete(customerId); + setDeleteDialogOpen(true); + }; + + const confirmDelete = () => { + if (customerToDelete) { + deleteMutation.mutate({ branch, id: customerToDelete }); + } + }; + + return ( + <> +
+
+

Customers

+ +
+ + + + +
+ + + + + + + Are you sure? + + This action cannot be undone. This will permanently delete the + customer and all associated data. + + + + Cancel + + {deleteMutation.isPending ? ( + <> + + Deleting... + + ) : ( + "Delete" + )} + + + + + + ); +} + +export function CustomersTableSkeleton() { + return ( +
+
+

Customers

+ +
+ +
+ ); +} diff --git a/src/features/customers/constants/status-options.ts b/src/features/customers/constants/status-options.ts new file mode 100644 index 0000000..33e6efa --- /dev/null +++ b/src/features/customers/constants/status-options.ts @@ -0,0 +1,10 @@ +export const CUSTOMER_STATUS_OPTIONS = [ + { value: "active", label: "Active" }, + { value: "inactive", label: "Inactive" }, + { value: "pending", label: "Pending" }, +]; + +export const CUSTOMER_TYPE_OPTIONS = [ + { value: "individual", label: "Individual" }, + { value: "corporate", label: "Corporate" }, +]; diff --git a/src/features/customers/schemas/customer.ts b/src/features/customers/schemas/customer.ts new file mode 100644 index 0000000..dd01329 --- /dev/null +++ b/src/features/customers/schemas/customer.ts @@ -0,0 +1,16 @@ +import { z } from "zod"; + +export const customerSchema = z.object({ + name: z.string().min(2, "ชื่อต้องมีอย่างน้อย 2 ตัวอักษร"), + email: z.string().email("กรุณาระบุอีเมลที่ถูกต้อง"), + phone: z.string().min(1, "กรุณาระบุเบอร์โทรศัพท์"), + company: z.string().min(1, "กรุณาระบุชื่อบริษัท"), + address: z.string().min(1, "กรุณาระบุที่อยู่"), + customerStatus: z.enum(["active", "inactive", "pending"]), + customerType: z.enum(["individual", "corporate"]), + crmCustomerCode: z.string().min(1, "กรุณาระบุรหัสลูกค้า CRM"), + erpCustomerCode: z.string().nullable().optional(), + taxId: z.string().min(1, "กรุณาระบุเลขประจำตัวผู้เสียภาษี"), +}); + +export type CustomerFormValues = z.infer; diff --git a/src/lib/searchparams.ts b/src/lib/searchparams.ts index 5b55582..3bc5636 100644 --- a/src/lib/searchparams.ts +++ b/src/lib/searchparams.ts @@ -2,8 +2,8 @@ import { createSearchParamsCache, createSerializer, parseAsInteger, - parseAsString -} from 'nuqs/server'; + parseAsString, +} from "nuqs/server"; export const searchParams = { page: parseAsInteger.withDefault(1), @@ -12,7 +12,8 @@ export const searchParams = { gender: parseAsString, category: parseAsString, role: parseAsString, - sort: parseAsString + status: parseAsString, + sort: parseAsString, // advanced filter // filters: getFiltersStateParser().withDefault([]), // joinOperator: parseAsStringEnum(['and', 'or']).withDefault('and') diff --git a/src/middleware/branch.ts b/src/middleware/branch.ts index ff25677..fc26cbd 100644 --- a/src/middleware/branch.ts +++ b/src/middleware/branch.ts @@ -95,7 +95,7 @@ export const branchMiddleware = new Elysia({ name: "branch" }).derive( currentBranchCode: currentBranch.code, accessibleBranches, userGroups, - } as BranchContext; + }; }, ); diff --git a/src/modules/customers/controller.ts b/src/modules/customers/controller.ts index 950a3f4..b049e89 100644 --- a/src/modules/customers/controller.ts +++ b/src/modules/customers/controller.ts @@ -1,8 +1,21 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ import { Elysia, t } from "elysia"; import * as service from "./service"; import { CustomerModel } from "./model"; import { branchMiddleware, type BranchContext } from "@/middleware/branch"; +// Helper to safely extract branch context from any context +// @ts-expect-error - Elysia type inference limitation with middleware +function getBranchContext(ctx: any): BranchContext { + return { + userId: ctx.userId || "", + currentBranchId: ctx.currentBranchId || "", + currentBranchCode: ctx.currentBranchCode || "", + accessibleBranches: ctx.accessibleBranches || [], + userGroups: ctx.userGroups || [], + }; +} + // Create Elysia instance for customers module export const customers = new Elysia({ prefix: "/customers", @@ -10,14 +23,22 @@ export const customers = new Elysia({ }) .use(branchMiddleware) .model(CustomerModel) - // GET /api/customers - Get all customers for current branch + // GET /api/customers - Get all customers for current branch with pagination .get( "/", - async ({ query, currentBranchId, userId }) => { - const { status } = query as { status?: string }; + async (ctx: any) => { + const { query } = ctx; + const { currentBranchId, userId } = getBranchContext(ctx); + const { status, page, limit, sortBy, sortOrder } = query as { + status?: string; + page?: string; + limit?: string; + sortBy?: string; + sortOrder?: "asc" | "desc"; + }; try { - const customers = await service.getCustomersByBranch( + const result = await service.getCustomersByBranch( { currentBranchId, userId, @@ -26,13 +47,25 @@ export const customers = new Elysia({ userGroups: [], }, status, + page || limit + ? { + page: page ? parseInt(page) : undefined, + limit: limit ? parseInt(limit) : undefined, + } + : undefined, + sortBy || sortOrder + ? { + sortBy, + sortOrder, + } + : undefined, ); return { success: true, - data: customers, - count: customers.length, - message: `Found ${customers.length} customer(s)`, + data: result.data, + pagination: result.pagination, + message: `Found ${result.pagination.total} customer(s) (page ${result.pagination.page} of ${result.pagination.totalPages})`, }; } catch (error) { console.error("Error fetching customers:", error); @@ -53,13 +86,24 @@ export const customers = new Elysia({ t.Literal("pending"), ]), ), + page: t.Optional(t.String()), + limit: t.Optional(t.String()), + sortBy: t.Optional(t.String()), + sortOrder: t.Optional(t.Union([t.Literal("asc"), t.Literal("desc")])), }), ), response: t.Union([ t.Object({ success: t.Literal(true), data: t.Array(CustomerModel.Customer), - count: t.Number(), + pagination: t.Object({ + page: t.Number(), + limit: t.Number(), + total: t.Number(), + totalPages: t.Number(), + hasNext: t.Boolean(), + hasPrev: t.Boolean(), + }), message: t.String(), }), t.Object({ @@ -70,7 +114,7 @@ export const customers = new Elysia({ ]), detail: { description: - "Get all customers for the current branch (from middleware)", + "Get all customers for the current branch with pagination and sorting", parameters: [ { name: "status", @@ -82,6 +126,49 @@ export const customers = new Elysia({ }, description: "Filter customers by status", }, + { + name: "page", + in: "query", + required: false, + schema: { + type: "integer", + default: 1, + }, + description: "Page number (default: 1)", + }, + { + name: "limit", + in: "query", + required: false, + schema: { + type: "integer", + default: 20, + maximum: 100, + }, + description: "Items per page (default: 20, max: 100)", + }, + { + name: "sortBy", + in: "query", + required: false, + schema: { + type: "string", + default: "createdAt", + }, + description: + "Field to sort by (e.g., name, createdAt, customerStatus)", + }, + { + name: "sortOrder", + in: "query", + required: false, + schema: { + type: "string", + enum: ["asc", "desc"], + default: "desc", + }, + description: "Sort order (default: desc)", + }, ], security: [ { @@ -91,11 +178,13 @@ export const customers = new Elysia({ }, }, ) - // GET /api/customers/:id - Get single customer by ID + // GET /api/customers/:customerId - Get single customer by ID .get( - "/:id", - async ({ params, currentBranchId, userId }) => { - const { id } = params; + "/:customerId", + async (ctx: any) => { + const { params } = ctx; + const { currentBranchId, userId } = getBranchContext(ctx); + const { customerId } = params; try { const customer = await service.getCustomerById( @@ -106,7 +195,7 @@ export const customers = new Elysia({ accessibleBranches: [], userGroups: [], }, - id, + customerId, ); if (!customer) { @@ -131,7 +220,7 @@ export const customers = new Elysia({ }, { params: t.Object({ - id: t.String(), + customerId: t.String(), }), response: t.Union([ t.Object({ @@ -157,7 +246,10 @@ export const customers = new Elysia({ // POST /api/customers - Create new customer .post( "/", - async ({ body, currentBranchId, userId }) => { + async (ctx: any) => { + const { body } = ctx; + const { currentBranchId, userId } = getBranchContext(ctx); + try { // Generate CRM customer code const crmCustomerCode = @@ -201,7 +293,6 @@ export const customers = new Elysia({ customerStatus: t.Optional(t.String()), customerType: t.Optional(t.String()), taxId: t.Optional(t.String()), - // Add other fields as needed }), response: t.Union([ t.Object({ @@ -225,11 +316,13 @@ export const customers = new Elysia({ }, }, ) - // PUT /api/customers/:id - Update customer + // PUT /api/customers/:customerId - Update customer .put( - "/:id", - async ({ params, body, currentBranchId, userId }) => { - const { id } = params; + "/:customerId", + async (ctx: any) => { + const { params, body } = ctx; + const { currentBranchId, userId } = getBranchContext(ctx); + const { customerId } = params; try { const customer = await service.updateCustomer( @@ -240,7 +333,7 @@ export const customers = new Elysia({ accessibleBranches: [], userGroups: [], }, - id, + customerId, body, ); @@ -267,7 +360,7 @@ export const customers = new Elysia({ }, { params: t.Object({ - id: t.String(), + customerId: t.String(), }), body: t.Object({ name: t.Optional(t.String()), @@ -277,7 +370,6 @@ export const customers = new Elysia({ address: t.Optional(t.String()), customerStatus: t.Optional(t.String()), erpCustomerCode: t.Optional(t.String()), - // Add other fields as needed }), response: t.Union([ t.Object({ @@ -301,11 +393,13 @@ export const customers = new Elysia({ }, }, ) - // DELETE /api/customers/:id - Delete customer (soft delete) + // DELETE /api/customers/:customerId - Delete customer (soft delete) .delete( - "/:id", - async ({ params, currentBranchId, userId }) => { - const { id } = params; + "/:customerId", + async (ctx: any) => { + const { params } = ctx; + const { currentBranchId, userId } = getBranchContext(ctx); + const { customerId } = params; try { const customer = await service.deleteCustomer( @@ -316,7 +410,7 @@ export const customers = new Elysia({ accessibleBranches: [], userGroups: [], }, - id, + customerId, ); if (!customer) { @@ -342,7 +436,7 @@ export const customers = new Elysia({ }, { params: t.Object({ - id: t.String(), + customerId: t.String(), }), response: t.Union([ t.Object({ @@ -373,7 +467,9 @@ export const customers = new Elysia({ // GET /api/customers/:customerId/contacts - Get visible contacts for customer .get( "/:customerId/contacts", - async ({ params, currentBranchId, userId }) => { + async (ctx: any) => { + const { params } = ctx; + const { currentBranchId, userId } = getBranchContext(ctx); const { customerId } = params; try { @@ -444,7 +540,9 @@ export const customers = new Elysia({ // POST /api/customers/:customerId/contacts - Create contact .post( "/:customerId/contacts", - async ({ params, body, currentBranchId, userId }) => { + async (ctx: any) => { + const { params, body } = ctx; + const { currentBranchId, userId } = getBranchContext(ctx); const { customerId } = params; try { @@ -522,7 +620,9 @@ export const customers = new Elysia({ // PUT /api/contacts/:contactId - Update contact .put( "/contacts/:contactId", - async ({ params, body, currentBranchId, userId }) => { + async (ctx: any) => { + const { params, body } = ctx; + const { currentBranchId, userId } = getBranchContext(ctx); const { contactId } = params; try { @@ -600,7 +700,9 @@ export const customers = new Elysia({ // POST /api/contacts/:contactId/share - Share contact (make public) .post( "/contacts/:contactId/share", - async ({ params, currentBranchId, userId }) => { + async (ctx: any) => { + const { params } = ctx; + const { currentBranchId, userId } = getBranchContext(ctx); const { contactId } = params; try { @@ -669,7 +771,9 @@ export const customers = new Elysia({ // POST /api/contacts/:contactId/unshare - Unshare contact (make private) .post( "/contacts/:contactId/unshare", - async ({ params, currentBranchId, userId }) => { + async (ctx: any) => { + const { params } = ctx; + const { currentBranchId, userId } = getBranchContext(ctx); const { contactId } = params; try { @@ -738,7 +842,9 @@ export const customers = new Elysia({ // DELETE /api/contacts/:contactId - Delete contact .delete( "/contacts/:contactId", - async ({ params, currentBranchId, userId }) => { + async (ctx: any) => { + const { params } = ctx; + const { currentBranchId, userId } = getBranchContext(ctx); const { contactId } = params; try { @@ -810,7 +916,9 @@ export const customers = new Elysia({ // POST /api/contacts/:contactId/share-with - Share contact with specific user .post( "/contacts/:contactId/share-with", - async ({ params, body, currentBranchId, userId }) => { + async (ctx: any) => { + const { params, body } = ctx; + const { currentBranchId, userId } = getBranchContext(ctx); const { contactId } = params; const { targetUserId, notes } = body as { targetUserId: string; @@ -887,7 +995,9 @@ export const customers = new Elysia({ // DELETE /api/contacts/:contactId/share/:targetUserId - Unshare contact from specific user .delete( "/contacts/:contactId/share/:targetUserId", - async ({ params, currentBranchId, userId }) => { + async (ctx: any) => { + const { params } = ctx; + const { currentBranchId, userId } = getBranchContext(ctx); const { contactId, targetUserId } = params; try { @@ -958,7 +1068,9 @@ export const customers = new Elysia({ // GET /api/contacts/:contactId/shares - Get all shares for a contact .get( "/contacts/:contactId/shares", - async ({ params, currentBranchId, userId }) => { + async (ctx: any) => { + const { params } = ctx; + const { currentBranchId, userId } = getBranchContext(ctx); const { contactId } = params; try { @@ -1029,7 +1141,9 @@ export const customers = new Elysia({ // GET /api/contacts/shared-with-me - Get contacts shared with current user .get( "/contacts/shared-with-me", - async ({ query, currentBranchId, userId }) => { + async (ctx: any) => { + const { query } = ctx; + const { currentBranchId, userId } = getBranchContext(ctx); const { customerId } = query as { customerId?: string }; try { diff --git a/src/modules/customers/service.ts b/src/modules/customers/service.ts index ad2942a..dfccc31 100644 --- a/src/modules/customers/service.ts +++ b/src/modules/customers/service.ts @@ -10,7 +10,7 @@ import { type CustomerContactShare, type NewCustomerContactShare, } from "@/database/schema"; -import { eq, and, or, sql, exists } from "drizzle-orm"; +import { eq, and, or, sql, exists, desc, asc } from "drizzle-orm"; import { BranchContext } from "@/middleware/branch"; /** @@ -23,33 +23,82 @@ import { BranchContext } from "@/middleware/branch"; // ========================================================= /** - * Get all customers for the current branch + * Get all customers for the current branch with pagination and sorting * @param context - Branch context from middleware * @param status - Optional status filter - * @returns Array of customers + * @param pagination - Pagination options (page, limit) + * @param sorting - Sorting options (sortBy, sortOrder) + * @returns Paginated customers with metadata */ export async function getCustomersByBranch( context: BranchContext, status?: string, -): Promise { + pagination?: { page?: number; limit?: number }, + sorting?: { sortBy?: string; sortOrder?: "asc" | "desc" }, +): Promise<{ + data: Customer[]; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + hasNext: boolean; + hasPrev: boolean; + }; +}> { const { currentBranchId } = context; + // Default values + const page = pagination?.page || 1; + const limit = Math.min(pagination?.limit || 20, 100); // Max 100 per page + const offset = (page - 1) * limit; + const sortBy = sorting?.sortBy || "createdAt"; + const sortOrder = sorting?.sortOrder || "desc"; + + // Build WHERE conditions + const whereConditions = [eq(customers.branchId, currentBranchId)]; if (status) { - return await db - .select() - .from(customers) - .where( - and( - eq(customers.branchId, currentBranchId), - eq(customers.customerStatus, status), - ), - ); + whereConditions.push(eq(customers.customerStatus, status)); } - return await db + // Build ORDER BY clause + let orderByClause; + const sortField = sortBy as keyof Customer; + if (sortOrder === "asc") { + orderByClause = asc(customers[sortField]); + } else { + orderByClause = desc(customers[sortField]); + } + + // Get total count + const [{ count: totalCount }] = await db + .select({ count: sql`count(*)` }) + .from(customers) + .where(and(...whereConditions)); + + // Get paginated data + const data = await db .select() .from(customers) - .where(eq(customers.branchId, currentBranchId)); + .where(and(...whereConditions)) + .orderBy(orderByClause) + .limit(limit) + .offset(offset); + + // Calculate pagination metadata + const totalPages = Math.ceil(Number(totalCount) / limit); + + return { + data, + pagination: { + page, + limit, + total: Number(totalCount), + totalPages, + hasNext: page < totalPages, + hasPrev: page > 1, + }, + }; } /**