task-b complate

This commit is contained in:
phaichayon
2026-06-11 16:55:45 +07:00
parent 7a390cf0df
commit 67545d9295
135 changed files with 3295 additions and 6385 deletions

View File

@@ -1,21 +0,0 @@
# CLAUDE.md
This is a Next.js 16 + shadcn/ui admin dashboard starter kit.
## Key References
- **[AGENTS.md](./AGENTS.md)** — Full project overview, tech stack, structure, conventions, data fetching patterns, deployment
- **[docs/forms.md](./docs/forms.md)** — Form system: TanStack Form + Zod, composable fields, validation, multi-step, sheet/dialog forms
- **[docs/themes.md](./docs/themes.md)** — Theme system: OKLCH colors, adding themes, font config
- **[docs/nav-rbac.md](./docs/nav-rbac.md)** — Navigation RBAC: access control, Clerk integration
- **[docs/clerk_setup.md](./docs/clerk_setup.md)** — Clerk auth setup: organizations, billing, environment variables
## Critical Conventions
- **React Query** for all data fetching — `void prefetchQuery()` on server + `useSuspenseQuery` on client (standard TanStack pattern), `useMutation` for forms, `HydrationBoundary` + `dehydrate` for hydration, `<Suspense fallback>` for streaming
- **API layer** per feature — `api/types.ts``api/service.ts``api/queries.ts`; queries use key factories (`entityKeys.all/list/detail`); components import from service and queries, never from mock APIs directly
- **nuqs** for URL search params — `searchParamsCache` on server, `useQueryStates` on client, use `getSortingStateParser` for sort (same parser as `useDataTable`)
- **Icons** — only import from `@/components/icons`, never from `@tabler/icons-react` directly
- **Forms** — use `useAppForm` + `useFormFields<T>()` from `@/components/ui/tanstack-form`
- **Page headers** — use `PageContainer` props (`pageTitle`, `pageDescription`, `pageHeaderAction`), never import `<Heading>` manually
- **Formatting** — single quotes, JSX single quotes, no trailing comma, 2-space indent

View File

@@ -0,0 +1,426 @@
# Task A Template Audit
## 1. Executive Summary
This repository is already partway through the migration described in the project instructions. The current production-facing baseline is `Auth.js` plus app-owned `users`, `organizations`, and `memberships`, with `products` and `users` already using Route Handlers plus Drizzle. The dashboard still contains a mix of real app patterns and template/demo seams, so Task B should extend only the migrated patterns and avoid inheriting placeholder implementations.
The convention freeze for ALLA OS CRM vNext should be:
- Primary tenant boundary is `organizationId`.
- Membership, role, and permission checks live inside the active organization context.
- `branchId` is a business sub-scope inside an organization, not the tenant key.
- New CRM production work should follow the `products` and `users` module architecture, while using `Layout.md` as the structural UI source of truth for CRM pages.
No required audit path was missing. `Layout.md`, `AGENTS.md`, `README.md`, `package.json`, `src/db/schema.ts`, `src/lib/auth`, `src/app/dashboard`, `src/features`, and `src/components` are all present.
## 2. Project Structure
Observed top-level implementation anchors:
- App Router lives under `src/app`.
- Shared UI primitives live under `src/components/ui`.
- Layout primitives live under `src/components/layout`.
- Feature modules live under `src/features`.
- Drizzle schema is centralized in `src/db/schema.ts`.
- Shared auth helpers live under `src/lib/auth`.
- API boundaries live under `src/app/api/**`.
Observed feature directories:
- `auth`
- `chat`
- `crm`
- `elements`
- `example-dashboard`
- `forms`
- `kanban`
- `notifications`
- `overview`
- `products`
- `profile`
- `react-query-demo`
- `setup`
- `users`
Freeze recommendation:
- Treat `products` and `users` as the authoritative app-owned CRUD reference.
- Treat `crm`, `example-dashboard`, `forms`, `react-query-demo`, and several dashboard demo routes as template/demo seams unless and until they are migrated behind route handlers and auth-aware persistence.
## 3. Layout.md Findings
`Layout.md` is a structural skeleton, not a token source. It explicitly says no design tokens, colors, text, or icons are embedded directly and everything is represented by semantic placeholders.
Important frozen rules from `Layout.md`:
- CRM detail pages should use Template A.
- CRM list pages should use Template B.
- Page composition should remain slot-based: header, action row, preview, progress, main column, side column, dialogs.
- Layout spacing should map to semantic spacing slots rather than inventing page-local spacing rules.
- Cards, tabs, summary grids, timelines, and action rows are part of the intended page grammar.
- Detail pages are expected to support status-driven actions, preview panels, and side-column quick info where the business flow needs them.
Implication for Task B:
- Build CRM UI by mapping repository components and tokens onto the `Layout.md` skeleton.
- Do not invent a new page grammar if the existing template already covers the CRM use case.
## 4. kiranism-shadcn-dashboard Skill Findings
The skill is directionally correct but partially stale relative to the current repo state.
Still valid:
- Use Auth.js, Drizzle, route handlers, and app-owned org/membership semantics.
- Keep feature code under `src/features/<name>/`.
- Use `types.ts -> service.ts -> queries.ts -> mutations.ts`.
- Use local `apiClient` from feature services.
- Prefer server prefetch plus `HydrationBoundary`.
Stale compared with current code:
- The skill says there is no committed `auth.ts`, Drizzle schema, or shared RBAC model. Those now exist in `src/auth.ts`, `src/db/schema.ts`, and `src/lib/auth/*`.
- The skill says `src/features/products/api/service.ts` and `src/features/users/api/service.ts` still call mock data. They now call local route handlers through `apiClient`.
Convention freeze:
- Use the skill for architecture direction.
- Use the repository code as the final source of truth when the skill and code disagree.
## 5. Routing Convention
Observed routing pattern:
- Global dashboard shell: `src/app/dashboard/layout.tsx`
- Feature pages: `src/app/dashboard/**/page.tsx`
- API boundaries: `src/app/api/**/route.ts`
- Auth entrypoint: `src/app/api/auth/[...nextauth]/route.ts`
Dashboard routes currently mix three categories:
- Migrated app-owned routes: `product`, `users`, `workspaces`
- Template utility/demo routes: `forms`, `react-query`, `elements`, `overview`, `kanban`, `notifications`, `billing`, `profile`
- CRM demo routes: `crm/**`
Freeze recommendation:
- New CRM production pages should live under `src/app/dashboard/crm/**`.
- Production data for CRM must move through `src/app/api/crm/**` or feature-specific route handler namespaces, not direct mock service access.
## 6. Dashboard Layout Convention
`src/app/dashboard/layout.tsx` establishes the dashboard shell:
- `KBar`
- `SidebarProvider`
- `AppSidebar`
- `SidebarInset`
- `Header`
- `InfobarProvider`
- `InfoSidebar`
`src/components/layout/page-container.tsx` is the standard page wrapper and should remain the default page-level container:
- Handles title and description through `Heading`
- Supports page header actions
- Supports access fallback state
- Supports loading skeleton state
Freeze recommendation:
- Use `PageContainer` for dashboard pages instead of composing page headers ad hoc.
- Preserve the existing shell and only replace inner feature content.
## 7. Feature Folder Convention
Strong existing reference pattern:
- `src/features/products/api/{types,service,queries,mutations}.ts`
- `src/features/users/api/{types,service,queries,mutations}.ts`
- `src/features/<feature>/components/**`
- `src/features/<feature>/schemas/**` when forms exist
Freeze recommendation:
- CRM production work should follow the same split:
- `api/types.ts`
- `api/service.ts`
- `api/queries.ts`
- `api/mutations.ts`
- `components/**`
- `schemas/**` for form validation
## 8. Component Convention
Observed shared conventions:
- Shared UI primitives live under `src/components/ui/**`.
- Icons should be imported from `@/components/icons`.
- Page headers should go through `PageContainer`.
- Contextual documentation uses `InfoSidebar` plus `infoContent`.
Server/client split:
- Server components are the default for route entry pages.
- Client components are used for interactive tables, forms, switchers, and session-driven sidebar behavior.
- `AppSidebar`, `OrgSwitcher`, forms, and table shells are client components.
Freeze recommendation:
- Keep route pages server-first.
- Push interaction into feature components and shared UI primitives.
## 9. Form Convention
Observed form system:
- Primary form hook is `useAppForm` from `src/components/ui/tanstack-form.tsx`.
- Typed field helpers come from `useFormFields<T>()`.
- Validation is Zod-based.
- Form dialogs/sheets submit through React Query mutations.
Observed sheet/dialog pattern:
- `src/features/users/components/user-form-sheet.tsx` is the best current reference.
- Forms use `Sheet`, `SheetContent`, `SheetHeader`, `SheetFooter`.
- Submit button uses `<Button isLoading={isPending}>`.
Freeze recommendation:
- CRM forms should use `useAppForm`, typed fields, Zod schemas, and mutation-backed submission.
- Prefer `Sheet` for create/edit overlays when the current dashboard pattern already uses it.
## 10. Table Convention
Observed table pattern:
- Shared table shell is `src/components/ui/table/data-table.tsx`.
- State management helper is `src/hooks/use-data-table.ts`.
- URL state is driven by `nuqs`.
- Feature table pages prefetch on the server and render table components on the client.
Key conventions:
- Manual pagination, sorting, and filtering are expected.
- Query string keys are standardized via `src/lib/searchparams.ts`.
- Data tables rely on TanStack Table and TanStack Query.
Freeze recommendation:
- CRM list pages should keep the same table state model when the UI is tabular.
- If a CRM list is card-based per `Layout.md` Template B, the server data contract can still reuse the same filter, sort, and pagination conventions.
## 11. API / Server Action Convention
Observed production boundary:
- Feature services call `apiClient`.
- Route handlers perform auth, access control, and database work.
- Current repo prefers route handlers over direct client-to-DB access.
Observed examples:
- `src/app/api/products/route.ts`
- `src/app/api/products/[id]/route.ts`
- `src/app/api/users/route.ts`
- `src/app/api/users/[id]/route.ts`
- `src/app/api/organizations/route.ts`
- `src/app/api/organizations/active/route.ts`
Freeze recommendation:
- CRM production code should follow route handlers plus Drizzle, not in-memory feature services.
- Server actions are acceptable only when they fit naturally, but route handlers are the stronger established repo pattern.
## 12. Drizzle / Database Convention
Observed schema in `src/db/schema.ts`:
- `users`
- `organizations`
- `memberships`
- `products`
Current conventions:
- IDs for app-owned auth entities are `text` primary keys.
- Organization-scoped business entities include `organizationId`.
- Timestamps are timezone-aware and default to `now`.
- `users.activeOrganizationId` stores active workspace context.
- `memberships.permissions` is an array column.
Freeze recommendation for CRM vNext:
- Every CRM entity that belongs to a tenant must carry `organizationId`.
- `branchId` should be an additional column where needed for business scoping, not a replacement for `organizationId`.
- Audit and workflow entities should reference organization first, then branch if applicable.
## 13. Auth Convention
Current auth baseline is real and already committed:
- Auth.js config lives in `src/auth.ts`.
- Credentials provider checks `users.password_hash`.
- Session is enriched with organizations, active organization, membership role, business role, and active permissions.
- `src/proxy.ts` protects dashboard and protected API routes.
Preferred server helpers:
- `requireSession()`
- `requireSystemRole()`
- `requireOrganizationAccess()`
Freeze recommendation:
- All new CRM protected routes should use shared helpers from `src/lib/auth/session.ts`.
- Do not introduce Clerk or alternate auth paths.
## 14. Organization / Membership / Permission Convention
Current app-owned model:
- `users.systemRole`: global role such as `super_admin | user`
- `memberships.role`: organization role such as `admin | user`
- `memberships.businessRole`: business/department role such as `it_admin`, `helpdesk`, `auditor`
- `memberships.permissions`: fine-grained permissions array
Relevant code:
- `src/lib/auth/rbac.ts`
- `src/lib/auth/session.ts`
- `src/config/nav-config.ts`
- `src/hooks/use-nav.ts`
Freeze recommendation:
- CRM authorization must be organization-aware first.
- Membership role and explicit permissions should gate organization-scoped actions.
- Business role can layer domain-specific behavior but should not replace organization membership checks.
## 15. Branch Scope Convention
Task A explicitly requires organization-first scoping. The current repo supports that direction.
Evidence:
- Existing real entities use `organizationId` as the tenant boundary.
- Active context switching works at the organization level via `users.activeOrganizationId` and `/api/organizations/active`.
- CRM mock services use `branchId` inside domain records such as customers, enquiries, quotations, and approvals, which implies branch is a nested business scope.
Frozen convention:
- `organizationId` = tenant / workspace / company group scope
- `membership` = user access inside organization
- `branchId` = business branch / operational document scope inside organization
Do not design CRM around branch-first tenancy.
## 16. UI / Design Token Convention
Observed visual/token system:
- Theme system lives under `src/components/themes/**`.
- Theme CSS entrypoint is `src/styles/theme.css`.
- Default theme is `mongodb` from `src/components/themes/theme.config.ts`.
- Multiple named themes already exist; this repo already has a tokenized visual system.
Observed dashboard tone:
- Sidebar-based admin shell
- Sticky translucent header
- Theme switching and documentation sidebar
- shadcn cards, sheets, dialogs, tables, and Radix-based interactions
Freeze recommendation:
- Reuse the existing theme/token system.
- Use `Layout.md` for CRM structure, but map it onto current shadcn/tailwind/theme primitives.
- Do not create a parallel design system for Task B.
## 17. CRM vNext Recommended Convention
Recommended convention freeze for Task B:
- Routing:
- Use `src/app/dashboard/crm/**` for pages.
- Add route handlers under `src/app/api/crm/**` or sub-feature namespaces.
- Data model:
- Every CRM entity stores `organizationId`.
- Add `branchId` only as sub-scope where operationally required.
- Feature shape:
- Follow `types.ts -> service.ts -> queries.ts -> mutations.ts -> components`.
- Auth:
- Gate all real CRM reads and writes through `requireOrganizationAccess()`.
- UI:
- Use `PageContainer`.
- Use `Layout.md` Template A for detail pages and Template B for list pages.
- Reuse shared shadcn primitives, theme system, icons registry, and infobar pattern.
- Data fetching:
- Server prefetch with `getQueryClient()`
- `HydrationBoundary`
- client consumption through React Query
- Forms:
- `useAppForm`
- Zod schema
- React Query mutations
- Tables/lists:
- Keep `nuqs` filter/sort/page conventions
- Reuse `useDataTable` when the UX is tabular
## 18. Files That Should Not Be Touched
For Task B, these should be treated as source-of-truth or stable infrastructure unless there is a deliberate migration step:
- `Layout.md`
- `AGENTS.md`
- `src/auth.ts`
- `src/lib/auth/session.ts`
- `src/lib/auth/rbac.ts`
- `src/db/schema.ts`
- `src/components/ui/**`
- `src/components/layout/page-container.tsx`
- `src/components/themes/**`
- `src/styles/theme.css`
Files to avoid using as production references:
- `src/constants/mock-api.ts`
- `src/constants/mock-api-users.ts`
- `src/features/crm/api/service.ts`
- `src/features/crm/api/queries.ts`
- other demo/template routes that still depend on mock or placeholder data
These legacy/demo files do not need immediate deletion, but Task B should not extend them as the long-term production path.
## 19. Risks / Concerns
Key risks before Task B:
- The repo currently mixes migrated app-owned code and demo/template code, so copying patterns from the wrong feature can reintroduce mock architecture.
- `src/features/crm/api/service.ts` is still in-memory mock state and currently uses `branchId` across CRM records without a real persisted organization boundary.
- Some nav and page access behavior is client-visible UX filtering only; all CRM protection must still be enforced server-side.
- `src/proxy.ts` protects broad route classes, but fine-grained CRM permission rules will still need route handler or page-level checks.
- Existing skill text is partially stale, so implementation decisions should always be verified against repository code.
## 20. Task B Implementation Plan
Recommended path for Task B:
1. Define CRM Drizzle tables with `organizationId` as the tenant boundary and `branchId` as optional business sub-scope.
2. Add shared CRM API contracts under `src/features/crm/api/types.ts` that reflect real route handler payloads.
3. Introduce CRM route handlers under `src/app/api/crm/**` and gate them with `requireOrganizationAccess()`.
4. Replace `src/features/crm/api/service.ts` mock-backed functions with `apiClient` calls to local route handlers.
5. Keep existing CRM route/page structure where possible, but refit the data path to the real app boundary.
6. For list pages, preserve current query, pagination, sort, and hydration conventions.
7. For detail pages, implement UI against `Layout.md` Template A using existing shared primitives and theme tokens.
8. Only after the boundary is real should branch-specific business logic be layered into CRM workflows.
Final convention freeze:
- Organization-first
- Membership-aware
- Branch as sub-scope
- Route-handler plus Drizzle
- Server-enforced RBAC
- `Layout.md` as CRM layout source of truth
- `products` and `users` as production architecture reference

View File

@@ -0,0 +1,46 @@
CREATE TABLE "document_sequences" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"branch_id" text DEFAULT '' NOT NULL,
"document_type" text NOT NULL,
"prefix" text NOT NULL,
"period" text NOT NULL,
"current_number" integer DEFAULT 0 NOT NULL,
"padding_length" integer DEFAULT 3 NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "ms_options" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"category" text NOT NULL,
"code" text NOT NULL,
"label" text NOT NULL,
"value" text,
"parent_id" text,
"sort_order" integer DEFAULT 0 NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"metadata" jsonb,
"deleted_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "tr_audit_logs" (
"id" text PRIMARY KEY NOT NULL,
"organization_id" text NOT NULL,
"branch_id" text,
"user_id" text NOT NULL,
"entity_type" text NOT NULL,
"entity_id" text NOT NULL,
"action" text NOT NULL,
"before_data" jsonb,
"after_data" jsonb,
"request_id" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX "document_sequences_org_doc_period_branch_idx" ON "document_sequences" USING btree ("organization_id","document_type","period","branch_id");--> statement-breakpoint
CREATE UNIQUE INDEX "ms_options_org_category_code_idx" ON "ms_options" USING btree ("organization_id","category","code");

View File

@@ -0,0 +1,653 @@
{
"id": "a21ab199-cb43-48b2-b810-7d4afb9baaef",
"prevId": "1e236d6a-ca42-4830-b230-67a7c233d87b",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.document_sequences": {
"name": "document_sequences",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"organization_id": {
"name": "organization_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"branch_id": {
"name": "branch_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"document_type": {
"name": "document_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"prefix": {
"name": "prefix",
"type": "text",
"primaryKey": false,
"notNull": true
},
"period": {
"name": "period",
"type": "text",
"primaryKey": false,
"notNull": true
},
"current_number": {
"name": "current_number",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"padding_length": {
"name": "padding_length",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 3
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"document_sequences_org_doc_period_branch_idx": {
"name": "document_sequences_org_doc_period_branch_idx",
"columns": [
{
"expression": "organization_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "document_type",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "period",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "branch_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.memberships": {
"name": "memberships",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"organization_id": {
"name": "organization_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'user'"
},
"business_role": {
"name": "business_role",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'viewer'"
},
"permissions": {
"name": "permissions",
"type": "text[]",
"primaryKey": false,
"notNull": true,
"default": "'{}'"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.ms_options": {
"name": "ms_options",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"organization_id": {
"name": "organization_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true
},
"code": {
"name": "code",
"type": "text",
"primaryKey": false,
"notNull": true
},
"label": {
"name": "label",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": false
},
"parent_id": {
"name": "parent_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"metadata": {
"name": "metadata",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"deleted_at": {
"name": "deleted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"ms_options_org_category_code_idx": {
"name": "ms_options_org_category_code_idx",
"columns": [
{
"expression": "organization_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "category",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "code",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.organizations": {
"name": "organizations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"plan": {
"name": "plan",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'free'"
},
"created_by": {
"name": "created_by",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"organizations_slug_idx": {
"name": "organizations_slug_idx",
"columns": [
{
"expression": "slug",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.products": {
"name": "products",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"identity": {
"type": "always",
"name": "products_id_seq",
"schema": "public",
"increment": "1",
"startWith": "1",
"minValue": "1",
"maxValue": "2147483647",
"cache": "1",
"cycle": false
}
},
"organization_id": {
"name": "organization_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"category": {
"name": "category",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": true
},
"photo_url": {
"name": "photo_url",
"type": "text",
"primaryKey": false,
"notNull": true
},
"price": {
"name": "price",
"type": "double precision",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tr_audit_logs": {
"name": "tr_audit_logs",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"organization_id": {
"name": "organization_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"branch_id": {
"name": "branch_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"entity_type": {
"name": "entity_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"entity_id": {
"name": "entity_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"action": {
"name": "action",
"type": "text",
"primaryKey": false,
"notNull": true
},
"before_data": {
"name": "before_data",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"after_data": {
"name": "after_data",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"request_id": {
"name": "request_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.users": {
"name": "users",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"password_hash": {
"name": "password_hash",
"type": "text",
"primaryKey": false,
"notNull": true
},
"system_role": {
"name": "system_role",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'user'"
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"active_organization_id": {
"name": "active_organization_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"users_email_idx": {
"name": "users_email_idx",
"columns": [
{
"expression": "email",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -8,6 +8,13 @@
"when": 1781148450992,
"tag": "0000_icy_lizard",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1781171200406,
"tag": "0001_orange_mandarin",
"breakpoints": true
}
]
}

View File

@@ -1,53 +0,0 @@
# =================================================================
# Authentication (Auth.js)
# =================================================================
AUTH_SECRET=change-this-to-a-long-random-secret
# =================================================================
# Database (PostgreSQL)
# =================================================================
DATABASE_URL=postgres://postgres:postgres@localhost:5432/training_system
# =================================================================
# Initial Super Admin Seed
# =================================================================
SUPER_ADMIN_EMAIL=admin@example.com
SUPER_ADMIN_PASSWORD=change-this-password
SUPER_ADMIN_NAME=Super Admin
# Optional: set to "true" if you want the seed script to overwrite
# the existing super admin password on reruns.
SUPER_ADMIN_RESET_PASSWORD=false
# =================================================================
# Build Configuration
# =================================================================
BUILD_STANDALONE=
# =================================================================
# Error Tracking (Sentry)
# =================================================================
NEXT_PUBLIC_SENTRY_DSN=
NEXT_PUBLIC_SENTRY_ORG=
NEXT_PUBLIC_SENTRY_PROJECT=
SENTRY_AUTH_TOKEN=
NEXT_PUBLIC_SENTRY_DISABLED="false"
# =================================================================
# Notes
# =================================================================
# 1. Rename this file to `.env.local` for local development
# 2. Do not commit real secrets
# 3. AUTH_SECRET is required in production
# 4. DATABASE_URL must point to a reachable PostgreSQL instance
# 5. Run `npm run setup:db` on first setup to apply migrations and seed the super admin

4
package-lock.json generated
View File

@@ -1,11 +1,11 @@
{
"name": "alla-itcenter",
"name": "alla-allaos-fullstack",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "alla-itcenter",
"name": "alla-allaos-fullstack",
"version": "1.0.0",
"dependencies": {
"@dnd-kit/core": "^6.3.1",

View File

@@ -1,337 +0,0 @@
# ALLA-ITCENTER Roadmap
Version: 1.0
Status: Planning
---
# Vision
ALLA-ITCENTER เป็นแพลตฟอร์มบริหารจัดการงาน IT Operations สำหรับองค์กร
ครอบคลุม
* IT Asset Management
* Software Asset Management
* Lifecycle Management
* Inventory Management
* IT Service Management
* IT Governance
---
# Architecture Principles
อ้างอิงจาก AGENTS.md และ Dashboard Skill
## Technical Direction
* Next.js App Router
* Route Handlers
* PostgreSQL
* Drizzle ORM
* TanStack Query
* TanStack Form
* Feature Based Architecture
* Organization Aware RBAC
---
# Phase 0 - Foundation
Goal
สร้างข้อมูลกลางขององค์กร
## Modules
### Company Management
```text
src/features/company
```
### Site Management
```text
src/features/site
```
### Department Management
```text
src/features/department
```
### Location Management
```text
src/features/location
```
### Employee Management
```text
src/features/employee
```
### Vendor Management
```text
src/features/vendor
```
### Attachment Management
```text
src/features/attachment
```
---
Deliverables
* Company
* Site
* Building
* Floor
* Area
* Department
* Employee
* Vendor
---
# Phase 1 - Asset Core (MVP)
Goal
สร้าง Single Source of Truth ของ Asset
แก้ปัญหา
* ไม่รู้ใครถือครอง
* หาของไม่เจอ
* ตรวจนับไม่ตรง
* Asset History สูญหาย
## Modules
### Asset Management
```text
src/features/assets
```
### Asset Assignment
```text
src/features/asset-assignments
```
### Asset Transfer
```text
src/features/asset-transfers
```
### Asset Movement
```text
src/features/asset-movements
```
### Asset Dashboard
```text
src/features/asset-dashboard
```
---
Deliverables
* Asset Master
* Assignment
* Transfer
* Return
* Movement Timeline
* Dashboard
---
# Phase 2 - Governance & Lifecycle
Goal
บริหารความเสี่ยงของ Asset
## Modules
### Warranty
```text
src/features/warranties
```
### Contracts
```text
src/features/contracts
```
### Lifecycle
```text
src/features/lifecycle
```
### Asset Audit
```text
src/features/asset-audits
```
### Risk Dashboard
```text
src/features/risk-dashboard
```
---
Deliverables
* Warranty Tracking
* EOS
* EOL
* EOP
* Audit
* Risk Dashboard
---
# Phase 3 - Inventory & Stock
Goal
ควบคุมอุปกรณ์คงคลัง
## Modules
### Stockroom
### Stock Transactions
### Consumables
### Spare Parts
---
Deliverables
* Stock Control
* Spare Part Tracking
* Consumable Tracking
---
# Phase 4 - Software Asset Management
Goal
ควบคุม Software และ License
## Modules
### Software Catalog
### License Management
### Subscription Management
### SaaS Management
### License Assignment
---
Deliverables
* License Tracking
* SaaS Tracking
* License Utilization
---
# Phase 5 - IT Service Management
Goal
จัดการงานบริการ IT
## Modules
### Incident
### Service Request
### Problem
### Change
### Knowledge Base
---
Deliverables
* Helpdesk
* Service Portal
* Change Management
---
# Phase 6 - IT Governance Platform
Goal
ยกระดับเป็น IT Operations Platform
## Modules
### Procurement Tracking
### Budget Planning
### Refresh Planning
### Vendor Evaluation
### Capacity Planning
### Executive Dashboard
---
Deliverables
* Asset Forecast
* Budget Forecast
* Lifecycle Forecast
* Governance Dashboard
---
# Success Criteria
Platform สามารถตอบได้ว่า
* ใครถือครอง Asset
* Asset อยู่ที่ไหน
* Asset มีความเสี่ยงหรือไม่
* Asset จะหมดอายุเมื่อใด
* Asset เคยผ่านเหตุการณ์ใดบ้าง
* IT ใช้งบประมาณเท่าไรในอนาคต

View File

@@ -1,200 +0,0 @@
# IT Asset Governance MVP Plan For This Dashboard Repo
## Summary
Implement the IT Center MVP as a new app-owned feature set inside the existing Next.js dashboard, using the current architecture:
- `organizations` stays the tenant boundary and maps to `Company`
- business roles (`IT Admin`, `Helpdesk`, `Infrastructure`, `Application`, `Auditor`, `Viewer`) are modeled as workspace-scoped permission bundles, not as replacements for `systemRole`
- first release is a `Core Registry` MVP:
- master data
- asset register
- asset lifecycle transactions: assign, transfer, return, repair
- movement history
- basic dashboard summaries and risk widgets
This should be built as a new feature family, following the migrated `products` and `users` patterns:
`feature types -> service -> queries/mutations -> route handlers -> Drizzle`.
## Key Changes
### 1. Domain model and tenancy alignment
- Treat `organization` as `Company`; keep the existing table and session/org-switcher model.
- Add company-scoped master data tables under the selected organization:
- `sites`
- `departments`
- `locations`
- `employees`
- Add asset domain tables:
- `assets`
- `asset_movements`
- `asset_repairs`
- Keep `Asset UID` immutable and system-generated.
- Keep `Asset Code` mutable and history-tracked through movement events.
- Store current state on `assets`; store every lifecycle change as an append-only movement row.
### 2. RBAC model for IT Center
- Keep current auth layers:
- `users.systemRole` for global authority
- workspace membership and active workspace session context for tenant authority
- Add a workspace-scoped business-role profile to membership-derived access:
- either a new membership field like `businessRole`
- or a dedicated per-organization user-access table linked to `user + organization`
- Recommended canonical permissions:
- `master_data:manage`
- `asset:read`
- `asset:create`
- `asset:update`
- `asset:assign`
- `asset:transfer`
- `asset:return`
- `asset:repair`
- `asset:audit`
- `report:read`
- Map business roles to permission bundles:
- `IT Admin`: full company-scoped access
- `Helpdesk`: assign/transfer/return/read
- `Infrastructure`: hardware, warranty, EOS/EOL/EOP, repair
- `Application`: software/license/subscription assets
- `Auditor`: read, audit, export/report read
- `Viewer`: read-only
- Keep server-side enforcement in route handlers and page guards; nav remains UX-only.
### 3. Feature/module structure in this repo
Create a new feature family under `src/features/assets/` and sibling dashboard routes under `src/app/dashboard/`:
- `src/features/assets/`
- `api/types.ts`
- `api/service.ts`
- `api/queries.ts`
- `api/mutations.ts`
- `components/`
- `schemas/`
- Add dashboard routes:
- `/dashboard/assets`
- `/dashboard/assets/[assetId]`
- `/dashboard/assets/assign`
- `/dashboard/assets/transfer`
- `/dashboard/assets/return`
- `/dashboard/assets/repair`
- `/dashboard/assets/history`
- Add master-data routes/pages, either grouped under `/dashboard/master-data/*` or `/dashboard/assets/settings/*`
- `sites`
- `departments`
- `locations`
- `employees`
- Reuse existing patterns:
- `PageContainer`
- TanStack Table
- TanStack Form + Zod
- React Query prefetch + `HydrationBoundary`
- route handlers in `src/app/api/**`
### 4. Backend and workflow design
Implement route handlers for:
- master data CRUD
- asset CRUD
- lifecycle actions as explicit endpoints, not generic updates:
- `POST /api/assets/[id]/assign`
- `POST /api/assets/[id]/transfer`
- `POST /api/assets/[id]/return`
- `POST /api/assets/[id]/repair`
- movement history listing:
- `GET /api/assets/[id]/movements`
- dashboard summary endpoints:
- totals by status
- warranty risk counts
- lifecycle risk counts
- data-quality counts
Behavior rules to lock in:
- `assign` updates current owner/location fields and inserts `ASSIGN` movement
- `transfer` updates user/department/location/code as needed and inserts one `TRANSFER` movement with old/new values
- `return` updates status to one of `IN_STOCK | REPAIR | RETIRED` and inserts `RETURN`
- `repair` inserts repair history and optionally sets status `REPAIR`
- every lifecycle mutation must write a movement record in the same server transaction
### 5. UI/navigation and documentation alignment
- Add new sidebar section for `Assets`, with visibility based on asset permissions.
- Keep `Workspaces` semantics unchanged; they still represent company switching.
- Add pages in this order for the MVP:
1. asset register/list
2. asset create/edit
3. assign
4. transfer
5. return
6. repair
7. movement history
8. dashboard summary widgets
9. master data CRUD pages
- Update stale docs while implementing:
- `docs/nav-rbac.md` is legacy Clerk-oriented and should be rewritten to match Auth.js + current session fields
- add a new domain glossary doc, preferably `CONTEXT.md`, to define:
- Organization = Company
- Site
- Department
- Location
- Employee
- Asset UID
- Asset Code
- Movement
- Business Role
## Public APIs / Types
- Add new feature contracts in `src/features/assets/api/types.ts`:
- `Asset`
- `AssetFilters`
- `AssetsResponse`
- `AssetMutationPayload`
- `AssetAssignmentPayload`
- `AssetTransferPayload`
- `AssetReturnPayload`
- `AssetRepairPayload`
- `AssetMovement`
- `AssetDashboardSummary`
- Add master data contracts:
- `Site`, `Department`, `Location`, `Employee`
- Extend workspace-scoped access modeling so session-backed access checks can resolve:
- current workspace business role
- current workspace asset permissions
- Do not overload `systemRole` with business roles.
## Test Plan
- Auth and tenancy:
- active organization scopes all master data and assets to one company
- switching organization changes visible assets and master data
- RBAC:
- `IT Admin` can do all asset operations in the active workspace
- `Helpdesk` can assign/transfer/return but cannot manage system settings
- `Infrastructure` can repair and manage lifecycle data
- `Auditor` can read and audit but cannot mutate assets
- `Viewer` cannot access transactional pages
- Asset lifecycle:
- create asset generates immutable `Asset UID`
- transfer can change owner, department, location, and asset code
- return updates status and writes movement
- repair writes repair history and status changes correctly
- History and risk:
- every lifecycle action creates a movement row
- warranty/EOS/EOL/EOP widgets count the right assets
- data-quality widgets flag missing user/location/serial/warranty
- UI:
- new nav items appear only for permitted roles
- asset list, detail, and transaction flows follow the repos React Query hydration pattern
## Assumptions and Defaults
- `organizations` remains the canonical tenant table and means `Company`.
- MVP phase 1 is the `Core Registry` only; `Audit`, advanced `Reports`, mobile scanning, approvals, and external integrations stay phase 2+.
- Business roles are workspace-scoped permission bundles layered on top of the current Auth.js + membership model.
- Hardware and software assets can share one `assets` table in MVP, using `assetType` and `assetCategory` fields, unless later scale forces separation.
- Movement history is the audit trail of lifecycle changes; it is not full event sourcing.

View File

@@ -1,429 +0,0 @@
# ALLA-ITCENTER
## Phase 1 - Asset Core MVP
Version: 1.0
Status: Approved Scope
---
# Objective
สร้าง Asset Single Source of Truth
ระบบต้องสามารถตอบได้ทันที
* Asset คืออะไร
* Asset อยู่ที่ไหน
* ใครถือครอง
* Asset เคยเปลี่ยน Code หรือไม่
* Asset เคยย้ายฝ่ายหรือไม่
---
# Scope
## Included
### Asset Master
### Assignment
### Transfer
### Return
### Movement History
### Dashboard
---
## Excluded
### Warranty
### EOS
### EOL
### EOP
### Audit
### Stock
### License
### Helpdesk
---
# Feature Structure
```text
src/features/
assets/
asset-assignments/
asset-transfers/
asset-movements/
asset-dashboard/
```
---
# Menu Structure
```text
Dashboard
Assets
├─ Asset List
├─ Asset Assignment
├─ Asset Transfer
└─ Asset Movements
```
---
# Asset Module
## Asset List
Route
```text
/dashboard/assets
```
Primary View
* Server-driven data table
* This is the canonical list UI for Asset records in Phase 1
* Must support desktop table layout and mobile-friendly responsive behavior
Functions
* Search
* Filter
* Sort
* Pagination
* Export
Columns
* Asset UID
* Asset Code
* Asset Name
* Category
* Company
* Department
* User
* Asset Status
* Asset Condition
* Disposition / Process Status
---
## Asset Detail
Route
```text
/dashboard/assets/[assetId]
```
Tabs
### Overview
### Assignment
### Movement Timeline
### Attachments
---
# Asset Assignment
Route
```text
/dashboard/assets/[assetId]/assign
```
Required Fields
* Asset
* Employee
* Department
* Location
* Assign Date
* Document Attachment
System Action
* Update Current Assignment
* Create Movement
---
# Asset Transfer
Route
```text
/dashboard/assets/[assetId]/transfer
```
Required Fields
* Old User
* New User
* Old Department
* New Department
* Old Location
* New Location
* Old Asset Code
* New Asset Code
* Transfer Date
* Reference Document
System Action
* Update Asset
* Create Movement
---
# Asset Return
Route
```text
/dashboard/assets/[assetId]/return
```
Required Fields
* Return Date
* Asset Condition
* Remark
System Action
* Asset Status = AVAILABLE
* Create Movement
---
# Status Model
Use these terms explicitly in UI, API contracts, database fields, filters, and dashboard labels.
## Asset Status
Current primary state of the asset.
```text
AVAILABLE
ASSIGNED
IN_REPAIR
LOST
RETIRED
```
## Asset Condition
Current physical condition of the asset.
```text
NORMAL
DAMAGED
```
## Disposition / Process Status
Current repair or write-off process state.
```text
NONE
WAITING_REPAIR
WAITING_WRITE_OFF
WRITE_OFF_COMPLETED
```
Status naming rules
* Do not use the word `Status` alone in requirements or UI labels
* `Asset Status` is the main status shown in the Asset Register Table
* `Asset Condition` is a separate field and filter
* `Disposition / Process Status` is a separate field and filter
* `Movement Type` belongs to timeline/history, not to the current-state status model
---
# Asset Movement
ทุก Event ต้องถูกบันทึก
Event Types
```text
CREATE
ASSIGN
TRANSFER
CHANGE_USER
CHANGE_LOCATION
CHANGE_DEPARTMENT
CHANGE_CODE
RETURN
```
---
# Dashboard
Route
```text
/dashboard
```
Cards
* Total Assets
* Assigned Assets
* In Stock Assets
* Transfer This Month
* Assets Without User
* Assets Without Location
---
# RBAC
## IT Admin
Full Access
---
## Helpdesk
* Read Asset
* Assign
* Transfer
* Return
---
## Infrastructure
* Read Asset
* Update Infrastructure Asset
---
## Viewer
Read Only
---
# API Scope
Assets
```text
GET /api/assets
GET /api/assets/:id
POST /api/assets
PUT /api/assets/:id
```
Assignments
```text
POST /api/assets/:id/assign
```
Transfers
```text
POST /api/assets/:id/transfer
```
Returns
```text
POST /api/assets/:id/return
```
Movements
```text
GET /api/assets/:id/movements
```
---
# UX Data Freshness Requirement
ทุก operation ที่เป็น CRUD ต้องทำให้ข้อมูลบนหน้าจอ "สด" หลัง action สำเร็จทันที
Rules
* Create: ต้อง refresh หรือ invalidate รายการที่เกี่ยวข้อง และอัปเดต detail view ถ้ามีการ redirect ไปหน้ารายละเอียด
* Update: ต้องอัปเดต detail cache และ refresh list cache ที่อาจได้รับผลกระทบจาก field ที่เปลี่ยน
* Delete: ต้องลบ item ออกจาก cache หรือ refetch list เพื่อไม่ให้ผู้ใช้เห็นข้อมูลค้าง
* Assignment, Transfer, Return: ต้อง refresh อย่างน้อย Asset Detail, Asset List, และ Movement Timeline
* Dashboard metrics/cards ที่ได้รับผลกระทบจาก mutation ต้องถูก refresh หลัง operation สำเร็จ
* ห้ามพึ่ง manual browser refresh เป็น flow ปกติของผู้ใช้
Implementation intent
* ใช้ TanStack Query `mutationOptions`
* กำหนด cache invalidation ใน `src/features/<name>/api/mutations.ts`
* ใช้ `invalidateQueries`, `setQueryData`, หรือ optimistic update ตามความเหมาะสม
* ทุก mutation ต้องมีแผน post-success cache synchronization ที่ชัดเจนเพื่อ UX ที่ดี
---
# Database Scope
Core Tables
```text
assets
asset_assignments
asset_movements
attachments
```
Reference Tables
```text
companies
sites
departments
locations
employees
```
---
# Success Criteria
ผู้ใช้งานสามารถค้นหา Asset และทราบได้ภายใน 30 วินาทีว่า
* Asset อยู่ที่ไหน
* ใครถือครอง
* เคยเปลี่ยนรหัสอะไร
* เคยย้ายฝ่ายหรือไม่
* เคยย้ายผู้ใช้หรือไม่
โดยไม่ต้องเปิด Excel หรือเอกสารกระดาษเพิ่มเติม

145
plans/task-a.md Normal file
View File

@@ -0,0 +1,145 @@
# Task A: Template Audit + Convention Freeze
## ALLA OS CRM vNext / Organization-based
คุณคือ Senior Full-stack Engineer + System Architect
ให้สำรวจ template project ปัจจุบันก่อนเริ่ม production code สำหรับ ALLA OS CRM vNext
## Context สำคัญ
ระบบนี้ต้องยึด **organization-based / workspace-based** เป็นแกนหลัก
```txt
organizationId = tenant / workspace / company group scope
membership = user role + permissions ภายใน organization
branchId = business branch / document scope ภายใน organization
```
ห้ามตีความ `branchId` เป็น tenant หลัก
ห้ามออกแบบแบบ branch-first
## ไฟล์/Skill ที่ต้องอ่านก่อน
ก่อนสรุป convention ต้องอ่าน:
```txt
.agents/skills/kiranism-shadcn-dashboard
Layout.md
AGENTS.md
README.md
package.json
src/db/schema.ts
src/lib/auth
src/app/dashboard
src/features
src/components
```
ถ้า path บางรายการไม่มี ให้ระบุใน audit ว่า “not found” ห้ามเดา
## เป้าหมาย
สร้างเอกสาร audit เพื่อ freeze convention ก่อนทำ Task B
## สิ่งที่ต้องตรวจ
1. Project structure
2. App Router structure
3. Dashboard layout
4. Layout rule จาก `Layout.md`
5. Skill rule จาก `.agents/skills/kiranism-shadcn-dashboard`
6. Sidebar / navigation pattern
7. PageContainer pattern
8. Server component / client component pattern
9. Data fetching pattern
10. TanStack Query / Hydration pattern
11. Form pattern
12. DataTable pattern
13. Sheet / Dialog / Drawer pattern
14. Drizzle schema convention
15. API route / server action convention
16. Feature folder convention
17. Auth convention
18. Organization context
19. Membership / permission / RBAC pattern
20. Active organization handling
21. Existing dashboard visual tone
22. Design token / theme usage
23. Files that should not be touched
24. Risk before Task B
## ข้อกำหนดสำคัญ
* ห้าม implement feature
* ห้าม refactor
* ห้ามเปลี่ยน config
* ห้ามเพิ่ม dependency
* ห้าม run build/test/lint จนกว่าจะได้รับคำสั่ง
* ห้ามสร้าง design system ใหม่
* ห้ามสร้าง layout ใหม่ถ้า template มีอยู่แล้ว
* ต้องยึด `Layout.md` และ dashboard skill เป็น source of truth ด้าน UI
## Output
สร้างไฟล์:
```txt
docs/implementation/task-a-template-audit.md
```
โครงสร้างเอกสาร:
```md
# Task A Template Audit
## 1. Executive Summary
## 2. Project Structure
## 3. Layout.md Findings
## 4. kiranism-shadcn-dashboard Skill Findings
## 5. Routing Convention
## 6. Dashboard Layout Convention
## 7. Feature Folder Convention
## 8. Component Convention
## 9. Form Convention
## 10. Table Convention
## 11. API / Server Action Convention
## 12. Drizzle / Database Convention
## 13. Auth Convention
## 14. Organization / Membership / Permission Convention
## 15. Branch Scope Convention
## 16. UI / Design Token Convention
## 17. CRM vNext Recommended Convention
## 18. Files That Should Not Be Touched
## 19. Risks / Concerns
## 20. Task B Implementation Plan
```
## Definition of Done
Task A ถือว่าเสร็จเมื่อ:
* มี audit document ครบ
* ยืนยันว่า organization-based เป็นแกนหลัก
* ยืนยันว่า branch เป็น sub-scope ภายใน organization
* ระบุ path สำหรับวาง Task B ได้ชัดเจน
* ระบุ convention UI จาก Layout.md และ dashboard skill แล้ว
* ยังไม่มี production feature ถูก implement

362
plans/task-b.md Normal file
View File

@@ -0,0 +1,362 @@
# Task B: Foundation Layer Implementation
## ALLA OS CRM vNext (Organization-Based)
คุณคือ Senior Full-stack Engineer
โปรด implement Foundation Layer สำหรับ ALLA OS CRM vNext โดยอ้างอิงผลจาก:
* docs/implementation/task-a-template-audit.md
* Layout.md
* .agents/skills/kiranism-shadcn-dashboard
## เป้าหมาย
สร้าง Foundation Layer ที่ทุก module ในอนาคตสามารถใช้ร่วมกันได้
Task B นี้ยังไม่ใช่ CRM Module
ยังไม่ต้องสร้าง:
* Customer
* Contact
* Enquiry
* Quotation
* Approval
* Dashboard KPI
* Report
* PDF Generator
---
# สิ่งที่ต้องอ่านก่อน
อ่านและยึดตาม:
```txt
docs/implementation/task-a-template-audit.md
Layout.md
.agents/skills/kiranism-shadcn-dashboard
src/auth.ts
src/lib/auth/**
src/db/schema.ts
src/features/products/**
src/features/users/**
```
ใช้ `products` และ `users` เป็น production reference
ห้ามใช้:
```txt
src/features/crm/**
src/constants/mock-api*
```
เป็น reference หลัก
---
# Scope 1: Current User Context
สร้าง shared helper
```ts
getCurrentUser()
requireCurrentUser()
getCurrentUserContext()
```
Expected output:
```ts
{
user,
activeOrganization,
membership,
permissions,
businessRole
}
```
Requirements:
* ใช้ Auth.js ของระบบ
* ใช้ session helper ที่มีอยู่แล้ว
* ห้าม duplicate auth logic
* ใช้ shared auth layer ของโปรเจ็ค
---
# Scope 2: Organization Context
สร้าง shared helper
```ts
getCurrentOrganization()
getActiveOrganizationId()
requireOrganizationAccess()
```
Requirements:
* ใช้ activeOrganizationId จากระบบเดิม
* ใช้ membership เดิม
* ใช้ organization เป็น tenant boundary
Business Rule:
```txt
ทุก CRM entity ในอนาคต
ต้องอยู่ภายใต้ organizationId
```
---
# Scope 3: Permission Layer
สร้าง helper
```ts
hasPermission(permission)
requirePermission(permission)
hasBusinessRole(role)
```
ตัวอย่าง:
```ts
hasPermission("crm.customer.create")
hasPermission("crm.quotation.approve")
```
Requirements:
* ใช้ memberships.permissions
* ใช้ businessRole
* ไม่สร้าง RBAC ใหม่
---
# Scope 4: Branch Scope
สร้าง helper
```ts
getUserBranches()
getActiveBranch()
validateBranchAccess()
```
Business Rule:
```txt
organization
├─ branch A
├─ branch B
└─ branch C
```
branch เป็น business scope
ไม่ใช่ tenant
Requirements:
* ยังไม่ต้องสร้าง CRM branch UI
* ยังไม่ต้อง migrate schema
* สร้างเฉพาะ abstraction layer
---
# Scope 5: Master Options Service
สร้าง service
```ts
getOptionsByCategory()
getActiveOptionsByCategory()
```
รองรับ category เช่น:
```txt
crm_customer_status
crm_customer_type
crm_enquiry_status
crm_quotation_status
crm_product_type
crm_currency
crm_payment_term
crm_priority
crm_lead_channel
```
Requirements:
* ใช้ ms_options schema
* รองรับ parent/child option
* รองรับ soft delete
* CRM module ในอนาคตห้าม hardcode status
---
# Scope 6: Document Sequence Service
สร้าง service
```ts
previewNextDocumentCode()
generateNextDocumentCode()
```
ตัวอย่าง:
```txt
CUS2606-001
ENQ2606-001
QT2606-001
```
Requirements:
* ใช้ document_sequences
* preview ห้าม increment
* generate ต้อง increment
* transaction-safe
* ห้าม generate code ฝั่ง client
---
# Scope 7: Audit Log Helper
สร้าง helper
```ts
auditCreate()
auditUpdate()
auditDelete()
auditAction()
```
Requirements:
* ใช้ tr_audit_logs
* รองรับ beforeData
* รองรับ afterData
* รองรับ requestId
* ทุก future mutation ต้องเรียกผ่าน helper นี้
---
# Folder Convention
ยึดตาม Task A
ถ้าไม่มี convention ที่ชัดเจน:
```txt
src/features/foundation/
├── auth-context/
├── organization-context/
├── permission/
├── branch-scope/
├── master-options/
├── document-sequence/
└── audit-log/
```
และทุก feature ใช้รูปแบบ:
```txt
api/
├── types.ts
├── service.ts
├── queries.ts
└── mutations.ts
```
ตาม kiranism-shadcn-dashboard
---
# UI Scope
ถ้าจำเป็นเท่านั้น
สร้างได้แค่:
```txt
/ dashboard/settings/master-options
```
สำหรับทดสอบ foundation
และต้อง:
* ใช้ PageContainer
* ใช้ Layout.md
* ใช้ theme token เดิม
* ใช้ DataTable เดิม
* ใช้ Sheet/Dialog เดิม
ห้ามสร้าง dashboard ใหม่
---
# ห้ามทำ
```txt
Customer CRUD
Contact CRUD
Enquiry CRUD
Quotation CRUD
Approval Workflow
Dashboard KPI
PDF Generator
Reporting
Notification
```
---
# Output
หลังเสร็จให้สรุป:
1. ไฟล์ที่เพิ่ม
2. ไฟล์ที่แก้ไข
3. Helper ที่สร้าง
4. Service ที่สร้าง
5. จุดที่ reuse จาก template
6. จุดที่ต้อง migration ในอนาคต
7. Risk
8. Task C readiness
---
# Definition of Done
Task B ถือว่าเสร็จเมื่อ:
✔ Current User Context ใช้งานได้
✔ Organization Context ใช้งานได้
✔ Permission Layer ใช้งานได้
✔ Branch Scope abstraction พร้อม
✔ Master Options Service พร้อม
✔ Document Sequence Service พร้อม
✔ Audit Log Helper พร้อม
✔ ยังไม่มี CRM business module ถูกสร้าง
✔ ไม่แก้ auth architecture เดิม
✔ ไม่สร้าง design system ใหม่
✔ พร้อมเริ่ม Task C: Customer + Contact

View File

@@ -1,31 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError } from '@/lib/auth/session';
import { assignAsset, parseAssignmentPayload, requireAssetAccess } from '../../_lib';
type Params = { params: Promise<{ id: string }> };
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireAssetAccess(PERMISSIONS.assetAssign);
const payload = parseAssignmentPayload(await request.json());
await assignAsset(organization.id, id, session.user.id, payload);
return NextResponse.json({
success: true,
message: 'Asset assigned successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to assign asset' }, { status: 500 });
}
}

View File

@@ -1,27 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError } from '@/lib/auth/session';
import { getAssetMovements, requireAssetAccess } from '../../_lib';
type Params = { params: Promise<{ id: string }> };
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireAssetAccess(PERMISSIONS.assetRead);
const movements = await getAssetMovements(organization.id, id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Asset movements loaded successfully',
movements
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load asset movements' }, { status: 500 });
}
}

View File

@@ -1,31 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError } from '@/lib/auth/session';
import { parseRepairPayload, repairAsset, requireAssetAccess } from '../../_lib';
type Params = { params: Promise<{ id: string }> };
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireAssetAccess(PERMISSIONS.assetRepair);
const payload = parseRepairPayload(await request.json());
await repairAsset(organization.id, id, session.user.id, payload);
return NextResponse.json({
success: true,
message: 'Asset repair recorded successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to record repair' }, { status: 500 });
}
}

View File

@@ -1,31 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError } from '@/lib/auth/session';
import { parseReturnPayload, requireAssetAccess, returnAsset } from '../../_lib';
type Params = { params: Promise<{ id: string }> };
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireAssetAccess(PERMISSIONS.assetReturn);
const payload = parseReturnPayload(await request.json());
await returnAsset(organization.id, id, session.user.id, payload);
return NextResponse.json({
success: true,
message: 'Asset returned successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to return asset' }, { status: 500 });
}
}

View File

@@ -1,88 +0,0 @@
import { and, eq } from 'drizzle-orm';
import { NextRequest, NextResponse } from 'next/server';
import { assets } from '@/db/schema';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError } from '@/lib/auth/session';
import { db } from '@/lib/db';
import {
getAssetById,
getAssetMovements,
getAssetRepairs,
parseAssetPayload,
requireAssetAccess,
updateAsset
} from '../_lib';
type Params = { params: Promise<{ id: string }> };
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireAssetAccess(PERMISSIONS.assetRead);
const [asset, movements, repairs] = await Promise.all([
getAssetById(organization.id, id),
getAssetMovements(organization.id, id),
getAssetRepairs(organization.id, id)
]);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Asset loaded successfully',
asset,
movements,
repairs
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load asset' }, { status: 500 });
}
}
export async function PUT(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireAssetAccess(PERMISSIONS.assetUpdate);
const payload = parseAssetPayload(await request.json());
await updateAsset(organization.id, id, session.user.id, payload);
return NextResponse.json({
success: true,
message: 'Asset updated successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to update asset' }, { status: 500 });
}
}
export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization } = await requireAssetAccess(PERMISSIONS.assetUpdate);
await db.delete(assets).where(and(eq(assets.id, id), eq(assets.organizationId, organization.id)));
return NextResponse.json({
success: true,
message: 'Asset deleted successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to delete asset' }, { status: 500 });
}
}

View File

@@ -1,31 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError } from '@/lib/auth/session';
import { parseTransferPayload, requireAssetAccess, transferAsset } from '../../_lib';
type Params = { params: Promise<{ id: string }> };
export async function POST(request: NextRequest, { params }: Params) {
try {
const { id } = await params;
const { organization, session } = await requireAssetAccess(PERMISSIONS.assetTransfer);
const payload = parseTransferPayload(await request.json());
await transferAsset(organization.id, id, session.user.id, payload);
return NextResponse.json({
success: true,
message: 'Asset transferred successfully'
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to transfer asset' }, { status: 500 });
}
}

View File

@@ -1,926 +0,0 @@
import { and, asc, desc, eq, isNull, sql } from 'drizzle-orm';
import { z } from 'zod';
import {
assetAssignments,
assetMovements,
assetRepairs,
assets,
departments,
employees,
locations,
organizations,
sites,
users
} from '@/db/schema';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
import { db } from '@/lib/db';
import type {
Asset,
AssetAssignmentPayload,
AssetFilters,
AssetMutationPayload,
AssetOptionsResponse,
AssetRepair,
AssetRepairPayload,
AssetReturnPayload,
AssetTransferPayload,
AssetMovement
} from '@/features/assets/api/types';
const assetSchema = z.object({
assetCode: z.string().min(1),
assetName: z.string().min(1),
assetType: z.enum(['hardware', 'software']),
assetCategory: z.string().min(1),
brand: z.string().optional().or(z.literal('')),
model: z.string().optional().or(z.literal('')),
specification: z.string().optional().or(z.literal('')),
serialNumber: z.string().optional().or(z.literal('')),
siteId: z.string().optional().nullable(),
departmentId: z.string().optional().nullable(),
locationId: z.string().optional().nullable(),
currentEmployeeId: z.string().optional().nullable(),
custodianTeam: z.string().optional().or(z.literal('')),
purchaseDate: z.string().optional().nullable(),
warrantyExpiryDate: z.string().optional().nullable(),
eosDate: z.string().optional().nullable(),
eolDate: z.string().optional().nullable(),
eopDate: z.string().optional().nullable(),
status: z.enum(['AVAILABLE', 'ASSIGNED', 'IN_REPAIR', 'LOST', 'RETIRED']),
assetCondition: z.enum(['NORMAL', 'DAMAGED']),
dispositionStatus: z.enum([
'NONE',
'WAITING_REPAIR',
'WAITING_WRITE_OFF',
'WRITE_OFF_COMPLETED'
]),
notes: z.string().optional().or(z.literal(''))
});
const assignmentSchema = z.object({
employeeId: z.string().min(1),
departmentId: z.string().optional().nullable(),
locationId: z.string().optional().nullable(),
siteId: z.string().optional().nullable(),
assignDate: z.string().min(1),
documentAttachment: z.string().min(1),
reason: z.string().optional().or(z.literal(''))
});
const transferSchema = z.object({
newEmployeeId: z.string().optional().nullable(),
newDepartmentId: z.string().optional().nullable(),
newLocationId: z.string().optional().nullable(),
newSiteId: z.string().optional().nullable(),
newAssetCode: z.string().optional().or(z.literal('')),
transferDate: z.string().min(1),
referenceDocument: z.string().min(1),
reason: z.string().optional().or(z.literal(''))
});
const returnSchema = z.object({
returnDate: z.string().min(1),
assetCondition: z.enum(['NORMAL', 'DAMAGED']),
remark: z.string().min(1)
});
const repairSchema = z.object({
repairDate: z.string().optional().nullable(),
vendor: z.string().optional().or(z.literal('')),
problem: z.string().min(1),
resolution: z.string().optional().or(z.literal('')),
cost: z.number().nullable().optional(),
markAsRepair: z.boolean().optional().default(true)
});
function asDate(value?: string | null) {
if (!value) {
return null;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function iso(value: Date | string | null) {
if (!value) {
return null;
}
return value instanceof Date ? value.toISOString() : new Date(value).toISOString();
}
function sanitizeText(value?: string | null) {
return value && value.trim().length > 0 ? value.trim() : null;
}
type AssetRow = {
id: string;
organizationId: string;
assetUid: string;
assetCode: string;
assetName: string;
companyName: string | null;
assetType: string;
assetCategory: string;
brand: string | null;
model: string | null;
specification: string | null;
serialNumber: string | null;
siteId: string | null;
siteName: string | null;
departmentId: string | null;
departmentName: string | null;
locationId: string | null;
locationName: string | null;
currentEmployeeId: string | null;
currentEmployeeName: string | null;
custodianTeam: string | null;
purchaseDate: Date | null;
warrantyExpiryDate: Date | null;
eosDate: Date | null;
eolDate: Date | null;
eopDate: Date | null;
status: string;
assetCondition: string;
dispositionStatus: string;
notes: string | null;
createdAt: Date;
updatedAt: Date;
};
function formatAsset(row: AssetRow): Asset {
return {
id: row.id,
organizationId: row.organizationId,
assetUid: row.assetUid,
assetCode: row.assetCode,
assetName: row.assetName,
companyName: row.companyName,
assetType: row.assetType === 'software' ? 'software' : 'hardware',
assetCategory: row.assetCategory,
brand: row.brand,
model: row.model,
specification: row.specification,
serialNumber: row.serialNumber,
siteId: row.siteId,
siteName: row.siteName,
departmentId: row.departmentId,
departmentName: row.departmentName,
locationId: row.locationId,
locationName: row.locationName,
currentEmployeeId: row.currentEmployeeId,
currentEmployeeName: row.currentEmployeeName,
custodianTeam: row.custodianTeam,
purchaseDate: iso(row.purchaseDate),
warrantyExpiryDate: iso(row.warrantyExpiryDate),
eosDate: iso(row.eosDate),
eolDate: iso(row.eolDate),
eopDate: iso(row.eopDate),
status: (['AVAILABLE', 'ASSIGNED', 'IN_REPAIR', 'LOST', 'RETIRED'] as const).includes(
row.status as Asset['status']
)
? (row.status as Asset['status'])
: 'AVAILABLE',
assetCondition: (['NORMAL', 'DAMAGED'] as const).includes(row.assetCondition as Asset['assetCondition'])
? (row.assetCondition as Asset['assetCondition'])
: 'NORMAL',
dispositionStatus: (
['NONE', 'WAITING_REPAIR', 'WAITING_WRITE_OFF', 'WRITE_OFF_COMPLETED'] as const
).includes(row.dispositionStatus as Asset['dispositionStatus'])
? (row.dispositionStatus as Asset['dispositionStatus'])
: 'NONE',
notes: row.notes,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString()
};
}
export async function requireAssetAccess(permission: string) {
return requireOrganizationAccess({ permission });
}
export function parseAssetPayload(body: unknown): AssetMutationPayload {
const parsed = assetSchema.safeParse(body);
if (!parsed.success) {
throw new Error('Invalid asset payload');
}
return parsed.data;
}
export function parseAssignmentPayload(body: unknown): AssetAssignmentPayload {
const parsed = assignmentSchema.safeParse(body);
if (!parsed.success) {
throw new Error('Invalid assignment payload');
}
return parsed.data;
}
export function parseTransferPayload(body: unknown): AssetTransferPayload {
const parsed = transferSchema.safeParse(body);
if (!parsed.success) {
throw new Error('Invalid transfer payload');
}
return parsed.data;
}
export function parseReturnPayload(body: unknown): AssetReturnPayload {
const parsed = returnSchema.safeParse(body);
if (!parsed.success) {
throw new Error('Invalid return payload');
}
return parsed.data;
}
export function parseRepairPayload(body: unknown): AssetRepairPayload {
const parsed = repairSchema.safeParse(body);
if (!parsed.success) {
throw new Error('Invalid repair payload');
}
return parsed.data;
}
async function getBaseAssetRows(organizationId: string) {
const rows = await db
.select({
id: assets.id,
organizationId: assets.organizationId,
assetUid: assets.assetUid,
assetCode: assets.assetCode,
assetName: assets.assetName,
companyName: organizations.name,
assetType: assets.assetType,
assetCategory: assets.assetCategory,
brand: assets.brand,
model: assets.model,
specification: assets.specification,
serialNumber: assets.serialNumber,
siteId: assets.siteId,
siteName: sites.name,
departmentId: assets.departmentId,
departmentName: departments.name,
locationId: assets.locationId,
locationName: locations.name,
currentEmployeeId: assets.currentEmployeeId,
currentEmployeeName: employees.name,
custodianTeam: assets.custodianTeam,
purchaseDate: assets.purchaseDate,
warrantyExpiryDate: assets.warrantyExpiryDate,
eosDate: assets.eosDate,
eolDate: assets.eolDate,
eopDate: assets.eopDate,
status: assets.status,
assetCondition: assets.assetCondition,
dispositionStatus: assets.dispositionStatus,
notes: assets.notes,
createdAt: assets.createdAt,
updatedAt: assets.updatedAt
})
.from(assets)
.innerJoin(organizations, eq(organizations.id, assets.organizationId))
.leftJoin(sites, eq(sites.id, assets.siteId))
.leftJoin(departments, eq(departments.id, assets.departmentId))
.leftJoin(locations, eq(locations.id, assets.locationId))
.leftJoin(employees, eq(employees.id, assets.currentEmployeeId))
.where(eq(assets.organizationId, organizationId));
return rows as AssetRow[];
}
export async function listAssetsForOrganization(organizationId: string, filters: AssetFilters) {
let rows = await getBaseAssetRows(organizationId);
if (filters.search) {
const search = filters.search.toLowerCase();
rows = rows.filter((row) =>
[
row.assetUid,
row.assetCode,
row.assetName,
row.assetCategory,
row.companyName,
row.brand,
row.model,
row.serialNumber
]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(search))
);
}
if (filters.status) {
const selected = filters.status
.split(/[.,]/)
.map((value) => value.trim())
.filter(Boolean);
rows = rows.filter((row) => selected.includes(row.status));
}
if (filters.assetType) {
const selected = filters.assetType
.split(/[.,]/)
.map((value) => value.trim())
.filter(Boolean);
rows = rows.filter((row) => selected.includes(row.assetType));
}
if (filters.assetCondition) {
const selected = filters.assetCondition
.split(/[.,]/)
.map((value) => value.trim())
.filter(Boolean);
rows = rows.filter((row) => selected.includes(row.assetCondition));
}
if (filters.dispositionStatus) {
const selected = filters.dispositionStatus
.split(/[.,]/)
.map((value) => value.trim())
.filter(Boolean);
rows = rows.filter((row) => selected.includes(row.dispositionStatus));
}
if (filters.sort) {
try {
const [primary] = JSON.parse(filters.sort) as Array<{ id: string; desc: boolean }>;
if (primary) {
rows = rows.toSorted((a, b) => {
const left = String((a as Record<string, unknown>)[primary.id] ?? '');
const right = String((b as Record<string, unknown>)[primary.id] ?? '');
return primary.desc ? right.localeCompare(left) : left.localeCompare(right);
});
}
} catch {
rows = rows.toSorted((a, b) => a.assetCode.localeCompare(b.assetCode));
}
} else {
rows = rows.toSorted((a, b) => a.assetCode.localeCompare(b.assetCode));
}
const page = filters.page ?? 1;
const limit = filters.limit ?? 10;
const offset = (page - 1) * limit;
return {
total: rows.length,
offset,
limit,
assets: rows.slice(offset, offset + limit).map(formatAsset)
};
}
export async function getAssetById(organizationId: string, assetId: string) {
const rows = await getBaseAssetRows(organizationId);
const asset = rows.find((row) => row.id === assetId);
if (!asset) {
throw new AuthError('Asset not found', 404);
}
return formatAsset(asset);
}
export async function getAssetRepairs(organizationId: string, assetId: string) {
const rows = await db.query.assetRepairs.findMany({
where: and(eq(assetRepairs.organizationId, organizationId), eq(assetRepairs.assetId, assetId)),
orderBy: desc(assetRepairs.repairDate)
});
return rows.map<AssetRepair>((row) => ({
id: row.id,
assetId: row.assetId,
repairDate: row.repairDate.toISOString(),
vendor: row.vendor,
problem: row.problem,
resolution: row.resolution,
cost: row.cost ? Number(row.cost) : null
}));
}
export async function getAssetMovements(organizationId: string, assetId?: string) {
const conditions = [eq(assetMovements.organizationId, organizationId)];
if (assetId) {
conditions.push(eq(assetMovements.assetId, assetId));
}
const rows = await db
.select({
id: assetMovements.id,
assetId: assetMovements.assetId,
assetUid: assets.assetUid,
assetCode: assets.assetCode,
assetName: assets.assetName,
eventType: assetMovements.eventType,
eventDate: assetMovements.eventDate,
reason: assetMovements.reason,
referenceDocument: assetMovements.referenceDocument,
performedByName: users.name,
metadata: assetMovements.metadata
})
.from(assetMovements)
.innerJoin(assets, eq(assets.id, assetMovements.assetId))
.leftJoin(users, eq(users.id, assetMovements.performedByUserId))
.where(and(...conditions))
.orderBy(desc(assetMovements.eventDate));
return rows.map<AssetMovement>((row) => ({
id: row.id,
assetId: row.assetId,
assetUid: row.assetUid,
assetCode: row.assetCode,
assetName: row.assetName,
eventType: row.eventType as AssetMovement['eventType'],
eventDate: row.eventDate.toISOString(),
reason: row.reason,
referenceDocument: row.referenceDocument,
performedByName: row.performedByName,
metadata: (row.metadata as Record<string, unknown> | null) ?? null
}));
}
export async function getAssetOptions(organizationId: string): Promise<AssetOptionsResponse> {
const [siteRows, departmentRows, locationRows, employeeRows] = await Promise.all([
db.select().from(sites).where(eq(sites.organizationId, organizationId)).orderBy(asc(sites.name)),
db
.select()
.from(departments)
.where(eq(departments.organizationId, organizationId))
.orderBy(asc(departments.name)),
listLocations(organizationId),
listEmployees(organizationId)
]);
return {
success: true,
time: new Date().toISOString(),
message: 'Asset options loaded successfully',
sites: siteRows.map((row) => ({
id: row.id,
organizationId: row.organizationId,
code: row.code,
name: row.name
})),
departments: departmentRows.map((row) => ({
id: row.id,
organizationId: row.organizationId,
code: row.code,
name: row.name
})),
locations: locationRows,
employees: employeeRows
};
}
async function listLocations(organizationId: string) {
const rows = await db
.select({
id: locations.id,
organizationId: locations.organizationId,
siteId: locations.siteId,
siteName: sites.name,
building: locations.building,
floor: locations.floor,
area: locations.area,
name: locations.name
})
.from(locations)
.leftJoin(sites, eq(sites.id, locations.siteId))
.where(eq(locations.organizationId, organizationId))
.orderBy(asc(locations.name));
return rows;
}
async function listEmployees(organizationId: string) {
const rows = await db
.select({
id: employees.id,
organizationId: employees.organizationId,
employeeNo: employees.employeeNo,
name: employees.name,
departmentId: employees.departmentId,
departmentName: departments.name,
siteId: employees.siteId,
siteName: sites.name,
status: employees.status
})
.from(employees)
.leftJoin(departments, eq(departments.id, employees.departmentId))
.leftJoin(sites, eq(sites.id, employees.siteId))
.where(eq(employees.organizationId, organizationId))
.orderBy(asc(employees.name));
return rows;
}
async function buildAssetUid() {
const result = await db.select({ count: sql<number>`count(*)::int` }).from(assets);
const nextValue = (result[0]?.count ?? 0) + 1;
return `AST-${String(nextValue).padStart(6, '0')}`;
}
function buildSnapshot(asset: Asset) {
return {
assetCode: asset.assetCode,
assetName: asset.assetName,
siteId: asset.siteId,
departmentId: asset.departmentId,
locationId: asset.locationId,
currentEmployeeId: asset.currentEmployeeId,
status: asset.status,
assetCondition: asset.assetCondition,
dispositionStatus: asset.dispositionStatus
};
}
async function insertMovement(tx: Pick<typeof db, 'insert'>, values: {
assetId: string;
organizationId: string;
eventType: string;
performedByUserId: string;
reason?: string | null;
referenceDocument?: string | null;
snapshotBefore?: Record<string, unknown> | null;
snapshotAfter?: Record<string, unknown> | null;
metadata?: Record<string, unknown> | null;
eventDate?: Date;
}) {
await tx.insert(assetMovements).values({
id: crypto.randomUUID(),
assetId: values.assetId,
organizationId: values.organizationId,
eventType: values.eventType,
eventDate: values.eventDate ?? new Date(),
reason: values.reason ?? null,
referenceDocument: values.referenceDocument ?? null,
performedByUserId: values.performedByUserId,
snapshotBefore: values.snapshotBefore ?? null,
snapshotAfter: values.snapshotAfter ?? null,
metadata: values.metadata ?? null
});
}
export async function createAsset(organizationId: string, performedByUserId: string, payload: AssetMutationPayload) {
const assetId = crypto.randomUUID();
const assetUid = await buildAssetUid();
await db.transaction(async (tx) => {
await tx.insert(assets).values({
id: assetId,
organizationId,
assetUid,
assetCode: payload.assetCode,
assetName: payload.assetName,
assetType: payload.assetType,
assetCategory: payload.assetCategory,
brand: sanitizeText(payload.brand),
model: sanitizeText(payload.model),
specification: sanitizeText(payload.specification),
serialNumber: sanitizeText(payload.serialNumber),
siteId: payload.siteId ?? null,
departmentId: payload.departmentId ?? null,
locationId: payload.locationId ?? null,
currentEmployeeId: payload.currentEmployeeId ?? null,
custodianTeam: sanitizeText(payload.custodianTeam),
purchaseDate: asDate(payload.purchaseDate),
warrantyExpiryDate: asDate(payload.warrantyExpiryDate),
eosDate: asDate(payload.eosDate),
eolDate: asDate(payload.eolDate),
eopDate: asDate(payload.eopDate),
status: payload.status,
assetCondition: payload.assetCondition,
dispositionStatus: payload.dispositionStatus,
notes: sanitizeText(payload.notes)
});
await insertMovement(tx, {
assetId,
organizationId,
eventType: 'CREATE',
performedByUserId,
snapshotAfter: {
assetCode: payload.assetCode,
assetName: payload.assetName,
status: payload.status,
assetCondition: payload.assetCondition,
dispositionStatus: payload.dispositionStatus,
assetUid
}
});
});
return assetId;
}
export async function updateAsset(
organizationId: string,
assetId: string,
performedByUserId: string,
payload: AssetMutationPayload
) {
const currentAsset = await getAssetById(organizationId, assetId);
await db.transaction(async (tx) => {
await tx
.update(assets)
.set({
assetCode: payload.assetCode,
assetName: payload.assetName,
assetType: payload.assetType,
assetCategory: payload.assetCategory,
brand: sanitizeText(payload.brand),
model: sanitizeText(payload.model),
specification: sanitizeText(payload.specification),
serialNumber: sanitizeText(payload.serialNumber),
siteId: payload.siteId ?? null,
departmentId: payload.departmentId ?? null,
locationId: payload.locationId ?? null,
currentEmployeeId: payload.currentEmployeeId ?? null,
custodianTeam: sanitizeText(payload.custodianTeam),
purchaseDate: asDate(payload.purchaseDate),
warrantyExpiryDate: asDate(payload.warrantyExpiryDate),
eosDate: asDate(payload.eosDate),
eolDate: asDate(payload.eolDate),
eopDate: asDate(payload.eopDate),
status: payload.status,
assetCondition: payload.assetCondition,
dispositionStatus: payload.dispositionStatus,
notes: sanitizeText(payload.notes),
updatedAt: new Date()
})
.where(and(eq(assets.id, assetId), eq(assets.organizationId, organizationId)));
const updatedAsset = await getAssetById(organizationId, assetId);
await insertMovement(tx, {
assetId,
organizationId,
eventType: 'UPDATE',
performedByUserId,
snapshotBefore: buildSnapshot(currentAsset),
snapshotAfter: buildSnapshot(updatedAsset)
});
});
}
export async function assignAsset(
organizationId: string,
assetId: string,
performedByUserId: string,
payload: AssetAssignmentPayload
) {
const currentAsset = await getAssetById(organizationId, assetId);
await db.transaction(async (tx) => {
await tx.insert(assetAssignments).values({
id: crypto.randomUUID(),
assetId,
organizationId,
employeeId: payload.employeeId,
departmentId: payload.departmentId ?? currentAsset.departmentId,
locationId: payload.locationId ?? currentAsset.locationId,
siteId: payload.siteId ?? currentAsset.siteId,
assignedAt: asDate(payload.assignDate) ?? new Date(),
documentAttachment: sanitizeText(payload.documentAttachment),
assignedByUserId: performedByUserId
});
await tx
.update(assets)
.set({
currentEmployeeId: payload.employeeId,
departmentId: payload.departmentId ?? currentAsset.departmentId,
locationId: payload.locationId ?? currentAsset.locationId,
siteId: payload.siteId ?? currentAsset.siteId,
status: 'ASSIGNED',
dispositionStatus: 'NONE',
updatedAt: new Date()
})
.where(and(eq(assets.id, assetId), eq(assets.organizationId, organizationId)));
const updatedAsset = await getAssetById(organizationId, assetId);
await insertMovement(tx, {
assetId,
organizationId,
eventType: 'ASSIGN',
performedByUserId,
reason: payload.reason ?? null,
referenceDocument: payload.documentAttachment,
eventDate: asDate(payload.assignDate) ?? new Date(),
snapshotBefore: buildSnapshot(currentAsset),
snapshotAfter: buildSnapshot(updatedAsset),
metadata: {
employeeId: payload.employeeId,
departmentId: payload.departmentId ?? null,
locationId: payload.locationId ?? null,
siteId: payload.siteId ?? null,
documentAttachment: payload.documentAttachment
}
});
});
}
export async function transferAsset(
organizationId: string,
assetId: string,
performedByUserId: string,
payload: AssetTransferPayload
) {
const currentAsset = await getAssetById(organizationId, assetId);
const transferEventDate = asDate(payload.transferDate) ?? new Date();
const nextAssetCode = sanitizeText(payload.newAssetCode) ?? currentAsset.assetCode;
const nextEmployeeId = payload.newEmployeeId ?? currentAsset.currentEmployeeId;
const nextDepartmentId = payload.newDepartmentId ?? currentAsset.departmentId;
const nextLocationId = payload.newLocationId ?? currentAsset.locationId;
const nextSiteId = payload.newSiteId ?? currentAsset.siteId;
await db.transaction(async (tx) => {
await tx
.update(assetAssignments)
.set({
returnedAt: transferEventDate,
returnedByUserId: performedByUserId,
updatedAt: new Date()
})
.where(
and(
eq(assetAssignments.assetId, assetId),
eq(assetAssignments.organizationId, organizationId),
isNull(assetAssignments.returnedAt)
)
);
if (nextEmployeeId) {
await tx.insert(assetAssignments).values({
id: crypto.randomUUID(),
assetId,
organizationId,
employeeId: nextEmployeeId,
departmentId: nextDepartmentId,
locationId: nextLocationId,
siteId: nextSiteId,
assignedAt: transferEventDate,
documentAttachment: sanitizeText(payload.referenceDocument),
assignedByUserId: performedByUserId
});
}
await tx
.update(assets)
.set({
currentEmployeeId: nextEmployeeId,
departmentId: nextDepartmentId,
locationId: nextLocationId,
siteId: nextSiteId,
assetCode: nextAssetCode,
status: nextEmployeeId ? 'ASSIGNED' : 'AVAILABLE',
updatedAt: new Date()
})
.where(and(eq(assets.id, assetId), eq(assets.organizationId, organizationId)));
const updatedAsset = await getAssetById(organizationId, assetId);
await insertMovement(tx, {
assetId,
organizationId,
eventType: 'TRANSFER',
performedByUserId,
reason: payload.reason ?? null,
referenceDocument: payload.referenceDocument,
eventDate: transferEventDate,
snapshotBefore: buildSnapshot(currentAsset),
snapshotAfter: buildSnapshot(updatedAsset),
metadata: {
oldEmployeeId: currentAsset.currentEmployeeId,
newEmployeeId: nextEmployeeId,
oldDepartmentId: currentAsset.departmentId,
newDepartmentId: nextDepartmentId,
oldLocationId: currentAsset.locationId,
newLocationId: nextLocationId,
oldSiteId: currentAsset.siteId,
newSiteId: nextSiteId,
oldAssetCode: currentAsset.assetCode,
newAssetCode: nextAssetCode
}
});
});
}
export async function returnAsset(
organizationId: string,
assetId: string,
performedByUserId: string,
payload: AssetReturnPayload
) {
const currentAsset = await getAssetById(organizationId, assetId);
const returnEventDate = asDate(payload.returnDate) ?? new Date();
const nextDispositionStatus = payload.assetCondition === 'DAMAGED' ? 'WAITING_REPAIR' : 'NONE';
await db.transaction(async (tx) => {
await tx
.update(assetAssignments)
.set({
returnedAt: returnEventDate,
returnedByUserId: performedByUserId,
updatedAt: new Date()
})
.where(
and(
eq(assetAssignments.assetId, assetId),
eq(assetAssignments.organizationId, organizationId),
isNull(assetAssignments.returnedAt)
)
);
await tx
.update(assets)
.set({
currentEmployeeId: null,
status: 'AVAILABLE',
assetCondition: payload.assetCondition,
dispositionStatus: nextDispositionStatus,
updatedAt: new Date()
})
.where(and(eq(assets.id, assetId), eq(assets.organizationId, organizationId)));
const updatedAsset = await getAssetById(organizationId, assetId);
await insertMovement(tx, {
assetId,
organizationId,
eventType: 'RETURN',
performedByUserId,
reason: payload.remark,
eventDate: returnEventDate,
snapshotBefore: buildSnapshot(currentAsset),
snapshotAfter: buildSnapshot(updatedAsset),
metadata: {
assetCondition: payload.assetCondition,
dispositionStatus: nextDispositionStatus
}
});
});
}
export async function repairAsset(
organizationId: string,
assetId: string,
performedByUserId: string,
payload: AssetRepairPayload
) {
const currentAsset = await getAssetById(organizationId, assetId);
await db.transaction(async (tx) => {
await tx.insert(assetRepairs).values({
id: crypto.randomUUID(),
assetId,
organizationId,
repairDate: asDate(payload.repairDate) ?? new Date(),
vendor: sanitizeText(payload.vendor),
problem: payload.problem,
resolution: sanitizeText(payload.resolution),
cost: payload.cost != null ? String(payload.cost) : null,
createdByUserId: performedByUserId
});
if (payload.markAsRepair ?? true) {
await tx
.update(assets)
.set({
status: 'IN_REPAIR',
dispositionStatus: 'WAITING_REPAIR',
updatedAt: new Date()
})
.where(and(eq(assets.id, assetId), eq(assets.organizationId, organizationId)));
}
const updatedAsset = await getAssetById(organizationId, assetId);
await insertMovement(tx, {
assetId,
organizationId,
eventType: 'REPAIR',
performedByUserId,
eventDate: asDate(payload.repairDate) ?? new Date(),
snapshotBefore: buildSnapshot(currentAsset),
snapshotAfter: buildSnapshot(updatedAsset),
metadata: {
vendor: payload.vendor ?? null,
problem: payload.problem,
resolution: payload.resolution ?? null,
cost: payload.cost ?? null
}
});
});
}

View File

@@ -1,65 +0,0 @@
import { startOfMonth } from 'date-fns';
import { NextResponse } from 'next/server';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError } from '@/lib/auth/session';
import {
getAssetMovements,
listAssetsForOrganization,
requireAssetAccess
} from '../../_lib';
export async function GET() {
try {
const { organization } = await requireAssetAccess(PERMISSIONS.assetRead);
const [result, movements] = await Promise.all([
listAssetsForOrganization(organization.id, {
page: 1,
limit: 10000
}),
getAssetMovements(organization.id)
]);
const monthStart = startOfMonth(new Date());
const summary = result.assets.reduce(
(acc, asset) => {
acc.cards.totalAssets += 1;
if (asset.status === 'ASSIGNED') acc.cards.assignedAssets += 1;
if (asset.status === 'AVAILABLE') acc.cards.inStockAssets += 1;
if (!asset.currentEmployeeId) acc.cards.withoutUser += 1;
if (!asset.locationId) acc.cards.withoutLocation += 1;
return acc;
},
{
cards: {
totalAssets: 0,
assignedAssets: 0,
inStockAssets: 0,
transferThisMonth: 0,
withoutUser: 0,
withoutLocation: 0
}
}
);
summary.cards.transferThisMonth = movements.filter((movement) => {
return (
movement.eventType === 'TRANSFER' && new Date(movement.eventDate).getTime() >= monthStart.getTime()
);
}).length;
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Asset dashboard summary loaded successfully',
...summary
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load dashboard summary' }, { status: 500 });
}
}

View File

@@ -1,24 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError } from '@/lib/auth/session';
import { getAssetMovements, requireAssetAccess } from '../_lib';
export async function GET(_request: NextRequest) {
try {
const { organization } = await requireAssetAccess(PERMISSIONS.assetRead);
const movements = await getAssetMovements(organization.id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Asset history loaded successfully',
movements
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load asset history' }, { status: 500 });
}
}

View File

@@ -1,19 +0,0 @@
import { NextResponse } from 'next/server';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError } from '@/lib/auth/session';
import { getAssetOptions, requireAssetAccess } from '../_lib';
export async function GET() {
try {
const { organization } = await requireAssetAccess(PERMISSIONS.assetRead);
const response = await getAssetOptions(organization.id);
return NextResponse.json(response);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load asset options' }, { status: 500 });
}
}

View File

@@ -1,70 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import {
createAsset,
listAssetsForOrganization,
parseAssetPayload,
requireAssetAccess
} from './_lib';
import { AuthError } from '@/lib/auth/session';
import { PERMISSIONS } from '@/lib/auth/rbac';
export async function GET(request: NextRequest) {
try {
const { organization } = await requireAssetAccess(PERMISSIONS.assetRead);
const { searchParams } = request.nextUrl;
const result = await listAssetsForOrganization(organization.id, {
page: Number(searchParams.get('page') ?? 1),
limit: Number(searchParams.get('limit') ?? 10),
search: searchParams.get('search') ?? undefined,
status: searchParams.get('status') ?? undefined,
assetCondition: searchParams.get('assetCondition') ?? undefined,
dispositionStatus: searchParams.get('dispositionStatus') ?? undefined,
assetType: searchParams.get('assetType') ?? undefined,
sort: searchParams.get('sort') ?? undefined
});
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Assets loaded successfully',
total_assets: result.total,
offset: result.offset,
limit: result.limit,
assets: result.assets
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load assets' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
try {
const { organization, session } = await requireAssetAccess(PERMISSIONS.assetCreate);
const payload = parseAssetPayload(await request.json());
const id = await createAsset(organization.id, session.user.id, payload);
return NextResponse.json(
{
success: true,
message: 'Asset created successfully',
id
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to create asset' }, { status: 500 });
}
}

View File

@@ -1,79 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { exampleProducts } from '@/features/example-dashboard/data';
function parseCategories(value: string | null) {
if (!value) return [];
return value
.split(/[.,]/)
.map((item) => item.trim())
.filter(Boolean);
}
function sortProducts(
items: typeof exampleProducts,
sort: string | null
) {
const defaultSorted = items.toSorted((a, b) => a.name.localeCompare(b.name));
if (!sort) {
return defaultSorted;
}
try {
const parsed = JSON.parse(sort) as Array<{ id: keyof (typeof exampleProducts)[number]; desc: boolean }>;
const [primarySort] = parsed;
if (!primarySort?.id) {
return defaultSorted;
}
const sorted = items.toSorted((left, right) => {
const a = left[primarySort.id];
const b = right[primarySort.id];
if (typeof a === 'number' && typeof b === 'number') {
return a - b;
}
return String(a).localeCompare(String(b));
});
return primarySort.desc ? sorted.toReversed() : sorted;
} catch {
return defaultSorted;
}
}
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const page = Number(searchParams.get('page') ?? 1);
const limit = Number(searchParams.get('limit') ?? 10);
const categories = parseCategories(searchParams.get('categories'));
const search = searchParams.get('search')?.trim().toLowerCase();
const sort = searchParams.get('sort');
const filtered = exampleProducts.filter((product) => {
const matchesCategory = categories.length ? categories.includes(product.category) : true;
const matchesSearch = search
? [product.name, product.description, product.category].some((value) =>
value.toLowerCase().includes(search)
)
: true;
return matchesCategory && matchesSearch;
});
const sorted = sortProducts(filtered, sort);
const offset = (page - 1) * limit;
const products = sorted.slice(offset, offset + limit);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Example products loaded successfully',
total_products: sorted.length,
offset,
limit,
products
});
}

View File

@@ -1,86 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { exampleUsers } from '@/features/example-dashboard/data';
function parseRoles(value: string | null) {
if (!value) return [];
return value
.split(/[.,]/)
.map((item) => item.trim())
.filter(Boolean);
}
function sortUsers(
items: typeof exampleUsers,
sort: string | null
) {
const defaultSorted = items.toSorted((a, b) => a.name.localeCompare(b.name));
if (!sort) {
return defaultSorted;
}
try {
const parsed = JSON.parse(sort) as Array<{ id: string; desc: boolean }>;
const [primarySort] = parsed;
if (!primarySort?.id) {
return defaultSorted;
}
const sorted = items.toSorted((left, right) => {
const a =
primarySort.id === 'role'
? `${left.systemRole}:${left.activeMembershipRole ?? ''}`
: primarySort.id === 'organizations'
? left.organizations.map((organization) => organization.name).join(', ')
: left.name;
const b =
primarySort.id === 'role'
? `${right.systemRole}:${right.activeMembershipRole ?? ''}`
: primarySort.id === 'organizations'
? right.organizations.map((organization) => organization.name).join(', ')
: right.name;
return a.localeCompare(b);
});
return primarySort.desc ? sorted.toReversed() : sorted;
} catch {
return defaultSorted;
}
}
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const page = Number(searchParams.get('page') ?? 1);
const limit = Number(searchParams.get('limit') ?? 10);
const roles = parseRoles(searchParams.get('roles'));
const search = searchParams.get('search')?.trim().toLowerCase();
const sort = searchParams.get('sort');
const filtered = exampleUsers.filter((user) => {
const matchesSearch = search
? [user.name, user.email].some((value) => value.toLowerCase().includes(search))
: true;
const matchesRole = roles.length
? roles.includes(user.systemRole) ||
user.memberships.some((membership) => roles.includes(membership.membershipRole))
: true;
return matchesSearch && matchesRole;
});
const sorted = sortUsers(filtered, sort);
const offset = (page - 1) * limit;
const users = sorted.slice(offset, offset + limit);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Example users loaded successfully',
total_users: sorted.length,
offset,
limit,
users
});
}

View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from 'next/server';
import { listMasterOptions } from '@/features/foundation/master-options/service';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
export async function GET(request: NextRequest) {
try {
const { organization } = await requireOrganizationAccess({
permission: PERMISSIONS.organizationManage
});
const { searchParams } = request.nextUrl;
const page = Number(searchParams.get('page') ?? 1);
const limit = Number(searchParams.get('limit') ?? 10);
const search = searchParams.get('search') ?? undefined;
const category = searchParams.get('category') ?? undefined;
const sort = searchParams.get('sort') ?? undefined;
const result = await listMasterOptions({
organizationId: organization.id,
page,
limit,
search,
category,
sort
});
const offset = (page - 1) * limit;
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: 'Master options loaded successfully',
total_items: result.totalItems,
offset,
limit,
items: result.items.map((item) => ({
id: item.id,
organization_id: item.organizationId,
category: item.category,
code: item.code,
label: item.label,
value: item.value,
parent_id: item.parentId,
parent_label: item.parentLabel,
sort_order: item.sortOrder,
is_active: item.isActive,
deleted_at: item.deletedAt,
updated_at: item.updatedAt
}))
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to load master options' }, { status: 500 });
}
}

View File

@@ -1,58 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { AuthError } from '@/lib/auth/session';
import {
assertMasterDataEntity,
deleteMasterData,
parseMasterDataPayload,
requireMasterDataAccess,
updateMasterData
} from '../../_lib';
type Params = { params: Promise<{ entity: string; id: string }> };
export async function PUT(request: NextRequest, { params }: Params) {
try {
const { entity: rawEntity, id } = await params;
const entity = assertMasterDataEntity(rawEntity);
const { organization } = await requireMasterDataAccess();
const payload = parseMasterDataPayload(entity, await request.json());
await updateMasterData(entity, organization.id, id, payload);
return NextResponse.json({
success: true,
message: `${entity} updated successfully`
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to update master data' }, { status: 500 });
}
}
export async function DELETE(_request: NextRequest, { params }: Params) {
try {
const { entity: rawEntity, id } = await params;
const entity = assertMasterDataEntity(rawEntity);
const { organization } = await requireMasterDataAccess();
await deleteMasterData(entity, organization.id, id);
return NextResponse.json({
success: true,
message: `${entity} deleted successfully`
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to delete master data' }, { status: 500 });
}
}

View File

@@ -1,62 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { AuthError } from '@/lib/auth/session';
import {
assertMasterDataEntity,
createMasterData,
listMasterData,
parseMasterDataPayload,
requireMasterDataAccess
} from '../_lib';
type Params = { params: Promise<{ entity: string }> };
export async function GET(_request: NextRequest, { params }: Params) {
try {
const { entity: rawEntity } = await params;
const entity = assertMasterDataEntity(rawEntity);
const { organization } = await requireMasterDataAccess();
const items = await listMasterData(entity, organization.id);
return NextResponse.json({
success: true,
time: new Date().toISOString(),
message: `${entity} loaded successfully`,
items
});
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
return NextResponse.json({ message: 'Unable to load master data' }, { status: 500 });
}
}
export async function POST(request: NextRequest, { params }: Params) {
try {
const { entity: rawEntity } = await params;
const entity = assertMasterDataEntity(rawEntity);
const { organization } = await requireMasterDataAccess();
const payload = parseMasterDataPayload(entity, await request.json());
const id = await createMasterData(entity, organization.id, payload);
return NextResponse.json(
{
success: true,
message: `${entity} created successfully`,
id
},
{ status: 201 }
);
} catch (error) {
if (error instanceof AuthError) {
return NextResponse.json({ message: error.message }, { status: error.status });
}
if (error instanceof Error) {
return NextResponse.json({ message: error.message }, { status: 400 });
}
return NextResponse.json({ message: 'Unable to create master data' }, { status: 500 });
}
}

View File

@@ -1,275 +0,0 @@
import { and, eq } from 'drizzle-orm';
import { z } from 'zod';
import { departments, employees, locations, sites } from '@/db/schema';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { requireOrganizationAccess } from '@/lib/auth/session';
import { db } from '@/lib/db';
import type {
Department,
Employee,
Location,
MasterDataEntity,
MasterDataMutationPayload,
Site
} from '@/features/assets/api/types';
const masterDataEntities = ['sites', 'departments', 'locations', 'employees'] as const;
const entitySchemaMap = {
sites: z.object({
code: z.string().min(1),
name: z.string().min(1)
}),
departments: z.object({
code: z.string().min(1),
name: z.string().min(1)
}),
locations: z.object({
name: z.string().min(1),
siteId: z.string().optional().nullable(),
building: z.string().optional(),
floor: z.string().optional(),
area: z.string().optional()
}),
employees: z.object({
employeeNo: z.string().min(1),
name: z.string().min(1),
departmentId: z.string().optional().nullable(),
siteId: z.string().optional().nullable(),
status: z.string().default('active')
})
} as const;
export async function requireMasterDataAccess() {
return requireOrganizationAccess({ permission: PERMISSIONS.masterDataManage });
}
export function assertMasterDataEntity(entity: string): MasterDataEntity {
if ((masterDataEntities as readonly string[]).includes(entity)) {
return entity as MasterDataEntity;
}
throw new Error('Invalid master data entity');
}
export function parseMasterDataPayload(
entity: MasterDataEntity,
body: unknown
): MasterDataMutationPayload {
const schema = entitySchemaMap[entity];
const parsed = schema.safeParse(body);
if (!parsed.success) {
throw new Error('Invalid master data payload');
}
return parsed.data;
}
export async function listMasterData(entity: MasterDataEntity, organizationId: string) {
switch (entity) {
case 'sites': {
const rows = await db.query.sites.findMany({
where: eq(sites.organizationId, organizationId)
});
return rows.map<Site>((row) => ({
id: row.id,
organizationId: row.organizationId,
code: row.code,
name: row.name
}));
}
case 'departments': {
const rows = await db.query.departments.findMany({
where: eq(departments.organizationId, organizationId)
});
return rows.map<Department>((row) => ({
id: row.id,
organizationId: row.organizationId,
code: row.code,
name: row.name
}));
}
case 'locations': {
const rows = await db
.select({
id: locations.id,
organizationId: locations.organizationId,
siteId: locations.siteId,
siteName: sites.name,
building: locations.building,
floor: locations.floor,
area: locations.area,
name: locations.name
})
.from(locations)
.leftJoin(sites, eq(sites.id, locations.siteId))
.where(eq(locations.organizationId, organizationId));
return rows.map<Location>((row) => ({
id: row.id,
organizationId: row.organizationId,
siteId: row.siteId,
siteName: row.siteName,
building: row.building,
floor: row.floor,
area: row.area,
name: row.name
}));
}
case 'employees': {
const rows = await db
.select({
id: employees.id,
organizationId: employees.organizationId,
employeeNo: employees.employeeNo,
name: employees.name,
departmentId: employees.departmentId,
departmentName: departments.name,
siteId: employees.siteId,
siteName: sites.name,
status: employees.status
})
.from(employees)
.leftJoin(departments, eq(departments.id, employees.departmentId))
.leftJoin(sites, eq(sites.id, employees.siteId))
.where(eq(employees.organizationId, organizationId));
return rows.map<Employee>((row) => ({
id: row.id,
organizationId: row.organizationId,
employeeNo: row.employeeNo,
name: row.name,
departmentId: row.departmentId,
departmentName: row.departmentName,
siteId: row.siteId,
siteName: row.siteName,
status: row.status
}));
}
}
}
export async function createMasterData(
entity: MasterDataEntity,
organizationId: string,
payload: MasterDataMutationPayload
) {
const id = crypto.randomUUID();
switch (entity) {
case 'sites':
await db.insert(sites).values({
id,
organizationId,
code: payload.code!,
name: payload.name
});
break;
case 'departments':
await db.insert(departments).values({
id,
organizationId,
code: payload.code!,
name: payload.name
});
break;
case 'locations':
await db.insert(locations).values({
id,
organizationId,
siteId: payload.siteId ?? null,
building: payload.building ?? null,
floor: payload.floor ?? null,
area: payload.area ?? null,
name: payload.name
});
break;
case 'employees':
await db.insert(employees).values({
id,
organizationId,
employeeNo: payload.employeeNo!,
name: payload.name,
departmentId: payload.departmentId ?? null,
siteId: payload.siteId ?? null,
status: payload.status ?? 'active'
});
break;
}
return id;
}
export async function updateMasterData(
entity: MasterDataEntity,
organizationId: string,
id: string,
payload: MasterDataMutationPayload
) {
switch (entity) {
case 'sites':
await db
.update(sites)
.set({ code: payload.code!, name: payload.name, updatedAt: new Date() })
.where(and(eq(sites.id, id), eq(sites.organizationId, organizationId)));
break;
case 'departments':
await db
.update(departments)
.set({ code: payload.code!, name: payload.name, updatedAt: new Date() })
.where(and(eq(departments.id, id), eq(departments.organizationId, organizationId)));
break;
case 'locations':
await db
.update(locations)
.set({
siteId: payload.siteId ?? null,
building: payload.building ?? null,
floor: payload.floor ?? null,
area: payload.area ?? null,
name: payload.name,
updatedAt: new Date()
})
.where(and(eq(locations.id, id), eq(locations.organizationId, organizationId)));
break;
case 'employees':
await db
.update(employees)
.set({
employeeNo: payload.employeeNo!,
name: payload.name,
departmentId: payload.departmentId ?? null,
siteId: payload.siteId ?? null,
status: payload.status ?? 'active',
updatedAt: new Date()
})
.where(and(eq(employees.id, id), eq(employees.organizationId, organizationId)));
break;
}
}
export async function deleteMasterData(entity: MasterDataEntity, organizationId: string, id: string) {
switch (entity) {
case 'sites':
await db.delete(sites).where(and(eq(sites.id, id), eq(sites.organizationId, organizationId)));
break;
case 'departments':
await db
.delete(departments)
.where(and(eq(departments.id, id), eq(departments.organizationId, organizationId)));
break;
case 'locations':
await db
.delete(locations)
.where(and(eq(locations.id, id), eq(locations.organizationId, organizationId)));
break;
case 'employees':
await db
.delete(employees)
.where(and(eq(employees.id, id), eq(employees.organizationId, organizationId)));
break;
}
}

View File

@@ -1,21 +1,37 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
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';
import MasterOptionsListingPage from '@/features/foundation/master-options/components/master-options-listing';
import { PERMISSIONS } from '@/lib/auth/rbac';
import { searchParamsCache } from '@/lib/searchparams';
import type { SearchParams } from 'nuqs/server';
export default function MasterOptionsRoute() {
const queryClient = getQueryClient();
void queryClient.prefetchQuery(crmReferenceQueryOptions());
type PageProps = {
searchParams: Promise<SearchParams>;
};
export default async function MasterOptionsRoute(props: PageProps) {
const searchParams = await props.searchParams;
const session = await auth();
const canManageOptions =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes(PERMISSIONS.organizationManage)));
searchParamsCache.parse(searchParams);
return (
<PageContainer
pageTitle='Master Options'
pageDescription='Mock UI สำหรับ status options, product types, payment term, currency และ tax rate'
pageDescription='Organization-scoped CRM option registry for statuses, product types, payment terms, currency, and branch abstractions.'
access={canManageOptions}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to master option management.
</div>
}
>
<HydrationBoundary state={dehydrate(queryClient)}>
<MasterOptionsPage />
</HydrationBoundary>
<MasterOptionsListingPage />
</PageContainer>
);
}

View File

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

View File

@@ -19,8 +19,8 @@ export default async function ExclusivePage() {
<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.
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'>

View File

@@ -1,26 +1,37 @@
import type { ReactNode } from 'react';
import KBar from '@/components/kbar';
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';
import type { Metadata } from 'next';
import { cookies } from 'next/headers';
export default function DashboardLayout({ children }: { children: ReactNode }) {
export const metadata: Metadata = {
title: 'Next Shadcn Dashboard Starter',
description: 'Basic dashboard with Next.js and Shadcn',
robots: {
index: false,
follow: false
}
};
export default async function DashboardLayout({ children }: { children: React.ReactNode }) {
// Persisting the sidebar state in the cookie.
const cookieStore = await cookies();
const defaultOpen = cookieStore.get('sidebar_state')?.value === 'true';
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>
<SidebarProvider defaultOpen={defaultOpen}>
<AppSidebar />
<SidebarInset>
<Header />
<InfobarProvider defaultOpen={false}>
{children}
<InfoSidebar side='right' />
</InfobarProvider>
</SidebarInset>
</SidebarProvider>
</KBar>
);
}

View File

@@ -1,5 +1,5 @@
import { redirect } from 'next/navigation';
import OverViewPage from '@/features/overview/components/overview';
export default function DashboardIndexPage() {
redirect('/dashboard/crm');
export default function DashboardPage() {
return <OverViewPage />;
}

View File

@@ -0,0 +1,51 @@
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import { buttonVariants } from '@/components/ui/button';
import ProductListingPage from '@/features/products/components/product-listing';
import { searchParamsCache } from '@/lib/searchparams';
import { cn } from '@/lib/utils';
import { Icons } from '@/components/icons';
import Link from 'next/link';
import { SearchParams } from 'nuqs/server';
import { productInfoContent } from '@/config/infoconfig';
export const metadata = {
title: 'Dashboard: Products'
};
type pageProps = {
searchParams: Promise<SearchParams>;
};
export default async function Page(props: pageProps) {
const searchParams = await props.searchParams;
const session = await auth();
const hasActiveOrganization = !!session?.user?.activeOrganizationId;
searchParamsCache.parse(searchParams);
return (
<PageContainer
pageTitle='Products'
pageDescription='Manage products (React Query + nuqs table pattern.)'
infoContent={productInfoContent}
access={hasActiveOrganization}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
Select an active workspace before managing products.
</div>
}
pageHeaderAction={
hasActiveOrganization ? (
<Link
href='/dashboard/product/new'
className={cn(buttonVariants(), 'text-xs md:text-sm')}
>
<Icons.add className='mr-2 h-4 w-4' /> Add New
</Link>
) : null
}
>
<ProductListingPage canPrefetch={hasActiveOrganization} />
</PageContainer>
);
}

View File

@@ -1,23 +1,27 @@
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 PageContainer from '@/components/layout/page-container';
import { Suspense } from 'react';
import { PokemonSkeleton } from '@/features/react-query-demo/components/pokemon-skeleton';
import { reactQueryInfoContent } from '@/features/react-query-demo/info-content';
export const metadata = {
title: 'Example Dashboard: React Query'
title: 'Dashboard: React Query'
};
export default function ExampleReactQueryPage() {
export default function ReactQueryPage() {
const queryClient = getQueryClient();
// Prefetch on the server — data is ready before client JS loads
void queryClient.prefetchQuery(pokemonOptions(25));
return (
<PageContainer
pageTitle='React Query'
pageDescription='Server prefetch + hydration + suspense query pattern in a public example route.'
pageDescription='Server prefetch + client hydration + suspense query pattern.'
infoContent={reactQueryInfoContent}
>
<HydrationBoundary state={dehydrate(queryClient)}>
<Suspense fallback={<PokemonSkeleton />}>

View File

@@ -1,47 +1,44 @@
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { auth } from '@/auth';
import PageContainer from '@/components/layout/page-container';
import { getQueryClient } from '@/lib/query-client';
import UserListingPage from '@/features/users/components/user-listing';
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';
import { usersInfoContent } from '@/features/users/info-content';
import { UserFormSheetTrigger } from '@/features/users/components/user-form-sheet';
export const metadata = {
title: 'Dashboard: Users'
};
type PageProps = {
searchParams: Promise<SearchParams>;
};
export const metadata = {
title: 'Example Dashboard: Users'
};
export default async function ExampleUsersPage(props: PageProps) {
export default async function UsersPage(props: PageProps) {
const searchParams = await props.searchParams;
const session = await auth();
const canManageUsers =
session?.user?.systemRole === 'super_admin' ||
(!!session?.user?.activeOrganizationId &&
(session.user.activeMembershipRole === 'admin' ||
session.user.activePermissions.includes('users:manage')));
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.'
pageDescription='Manage users (React Query + nuqs table pattern.)'
infoContent={usersInfoContent}
access={canManageUsers}
accessFallback={
<div className='text-muted-foreground rounded-lg border border-dashed p-8 text-center'>
You do not have access to user management.
</div>
}
pageHeaderAction={canManageUsers ? <UserFormSheetTrigger /> : null}
>
<HydrationBoundary state={dehydrate(queryClient)}>
<ExampleUserTable />
</HydrationBoundary>
<UserListingPage />
</PageContainer>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,47 +0,0 @@
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

@@ -1,194 +0,0 @@
'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

@@ -1,55 +0,0 @@
'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>
);
}

View File

@@ -6,14 +6,13 @@ import ThemeProvider from '@/components/themes/theme-provider';
import { cn } from '@/lib/utils';
import type { Metadata, Viewport } from 'next';
import { cookies } from 'next/headers';
import Script from 'next/script';
import NextTopLoader from 'nextjs-toploader';
import { NuqsAdapter } from 'nuqs/adapters/next/app';
import '../styles/globals.css';
const META_THEME_COLORS = {
light: '#ffffff',
dark: '#09090b'
light: '#f9fbfa',
dark: '#001e2b'
};
export const metadata: Metadata = {
@@ -34,16 +33,18 @@ export default async function RootLayout({ children }: { children: React.ReactNo
return (
<html lang='en' suppressHydrationWarning data-theme={themeToApply}>
<head>
<Script id='theme-color-init' strategy='beforeInteractive'>
{`
try {
// Keep the browser chrome color in sync before hydration.
if (localStorage.theme === 'dark' || ((!('theme' in localStorage) || localStorage.theme === 'system') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', '${META_THEME_COLORS.dark}')
}
} catch (_) {}
`}
</Script>
<script
dangerouslySetInnerHTML={{
__html: `
try {
// Set meta theme color
if (localStorage.theme === 'dark' || ((!('theme' in localStorage) || localStorage.theme === 'system') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', '${META_THEME_COLORS.dark}')
}
} catch (_) {}
`
}}
/>
</head>
<body
className={cn(

View File

@@ -1,5 +1,12 @@
import { redirect } from "next/navigation";
import { auth } from '@/auth';
import { redirect } from 'next/navigation';
export default function Page() {
redirect("/dashboard");
export default async function Page() {
const session = await auth();
if (!session?.user?.id) {
return redirect('/auth/sign-in');
} else {
redirect('/dashboard');
}
}

View File

@@ -1,9 +0,0 @@
import { SetupWizard } from '@/features/setup/components/setup-wizard';
export const metadata = {
title: 'Setup Wizard'
};
export default function SetupPage() {
return <SetupWizard />;
}

View File

@@ -83,49 +83,38 @@ export default function AppSidebar() {
<SidebarMenu>
{group.items.map((item) => {
const Icon = item.icon ? Icons[item.icon] : Icons.logo;
const itemKey = item.url || item.title;
const childItems = item.items?.filter((subItem) => Boolean(subItem.url)) ?? [];
if (childItems.length > 0) {
return (
<Collapsible
key={itemKey}
asChild
defaultOpen={item.isActive}
className='group/collapsible'
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={item.title} isActive={pathname === item.url}>
{item.icon && <Icon />}
<span>{item.title}</span>
<Icons.chevronRight className='ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{childItems.map((subItem) => (
<SidebarMenuSubItem key={subItem.url || subItem.title}>
<SidebarMenuSubButton asChild isActive={pathname === subItem.url}>
<Link href={subItem.url}>
<span>{subItem.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
);
}
if (!item.url) {
return null;
}
return (
<SidebarMenuItem key={itemKey}>
return item?.items && item?.items?.length > 0 ? (
<Collapsible
key={item.title}
asChild
defaultOpen={item.isActive}
className='group/collapsible'
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton tooltip={item.title} isActive={pathname === item.url}>
{item.icon && <Icon />}
<span>{item.title}</span>
<Icons.chevronRight className='ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{item.items?.map((subItem) => (
<SidebarMenuSubItem key={subItem.title}>
<SidebarMenuSubButton asChild isActive={pathname === subItem.url}>
<Link href={subItem.url}>
<span>{subItem.title}</span>
</Link>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
))}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
) : (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton
asChild
tooltip={item.title}

View File

@@ -2,9 +2,13 @@
* Default theme that loads when no user preference is set
* Change this value to set a different default theme
*/
export const DEFAULT_THEME = 'vercel';
export const DEFAULT_THEME = 'mongodb';
export const THEMES = [
{
name: 'MongoDB',
value: 'mongodb'
},
{
name: 'Claude',
value: 'claude'

View File

@@ -1,4 +1,4 @@
import { NavGroup } from "@/types";
import { NavGroup } from '@/types';
/**
* Navigation configuration with RBAC support
@@ -35,118 +35,157 @@ import { NavGroup } from "@/types";
*/
export const navGroups: NavGroup[] = [
{
label: "Overview",
label: 'Overview',
items: [
{
title: "Dashboard",
url: "/dashboard",
icon: "dashboard",
title: 'Dashboard',
url: '/dashboard',
icon: 'dashboard',
isActive: false,
shortcut: ["d", "d"],
items: [],
shortcut: ['d', 'd'],
items: []
},
{
title: "Workspaces",
url: "/dashboard/workspaces",
icon: "workspace",
title: 'Workspaces',
url: '/dashboard/workspaces',
icon: 'workspace',
isActive: false,
items: [],
access: { systemRole: "super_admin" },
access: { systemRole: 'super_admin' }
},
{
title: "Teams",
url: "/dashboard/workspaces/team",
icon: "teams",
title: 'Teams',
url: '/dashboard/workspaces/team',
icon: 'teams',
isActive: false,
items: [],
access: { requireOrg: true, role: "admin" },
access: { requireOrg: true, role: 'admin' }
},
{
title: "Users",
url: "/dashboard/users",
icon: "teams",
shortcut: ["u", "u"],
title: 'Users',
url: '/dashboard/users',
icon: 'teams',
shortcut: ['u', 'u'],
isActive: false,
items: [],
access: { requireOrg: true, permission: "users:manage" },
access: { requireOrg: true, permission: 'users:manage' }
},
{
title: "Kanban",
url: "/dashboard/kanban",
icon: "kanban",
shortcut: ["k", "k"],
title: 'Kanban',
url: '/dashboard/kanban',
icon: 'kanban',
shortcut: ['k', 'k'],
isActive: false,
items: [],
},
],
},
{
label: "CRM",
items: [
{
title: "CRM Dashboard",
url: "/dashboard/crm",
icon: "dashboard",
isActive: false,
items: [],
access: { requireOrg: true },
},
{
title: "Enquiries",
url: "/dashboard/crm/enquiries",
icon: "forms",
isActive: false,
items: [],
access: { requireOrg: true },
},
{
title: "Customers",
url: "/dashboard/crm/customers",
icon: "teams",
isActive: false,
items: [],
access: { requireOrg: true },
},
{
title: "Quotations",
url: "/dashboard/crm/quotations",
icon: "fileTypePdf",
isActive: false,
items: [],
access: { requireOrg: true },
},
{
title: "Approvals",
url: "/dashboard/crm/approvals",
icon: "checks",
isActive: false,
items: [],
access: { requireOrg: true },
},
{
title: "CRM Settings",
url: "#",
icon: "settings",
isActive: true,
access: { requireOrg: true },
items: [
{
title: "Master Options",
url: "/dashboard/crm/settings/master-options",
icon: "settings",
},
{
title: "Document Sequences",
url: "/dashboard/crm/settings/document-sequences",
icon: "post",
},
{
title: "Templates",
url: "/dashboard/crm/settings/templates",
icon: "page",
},
],
},
],
},
items: []
}
// {
// title: "Chat",
// url: "/dashboard/chat",
// icon: "chat",
// shortcut: ["c", "c"],
// isActive: false,
// items: [],
// },
]
}
// {
// label: "Elements",
// items: [
// {
// title: "Forms",
// url: "#",
// icon: "forms",
// isActive: true,
// items: [
// {
// title: "Basic Form",
// url: "/dashboard/forms/basic",
// icon: "forms",
// shortcut: ["f", "f"],
// },
// {
// title: "Multi-Step Form",
// url: "/dashboard/forms/multi-step",
// icon: "forms",
// },
// {
// title: "Sheet & Dialog",
// url: "/dashboard/forms/sheet-form",
// icon: "forms",
// },
// {
// title: "Advanced Patterns",
// url: "/dashboard/forms/advanced",
// icon: "forms",
// },
// ],
// },
// {
// title: "React Query",
// url: "/dashboard/react-query",
// icon: "code",
// isActive: false,
// items: [],
// },
// {
// title: "Icons",
// url: "/dashboard/elements/icons",
// icon: "palette",
// isActive: false,
// items: [],
// },
// ],
// },
// {
// label: "",
// items: [
// {
// title: "Pro",
// url: "#",
// icon: "pro",
// isActive: true,
// items: [
// {
// title: "Exclusive",
// url: "/dashboard/exclusive",
// icon: "exclusive",
// shortcut: ["e", "e"],
// },
// ],
// },
// {
// title: "Account",
// url: "#",
// icon: "account",
// isActive: true,
// items: [
// {
// title: "Profile",
// url: "/dashboard/profile",
// icon: "profile",
// shortcut: ["m", "m"],
// },
// {
// title: "Notifications",
// url: "/dashboard/notifications",
// icon: "notification",
// shortcut: ["n", "n"],
// },
// {
// title: "Billing",
// url: "/dashboard/billing",
// icon: "billing",
// shortcut: ["b", "b"],
// access: { requireOrg: true, role: "admin" },
// },
// {
// title: "Login",
// shortcut: ["l", "l"],
// url: "/",
// icon: "login",
// },
// ],
// },
// ],
// },
];

View File

@@ -1,84 +1,130 @@
import {
boolean,
doublePrecision,
integer,
jsonb,
numeric,
pgTable,
text,
timestamp,
uniqueIndex,
} from "drizzle-orm/pg-core";
uniqueIndex
} from 'drizzle-orm/pg-core';
export const users = pgTable(
"users",
'users',
{
id: text("id").primaryKey(),
name: text("name").notNull(),
email: text("email").notNull(),
passwordHash: text("password_hash").notNull(),
systemRole: text("system_role").default("user").notNull(),
image: text("image"),
activeOrganizationId: text("active_organization_id"),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull(),
passwordHash: text('password_hash').notNull(),
systemRole: text('system_role').default('user').notNull(),
image: text('image'),
activeOrganizationId: text('active_organization_id'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => ({
emailIdx: uniqueIndex("users_email_idx").on(table.email),
}),
emailIdx: uniqueIndex('users_email_idx').on(table.email)
})
);
export const organizations = pgTable(
"organizations",
'organizations',
{
id: text("id").primaryKey(),
name: text("name").notNull(),
slug: text("slug").notNull(),
imageUrl: text("image_url"),
plan: text("plan").default("free").notNull(),
createdBy: text("created_by").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
id: text('id').primaryKey(),
name: text('name').notNull(),
slug: text('slug').notNull(),
imageUrl: text('image_url'),
plan: text('plan').default('free').notNull(),
createdBy: text('created_by').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => ({
slugIdx: uniqueIndex("organizations_slug_idx").on(table.slug),
}),
slugIdx: uniqueIndex('organizations_slug_idx').on(table.slug)
})
);
export const memberships = pgTable("memberships", {
id: text("id").primaryKey(),
userId: text("user_id").notNull(),
organizationId: text("organization_id").notNull(),
role: text("role").default("user").notNull(),
businessRole: text("business_role").default("viewer").notNull(),
permissions: text("permissions").array().default([]).notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
export const memberships = pgTable('memberships', {
id: text('id').primaryKey(),
userId: text('user_id').notNull(),
organizationId: text('organization_id').notNull(),
role: text('role').default('user').notNull(),
businessRole: text('business_role').default('viewer').notNull(),
permissions: text('permissions').array().default([]).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
});
export const products = pgTable("products", {
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
organizationId: text("organization_id").notNull(),
name: text("name").notNull(),
category: text("category").notNull(),
description: text("description").notNull(),
photoUrl: text("photo_url").notNull(),
price: doublePrecision("price").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
export const products = pgTable('products', {
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
organizationId: text('organization_id').notNull(),
name: text('name').notNull(),
category: text('category').notNull(),
description: text('description').notNull(),
photoUrl: text('photo_url').notNull(),
price: doublePrecision('price').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
});
export const msOptions = pgTable(
'ms_options',
{
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
category: text('category').notNull(),
code: text('code').notNull(),
label: text('label').notNull(),
value: text('value'),
parentId: text('parent_id'),
sortOrder: integer('sort_order').default(0).notNull(),
isActive: boolean('is_active').default(true).notNull(),
metadata: jsonb('metadata'),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => ({
organizationCategoryCodeIdx: uniqueIndex('ms_options_org_category_code_idx').on(
table.organizationId,
table.category,
table.code
)
})
);
export const documentSequences = pgTable(
'document_sequences',
{
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
branchId: text('branch_id').default('').notNull(),
documentType: text('document_type').notNull(),
prefix: text('prefix').notNull(),
period: text('period').notNull(),
currentNumber: integer('current_number').default(0).notNull(),
paddingLength: integer('padding_length').default(3).notNull(),
isActive: boolean('is_active').default(true).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
},
(table) => ({
organizationDocumentPeriodBranchIdx: uniqueIndex(
'document_sequences_org_doc_period_branch_idx'
).on(table.organizationId, table.documentType, table.period, table.branchId)
})
);
export const trAuditLogs = pgTable('tr_audit_logs', {
id: text('id').primaryKey(),
organizationId: text('organization_id').notNull(),
branchId: text('branch_id'),
userId: text('user_id').notNull(),
entityType: text('entity_type').notNull(),
entityId: text('entity_id').notNull(),
action: text('action').notNull(),
beforeData: jsonb('before_data'),
afterData: jsonb('after_data'),
requestId: text('request_id'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull()
});

View File

@@ -1,99 +0,0 @@
import { mutationOptions } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import {
assignAsset,
createAsset,
createMasterData,
deleteAsset,
deleteMasterData,
repairAsset,
returnAsset,
transferAsset,
updateAsset,
updateMasterData
} from './service';
import { assetKeys } from './queries';
import type {
AssetAssignmentPayload,
AssetMutationPayload,
AssetRepairPayload,
AssetReturnPayload,
AssetTransferPayload,
MasterDataEntity,
MasterDataMutationPayload
} from './types';
function invalidateAssets() {
const queryClient = getQueryClient();
queryClient.invalidateQueries({ queryKey: assetKeys.all });
}
export const createAssetMutation = mutationOptions({
mutationFn: (data: AssetMutationPayload) => createAsset(data),
onSuccess: invalidateAssets
});
export const updateAssetMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: AssetMutationPayload }) =>
updateAsset(id, values),
onSuccess: invalidateAssets
});
export const deleteAssetMutation = mutationOptions({
mutationFn: (id: string) => deleteAsset(id),
onSuccess: invalidateAssets
});
export const assignAssetMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: AssetAssignmentPayload }) =>
assignAsset(id, values),
onSuccess: invalidateAssets
});
export const transferAssetMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: AssetTransferPayload }) =>
transferAsset(id, values),
onSuccess: invalidateAssets
});
export const returnAssetMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: AssetReturnPayload }) =>
returnAsset(id, values),
onSuccess: invalidateAssets
});
export const repairAssetMutation = mutationOptions({
mutationFn: ({ id, values }: { id: string; values: AssetRepairPayload }) =>
repairAsset(id, values),
onSuccess: invalidateAssets
});
export const createMasterDataMutation = mutationOptions({
mutationFn: ({
entity,
values
}: {
entity: MasterDataEntity;
values: MasterDataMutationPayload;
}) => createMasterData(entity, values),
onSuccess: invalidateAssets
});
export const updateMasterDataMutation = mutationOptions({
mutationFn: ({
entity,
id,
values
}: {
entity: MasterDataEntity;
id: string;
values: MasterDataMutationPayload;
}) => updateMasterData(entity, id, values),
onSuccess: invalidateAssets
});
export const deleteMasterDataMutation = mutationOptions({
mutationFn: ({ entity, id }: { entity: MasterDataEntity; id: string }) =>
deleteMasterData(entity, id),
onSuccess: invalidateAssets
});

View File

@@ -1,84 +0,0 @@
import { queryOptions } from '@tanstack/react-query';
import {
getAssetById,
getAssetHistory,
getAssetOptions,
getAssets,
getAssetSummary,
getMasterData
} from './service';
import type { AssetFilters, Department, Employee, Location, MasterDataEntity, Site } from './types';
export const assetKeys = {
all: ['assets'] as const,
lists: () => [...assetKeys.all, 'list'] as const,
list: (filters: AssetFilters) => [...assetKeys.lists(), filters] as const,
detail: (id: string) => [...assetKeys.all, 'detail', id] as const,
summary: () => [...assetKeys.all, 'summary'] as const,
options: () => [...assetKeys.all, 'options'] as const,
history: () => [...assetKeys.all, 'history'] as const,
masterData: (entity: MasterDataEntity) => [...assetKeys.all, 'master-data', entity] as const
};
export function assetsQueryOptions(filters: AssetFilters) {
return queryOptions({
queryKey: assetKeys.list(filters),
queryFn: () => getAssets(filters)
});
}
export function assetByIdQueryOptions(id: string) {
return queryOptions({
queryKey: assetKeys.detail(id),
queryFn: () => getAssetById(id)
});
}
export function assetSummaryQueryOptions() {
return queryOptions({
queryKey: assetKeys.summary(),
queryFn: getAssetSummary
});
}
export function assetOptionsQueryOptions() {
return queryOptions({
queryKey: assetKeys.options(),
queryFn: getAssetOptions
});
}
export function assetHistoryQueryOptions() {
return queryOptions({
queryKey: assetKeys.history(),
queryFn: getAssetHistory
});
}
export function masterDataQueryOptions(entity: 'sites') {
return queryOptions({
queryKey: assetKeys.masterData(entity),
queryFn: () => getMasterData<Site>(entity)
});
}
export function departmentQueryOptions(entity: 'departments') {
return queryOptions({
queryKey: assetKeys.masterData(entity),
queryFn: () => getMasterData<Department>(entity)
});
}
export function locationQueryOptions(entity: 'locations') {
return queryOptions({
queryKey: assetKeys.masterData(entity),
queryFn: () => getMasterData<Location>(entity)
});
}
export function employeeQueryOptions(entity: 'employees') {
return queryOptions({
queryKey: assetKeys.masterData(entity),
queryFn: () => getMasterData<Employee>(entity)
});
}

View File

@@ -1,125 +0,0 @@
import { apiClient } from '@/lib/api-client';
import type {
AssetAssignmentPayload,
AssetByIdResponse,
AssetDashboardSummary,
AssetFilters,
AssetMutationPayload,
AssetOptionsResponse,
AssetRepairPayload,
AssetReturnPayload,
AssetsResponse,
AssetTransferPayload,
MasterDataEntity,
MasterDataListResponse,
MasterDataMutationPayload
} from './types';
export async function getAssets(filters: AssetFilters): Promise<AssetsResponse> {
const searchParams = new URLSearchParams();
if (filters.page) searchParams.set('page', String(filters.page));
if (filters.limit) searchParams.set('limit', String(filters.limit));
if (filters.search) searchParams.set('search', filters.search);
if (filters.status) searchParams.set('status', filters.status);
if (filters.assetCondition) searchParams.set('assetCondition', filters.assetCondition);
if (filters.dispositionStatus) searchParams.set('dispositionStatus', filters.dispositionStatus);
if (filters.assetType) searchParams.set('assetType', filters.assetType);
if (filters.sort) searchParams.set('sort', filters.sort);
return apiClient<AssetsResponse>(`/assets?${searchParams.toString()}`);
}
export async function getAssetById(id: string): Promise<AssetByIdResponse> {
return apiClient<AssetByIdResponse>(`/assets/${id}`);
}
export async function createAsset(data: AssetMutationPayload) {
return apiClient<{ success: boolean; message: string; id: string }>('/assets', {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function updateAsset(id: string, data: AssetMutationPayload) {
return apiClient<{ success: boolean; message: string }>(`/assets/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
});
}
export async function deleteAsset(id: string) {
return apiClient<{ success: boolean; message: string }>(`/assets/${id}`, {
method: 'DELETE'
});
}
export async function assignAsset(id: string, data: AssetAssignmentPayload) {
return apiClient<{ success: boolean; message: string }>(`/assets/${id}/assign`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function transferAsset(id: string, data: AssetTransferPayload) {
return apiClient<{ success: boolean; message: string }>(`/assets/${id}/transfer`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function returnAsset(id: string, data: AssetReturnPayload) {
return apiClient<{ success: boolean; message: string }>(`/assets/${id}/return`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function repairAsset(id: string, data: AssetRepairPayload) {
return apiClient<{ success: boolean; message: string }>(`/assets/${id}/repair`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function getAssetSummary() {
return apiClient<AssetDashboardSummary>('/assets/dashboard/summary');
}
export async function getAssetOptions() {
return apiClient<AssetOptionsResponse>('/assets/options');
}
export async function getAssetHistory() {
return apiClient<{ success: boolean; time: string; message: string; movements: AssetByIdResponse['movements'] }>(
'/assets/history'
);
}
export async function getMasterData<TItem>(entity: MasterDataEntity) {
return apiClient<MasterDataListResponse<TItem>>(`/master-data/${entity}`);
}
export async function createMasterData(entity: MasterDataEntity, data: MasterDataMutationPayload) {
return apiClient<{ success: boolean; message: string; id: string }>(`/master-data/${entity}`, {
method: 'POST',
body: JSON.stringify(data)
});
}
export async function updateMasterData(
entity: MasterDataEntity,
id: string,
data: MasterDataMutationPayload
) {
return apiClient<{ success: boolean; message: string }>(`/master-data/${entity}/${id}`, {
method: 'PUT',
body: JSON.stringify(data)
});
}
export async function deleteMasterData(entity: MasterDataEntity, id: string) {
return apiClient<{ success: boolean; message: string }>(`/master-data/${entity}/${id}`, {
method: 'DELETE'
});
}

View File

@@ -1,254 +0,0 @@
export type BusinessRole =
| 'it_admin'
| 'helpdesk'
| 'infrastructure'
| 'application'
| 'auditor'
| 'viewer';
export type AssetStatus = 'AVAILABLE' | 'ASSIGNED' | 'IN_REPAIR' | 'LOST' | 'RETIRED';
export type AssetCondition = 'NORMAL' | 'DAMAGED';
export type DispositionStatus =
| 'NONE'
| 'WAITING_REPAIR'
| 'WAITING_WRITE_OFF'
| 'WRITE_OFF_COMPLETED';
export type MovementType =
| 'CREATE'
| 'ASSIGN'
| 'TRANSFER'
| 'CHANGE_USER'
| 'CHANGE_LOCATION'
| 'CHANGE_DEPARTMENT'
| 'CHANGE_CODE'
| 'RETURN'
| 'REPAIR';
export type MasterDataEntity = 'sites' | 'departments' | 'locations' | 'employees';
export interface Site {
id: string;
organizationId: string;
code: string;
name: string;
}
export interface Department {
id: string;
organizationId: string;
code: string;
name: string;
}
export interface Location {
id: string;
organizationId: string;
siteId: string | null;
siteName: string | null;
building: string | null;
floor: string | null;
area: string | null;
name: string;
}
export interface Employee {
id: string;
organizationId: string;
employeeNo: string;
name: string;
departmentId: string | null;
departmentName: string | null;
siteId: string | null;
siteName: string | null;
status: string;
}
export interface Asset {
id: string;
organizationId: string;
assetUid: string;
assetCode: string;
assetName: string;
companyName: string | null;
assetType: 'hardware' | 'software';
assetCategory: string;
brand: string | null;
model: string | null;
specification: string | null;
serialNumber: string | null;
siteId: string | null;
siteName: string | null;
departmentId: string | null;
departmentName: string | null;
locationId: string | null;
locationName: string | null;
currentEmployeeId: string | null;
currentEmployeeName: string | null;
custodianTeam: string | null;
purchaseDate: string | null;
warrantyExpiryDate: string | null;
eosDate: string | null;
eolDate: string | null;
eopDate: string | null;
status: AssetStatus;
assetCondition: AssetCondition;
dispositionStatus: DispositionStatus;
notes: string | null;
createdAt: string;
updatedAt: string;
}
export interface AssetMovement {
id: string;
assetId: string;
assetUid: string;
assetCode: string;
assetName: string;
eventType: MovementType;
eventDate: string;
reason: string | null;
referenceDocument: string | null;
performedByName: string | null;
metadata: Record<string, unknown> | null;
}
export interface AssetRepair {
id: string;
assetId: string;
repairDate: string;
vendor: string | null;
problem: string;
resolution: string | null;
cost: number | null;
}
export interface AssetDashboardSummary {
cards: {
totalAssets: number;
assignedAssets: number;
inStockAssets: number;
transferThisMonth: number;
withoutUser: number;
withoutLocation: number;
};
}
export interface AssetFilters {
page?: number;
limit?: number;
search?: string;
status?: string;
assetCondition?: string;
dispositionStatus?: string;
assetType?: string;
sort?: string;
}
export interface AssetsResponse {
success: boolean;
time: string;
message: string;
total_assets: number;
offset: number;
limit: number;
assets: Asset[];
}
export interface AssetByIdResponse {
success: boolean;
time: string;
message: string;
asset: Asset;
movements: AssetMovement[];
repairs: AssetRepair[];
}
export interface AssetMutationPayload {
assetCode: string;
assetName: string;
assetType: 'hardware' | 'software';
assetCategory: string;
brand?: string;
model?: string;
specification?: string;
serialNumber?: string;
siteId?: string | null;
departmentId?: string | null;
locationId?: string | null;
currentEmployeeId?: string | null;
custodianTeam?: string;
purchaseDate?: string | null;
warrantyExpiryDate?: string | null;
eosDate?: string | null;
eolDate?: string | null;
eopDate?: string | null;
status: AssetStatus;
assetCondition: AssetCondition;
dispositionStatus: DispositionStatus;
notes?: string;
}
export interface AssetAssignmentPayload {
employeeId: string;
departmentId?: string | null;
locationId?: string | null;
siteId?: string | null;
assignDate: string;
documentAttachment: string;
reason?: string;
}
export interface AssetTransferPayload {
newEmployeeId?: string | null;
newDepartmentId?: string | null;
newLocationId?: string | null;
newSiteId?: string | null;
newAssetCode?: string;
transferDate: string;
referenceDocument: string;
reason?: string;
}
export interface AssetReturnPayload {
returnDate: string;
assetCondition: AssetCondition;
remark: string;
}
export interface AssetRepairPayload {
repairDate?: string | null;
vendor?: string;
problem: string;
resolution?: string;
cost?: number | null;
markAsRepair?: boolean;
}
export interface MasterDataMutationPayload {
code?: string;
name: string;
siteId?: string | null;
building?: string;
floor?: string;
area?: string;
employeeNo?: string;
departmentId?: string | null;
status?: string;
}
export interface MasterDataListResponse<TItem> {
success: boolean;
time: string;
message: string;
items: TItem[];
}
export interface AssetOptionsResponse {
success: boolean;
time: string;
message: string;
sites: Site[];
departments: Department[];
locations: Location[];
employees: Employee[];
}

View File

@@ -1,327 +0,0 @@
'use client';
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
import { assetConditionOptions } from '../constants';
import { assetByIdQueryOptions, assetOptionsQueryOptions } from '../api/queries';
import {
assignAssetMutation,
repairAssetMutation,
returnAssetMutation,
transferAssetMutation
} from '../api/mutations';
import {
assignmentSchema,
repairSchema,
returnSchema,
transferSchema,
type AssignmentFormValues,
type RepairFormValues,
type ReturnFormValues,
type TransferFormValues
} from '../schemas/asset';
type AssetAction = 'assign' | 'transfer' | 'return' | 'repair';
export function AssetActionForm({ action, assetId }: { action: AssetAction; assetId: string }) {
if (action === 'assign') {
return <AssignAssetForm assetId={assetId} />;
}
if (action === 'transfer') {
return <TransferAssetForm assetId={assetId} />;
}
if (action === 'return') {
return <ReturnAssetForm assetId={assetId} />;
}
return <RepairAssetForm assetId={assetId} />;
}
function AssignAssetForm({ assetId }: { assetId: string }) {
const router = useRouter();
const { data: assetData } = useSuspenseQuery(assetByIdQueryOptions(assetId));
const { data: options } = useSuspenseQuery(assetOptionsQueryOptions());
const mutation = useMutation({
...assignAssetMutation,
onSuccess: () => {
toast.success('Asset assigned successfully');
router.push(`/dashboard/assets/${assetId}`);
}
});
const form = useAppForm({
defaultValues: {
employeeId: assetData.asset.currentEmployeeId ?? '',
departmentId: assetData.asset.departmentId ?? '',
locationId: assetData.asset.locationId ?? '',
siteId: assetData.asset.siteId ?? '',
assignDate: new Date().toISOString().slice(0, 10),
documentAttachment: '',
reason: ''
} as AssignmentFormValues,
validators: { onSubmit: assignmentSchema },
onSubmit: async ({ value }) => {
await mutation.mutateAsync({
id: assetId,
values: {
...value,
departmentId: value.departmentId || null,
locationId: value.locationId || null,
siteId: value.siteId || null
}
});
}
});
const { FormSelectField, FormTextField, FormDatePickerField } =
useFormFields<AssignmentFormValues>();
return (
<AssetActionLayout actionTitle='Assign Asset' assetCode={assetData.asset.assetCode}>
<form.AppForm>
<form.Form className='space-y-6'>
<FormSelectField
name='employeeId'
label='Employee'
required
options={options.employees.map((item) => ({
value: item.id,
label: `${item.employeeNo} - ${item.name}`
}))}
/>
<FormSelectField
name='departmentId'
label='Department'
options={options.departments.map((item) => ({
value: item.id,
label: `${item.code} - ${item.name}`
}))}
/>
<FormSelectField
name='siteId'
label='Site'
options={options.sites.map((item) => ({ value: item.id, label: item.name }))}
/>
<FormSelectField
name='locationId'
label='Location'
options={options.locations.map((item) => ({ value: item.id, label: item.name }))}
/>
<FormDatePickerField name='assignDate' label='Assign Date' required />
<FormTextField name='documentAttachment' label='Document Attachment' required />
<FormTextField name='reason' label='Reason' />
<ActionButtons onCancel={() => router.back()} />
</form.Form>
</form.AppForm>
</AssetActionLayout>
);
}
function TransferAssetForm({ assetId }: { assetId: string }) {
const router = useRouter();
const { data: assetData } = useSuspenseQuery(assetByIdQueryOptions(assetId));
const { data: options } = useSuspenseQuery(assetOptionsQueryOptions());
const mutation = useMutation({
...transferAssetMutation,
onSuccess: () => {
toast.success('Asset transferred successfully');
router.push(`/dashboard/assets/${assetId}`);
}
});
const form = useAppForm({
defaultValues: {
newEmployeeId: assetData.asset.currentEmployeeId ?? '',
newDepartmentId: assetData.asset.departmentId ?? '',
newLocationId: assetData.asset.locationId ?? '',
newSiteId: assetData.asset.siteId ?? '',
newAssetCode: assetData.asset.assetCode,
transferDate: new Date().toISOString().slice(0, 10),
referenceDocument: '',
reason: ''
} as TransferFormValues,
validators: { onSubmit: transferSchema },
onSubmit: async ({ value }) => {
await mutation.mutateAsync({
id: assetId,
values: {
...value,
newEmployeeId: value.newEmployeeId || null,
newDepartmentId: value.newDepartmentId || null,
newLocationId: value.newLocationId || null,
newSiteId: value.newSiteId || null
}
});
}
});
const { FormSelectField, FormTextField, FormDatePickerField } =
useFormFields<TransferFormValues>();
return (
<AssetActionLayout actionTitle='Transfer Asset' assetCode={assetData.asset.assetCode}>
<form.AppForm>
<form.Form className='space-y-6'>
<FormSelectField
name='newEmployeeId'
label='New Employee'
options={options.employees.map((item) => ({
value: item.id,
label: `${item.employeeNo} - ${item.name}`
}))}
/>
<FormSelectField
name='newDepartmentId'
label='New Department'
options={options.departments.map((item) => ({
value: item.id,
label: `${item.code} - ${item.name}`
}))}
/>
<FormSelectField
name='newSiteId'
label='New Site'
options={options.sites.map((item) => ({ value: item.id, label: item.name }))}
/>
<FormSelectField
name='newLocationId'
label='New Location'
options={options.locations.map((item) => ({ value: item.id, label: item.name }))}
/>
<FormTextField name='newAssetCode' label='New Asset Code' />
<FormDatePickerField name='transferDate' label='Transfer Date' />
<FormTextField name='referenceDocument' label='Reference Document' required />
<FormTextField name='reason' label='Transfer Reason' />
<ActionButtons onCancel={() => router.back()} />
</form.Form>
</form.AppForm>
</AssetActionLayout>
);
}
function ReturnAssetForm({ assetId }: { assetId: string }) {
const router = useRouter();
const { data: assetData } = useSuspenseQuery(assetByIdQueryOptions(assetId));
const mutation = useMutation({
...returnAssetMutation,
onSuccess: () => {
toast.success('Asset returned successfully');
router.push(`/dashboard/assets/${assetId}`);
}
});
const form = useAppForm({
defaultValues: {
returnDate: new Date().toISOString().slice(0, 10),
assetCondition: assetData.asset.assetCondition,
remark: ''
} as ReturnFormValues,
validators: { onSubmit: returnSchema },
onSubmit: async ({ value }) => {
await mutation.mutateAsync({ id: assetId, values: value });
}
});
const { FormSelectField, FormTextField, FormDatePickerField } =
useFormFields<ReturnFormValues>();
return (
<AssetActionLayout actionTitle='Return Asset' assetCode={assetData.asset.assetCode}>
<form.AppForm>
<form.Form className='space-y-6'>
<FormDatePickerField name='returnDate' label='Return Date' />
<FormSelectField
name='assetCondition'
label='Asset Condition'
required
options={assetConditionOptions.map((option) => ({
value: option.value,
label: option.label
}))}
/>
<FormTextField name='remark' label='Remark' required />
<ActionButtons onCancel={() => router.back()} />
</form.Form>
</form.AppForm>
</AssetActionLayout>
);
}
function RepairAssetForm({ assetId }: { assetId: string }) {
const router = useRouter();
const { data: assetData } = useSuspenseQuery(assetByIdQueryOptions(assetId));
const mutation = useMutation({
...repairAssetMutation,
onSuccess: () => {
toast.success('Asset repair recorded successfully');
router.push(`/dashboard/assets/${assetId}`);
}
});
const form = useAppForm({
defaultValues: {
repairDate: new Date().toISOString().slice(0, 10),
vendor: '',
problem: '',
resolution: '',
cost: null,
markAsRepair: true
} as RepairFormValues,
validators: { onSubmit: repairSchema },
onSubmit: async ({ value }) => {
await mutation.mutateAsync({ id: assetId, values: value });
}
});
const { FormCheckboxField, FormTextField, FormTextareaField, FormDatePickerField } =
useFormFields<RepairFormValues>();
return (
<AssetActionLayout actionTitle='Repair Asset' assetCode={assetData.asset.assetCode}>
<form.AppForm>
<form.Form className='space-y-6'>
<FormDatePickerField name='repairDate' label='Repair Date' />
<FormTextField name='vendor' label='Vendor' />
<FormTextField name='cost' label='Cost' type='number' step={0.01} />
<FormTextareaField name='problem' label='Problem' required rows={3} />
<FormTextareaField name='resolution' label='Resolution' rows={3} />
<FormCheckboxField name='markAsRepair' label='Mark asset as repair' />
<ActionButtons onCancel={() => router.back()} />
</form.Form>
</form.AppForm>
</AssetActionLayout>
);
}
function AssetActionLayout({
actionTitle,
assetCode,
children
}: {
actionTitle: string;
assetCode: string;
children: React.ReactNode;
}) {
return (
<Card className='mx-auto w-full max-w-2xl'>
<CardHeader>
<CardTitle>{actionTitle}</CardTitle>
<div className='text-muted-foreground text-sm'>Asset: {assetCode}</div>
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
function ActionButtons({ onCancel }: { onCancel: () => void }) {
return (
<div className='flex justify-end gap-2'>
<Button type='button' variant='outline' onClick={onCancel}>
Cancel
</Button>
<Button type='submit'>Save</Button>
</div>
);
}

View File

@@ -1,111 +0,0 @@
'use client';
import Link from 'next/link';
import { useSuspenseQuery } from '@tanstack/react-query';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { buttonVariants } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { assetByIdQueryOptions } from '../api/queries';
export function AssetDetailView({ assetId }: { assetId: string }) {
const { data } = useSuspenseQuery(assetByIdQueryOptions(assetId));
return (
<div className='space-y-6'>
<Card>
<CardHeader className='flex flex-row items-start justify-between gap-4'>
<div>
<CardTitle>{data.asset.assetName}</CardTitle>
<div className='text-muted-foreground text-sm'>
{data.asset.assetCode} {data.asset.assetUid}
</div>
</div>
<div className='flex gap-2'>
<Link href={`/dashboard/assets/${assetId}/edit`} className={cn(buttonVariants({ variant: 'outline' }))}>
Edit
</Link>
<Link href={`/dashboard/assets/${assetId}/assign`} className={cn(buttonVariants())}>
Assign
</Link>
</div>
</CardHeader>
<CardContent className='grid gap-4 md:grid-cols-2 xl:grid-cols-3'>
<Detail label='Asset Name' value={data.asset.assetName} />
<Detail label='Category' value={data.asset.assetCategory} />
<Detail label='Company' value={data.asset.companyName ?? 'N/A'} />
<Detail label='Type' value={data.asset.assetType} />
<Detail label='Asset Status' value={<Badge variant='outline'>{data.asset.status}</Badge>} />
<Detail
label='Asset Condition'
value={<Badge variant='secondary'>{data.asset.assetCondition}</Badge>}
/>
<Detail
label='Disposition / Process Status'
value={<Badge variant='secondary'>{data.asset.dispositionStatus}</Badge>}
/>
<Detail label='Current User' value={data.asset.currentEmployeeName ?? 'Unassigned'} />
<Detail label='Department' value={data.asset.departmentName ?? 'N/A'} />
<Detail label='Location' value={data.asset.locationName ?? 'N/A'} />
<Detail label='Serial Number' value={data.asset.serialNumber ?? 'N/A'} />
<Detail label='Warranty Expiry' value={data.asset.warrantyExpiryDate?.slice(0, 10) ?? 'N/A'} />
<Detail label='Custodian Team' value={data.asset.custodianTeam ?? 'N/A'} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Movement History</CardTitle>
</CardHeader>
<CardContent className='space-y-3'>
{data.movements.length ? (
data.movements.map((movement) => (
<div key={movement.id} className='rounded-lg border p-3'>
<div className='flex items-center justify-between gap-4'>
<div className='font-medium'>{movement.eventType}</div>
<div className='text-muted-foreground text-sm'>{movement.eventDate.slice(0, 10)}</div>
</div>
<div className='text-muted-foreground mt-1 text-sm'>
{movement.reason ?? 'No reason provided'}
</div>
</div>
))
) : (
<div className='text-muted-foreground text-sm'>No movements recorded yet.</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Repair History</CardTitle>
</CardHeader>
<CardContent className='space-y-3'>
{data.repairs.length ? (
data.repairs.map((repair) => (
<div key={repair.id} className='rounded-lg border p-3'>
<div className='flex items-center justify-between gap-4'>
<div className='font-medium'>{repair.vendor ?? 'Internal'}</div>
<div className='text-muted-foreground text-sm'>{repair.repairDate.slice(0, 10)}</div>
</div>
<div className='mt-1 text-sm'>{repair.problem}</div>
<div className='text-muted-foreground text-sm'>{repair.resolution ?? 'Pending resolution'}</div>
</div>
))
) : (
<div className='text-muted-foreground text-sm'>No repair records yet.</div>
)}
</CardContent>
</Card>
</div>
);
}
function Detail({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className='space-y-1'>
<div className='text-muted-foreground text-sm'>{label}</div>
<div className='font-medium'>{value}</div>
</div>
);
}

View File

@@ -1,221 +0,0 @@
'use client';
import { useSuspenseQuery, useMutation } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { useAppForm, useFormFields } from '@/components/ui/tanstack-form';
import { createAssetMutation, updateAssetMutation } from '../api/mutations';
import { assetOptionsQueryOptions } from '../api/queries';
import type { Asset } from '../api/types';
import { assetSchema, type AssetFormValues } from '../schemas/asset';
import {
assetConditionOptions,
assetStatusOptions,
assetTypeOptions,
dispositionStatusOptions
} from '../constants';
export default function AssetForm({
initialData,
pageTitle
}: {
initialData: Asset | null;
pageTitle: string;
}) {
const router = useRouter();
const isEdit = !!initialData;
const { data: options } = useSuspenseQuery(assetOptionsQueryOptions());
const createMutation = useMutation({
...createAssetMutation,
onSuccess: () => {
toast.success('Asset created successfully');
router.push('/dashboard/assets');
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to create asset')
});
const updateMutation = useMutation({
...updateAssetMutation,
onSuccess: () => {
toast.success('Asset updated successfully');
router.push('/dashboard/assets');
},
onError: (error) => toast.error(error instanceof Error ? error.message : 'Failed to update asset')
});
const form = useAppForm({
defaultValues: {
assetCode: initialData?.assetCode ?? '',
assetName: initialData?.assetName ?? '',
assetType: initialData?.assetType ?? 'hardware',
assetCategory: initialData?.assetCategory ?? '',
brand: initialData?.brand ?? '',
model: initialData?.model ?? '',
specification: initialData?.specification ?? '',
serialNumber: initialData?.serialNumber ?? '',
siteId: initialData?.siteId ?? '',
departmentId: initialData?.departmentId ?? '',
locationId: initialData?.locationId ?? '',
currentEmployeeId: initialData?.currentEmployeeId ?? '',
custodianTeam: initialData?.custodianTeam ?? '',
purchaseDate: initialData?.purchaseDate?.slice(0, 10) ?? '',
warrantyExpiryDate: initialData?.warrantyExpiryDate?.slice(0, 10) ?? '',
eosDate: initialData?.eosDate?.slice(0, 10) ?? '',
eolDate: initialData?.eolDate?.slice(0, 10) ?? '',
eopDate: initialData?.eopDate?.slice(0, 10) ?? '',
status: initialData?.status ?? 'AVAILABLE',
assetCondition: initialData?.assetCondition ?? 'NORMAL',
dispositionStatus: initialData?.dispositionStatus ?? 'NONE',
notes: initialData?.notes ?? ''
} as AssetFormValues,
validators: {
onSubmit: assetSchema
},
onSubmit: async ({ value }) => {
const payload = {
...value,
siteId: value.siteId || null,
departmentId: value.departmentId || null,
locationId: value.locationId || null,
currentEmployeeId: value.currentEmployeeId || null
};
if (isEdit) {
await updateMutation.mutateAsync({ id: initialData.id, values: payload });
} else {
await createMutation.mutateAsync(payload);
}
}
});
const { FormTextField, FormSelectField, FormTextareaField, FormDatePickerField } =
useFormFields<AssetFormValues>();
return (
<Card className='mx-auto w-full'>
<CardHeader>
<CardTitle className='text-left text-2xl font-bold'>{pageTitle}</CardTitle>
</CardHeader>
<CardContent>
<form.AppForm>
<form.Form className='space-y-6'>
<div className='grid grid-cols-1 gap-6 md:grid-cols-2'>
<FormTextField name='assetCode' label='Asset Code' required placeholder='NBK-001' />
<FormTextField
name='assetName'
label='Asset Name'
required
placeholder='Dell Latitude 5450'
/>
<FormSelectField
name='assetType'
label='Asset Type'
required
options={assetTypeOptions.map((option) => ({
value: option.value,
label: option.label
}))}
/>
<FormTextField
name='assetCategory'
label='Asset Category'
required
placeholder='Notebook'
/>
<FormSelectField
name='status'
label='Asset Status'
required
options={assetStatusOptions.map((option) => ({
value: option.value,
label: option.label
}))}
/>
<FormSelectField
name='assetCondition'
label='Asset Condition'
required
options={assetConditionOptions.map((option) => ({
value: option.value,
label: option.label
}))}
/>
<FormSelectField
name='dispositionStatus'
label='Disposition / Process Status'
required
options={dispositionStatusOptions.map((option) => ({
value: option.value,
label: option.label
}))}
/>
<FormTextField name='brand' label='Brand' placeholder='Dell' />
<FormTextField name='model' label='Model' placeholder='Latitude 5450' />
<FormTextField name='serialNumber' label='Serial Number' placeholder='SN-12345' />
<FormTextField name='custodianTeam' label='Custodian Team' placeholder='Helpdesk' />
<FormSelectField
name='siteId'
label='Site'
options={options.sites.map((item) => ({ value: item.id, label: `${item.code} - ${item.name}` }))}
placeholder='Select site'
/>
<FormSelectField
name='departmentId'
label='Department'
options={options.departments.map((item) => ({
value: item.id,
label: `${item.code} - ${item.name}`
}))}
placeholder='Select department'
/>
<FormSelectField
name='locationId'
label='Location'
options={options.locations.map((item) => ({
value: item.id,
label: item.name
}))}
placeholder='Select location'
/>
<FormSelectField
name='currentEmployeeId'
label='Current User'
options={options.employees.map((item) => ({
value: item.id,
label: `${item.employeeNo} - ${item.name}`
}))}
placeholder='Select employee'
/>
<FormDatePickerField name='purchaseDate' label='Purchase Date' />
<FormDatePickerField
name='warrantyExpiryDate'
label='Warranty Expiry'
/>
<FormDatePickerField name='eosDate' label='EOS Date' />
<FormDatePickerField name='eolDate' label='EOL Date' />
<FormDatePickerField name='eopDate' label='EOP Date' />
</div>
<FormTextareaField
name='specification'
label='Specification'
placeholder='CPU, RAM, storage or software notes'
rows={4}
/>
<FormTextareaField name='notes' label='Notes' placeholder='Additional notes' rows={4} />
<div className='flex justify-end gap-2'>
<Button type='button' variant='outline' onClick={() => router.back()}>
Back
</Button>
<form.SubmitButton>{isEdit ? 'Update Asset' : 'Create Asset'}</form.SubmitButton>
</div>
</form.Form>
</form.AppForm>
</CardContent>
</Card>
);
}

Some files were not shown because too many files have changed in this diff Show More