task-b complate
This commit is contained in:
21
CLAUDE.md
21
CLAUDE.md
@@ -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
|
|
||||||
426
docs/implementation/task-a-template-audit.md
Normal file
426
docs/implementation/task-a-template-audit.md
Normal 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
|
||||||
46
drizzle/0001_orange_mandarin.sql
Normal file
46
drizzle/0001_orange_mandarin.sql
Normal 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");
|
||||||
@@ -328,4 +328,4 @@
|
|||||||
"schemas": {},
|
"schemas": {},
|
||||||
"tables": {}
|
"tables": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
653
drizzle/meta/0001_snapshot.json
Normal file
653
drizzle/meta/0001_snapshot.json
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,13 @@
|
|||||||
"when": 1781148450992,
|
"when": 1781148450992,
|
||||||
"tag": "0000_icy_lizard",
|
"tag": "0000_icy_lizard",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1781171200406,
|
||||||
|
"tag": "0001_orange_mandarin",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
4
package-lock.json
generated
@@ -1,11 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "alla-itcenter",
|
"name": "alla-allaos-fullstack",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "alla-itcenter",
|
"name": "alla-allaos-fullstack",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
|
|||||||
@@ -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 ใช้งบประมาณเท่าไรในอนาคต
|
|
||||||
@@ -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 repo’s 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.
|
|
||||||
@@ -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
145
plans/task-a.md
Normal 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
362
plans/task-b.md
Normal 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
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
});
|
|
||||||
}
|
|
||||||
60
src/app/api/foundation/master-options/route.ts
Normal file
60
src/app/api/foundation/master-options/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,37 @@
|
|||||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
import { auth } from '@/auth';
|
||||||
import PageContainer from '@/components/layout/page-container';
|
import PageContainer from '@/components/layout/page-container';
|
||||||
import { crmReferenceQueryOptions } from '@/features/crm/api/queries';
|
import MasterOptionsListingPage from '@/features/foundation/master-options/components/master-options-listing';
|
||||||
import { MasterOptionsPage } from '@/features/crm/components/settings-page';
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { getQueryClient } from '@/lib/query-client';
|
import { searchParamsCache } from '@/lib/searchparams';
|
||||||
|
import type { SearchParams } from 'nuqs/server';
|
||||||
|
|
||||||
export default function MasterOptionsRoute() {
|
type PageProps = {
|
||||||
const queryClient = getQueryClient();
|
searchParams: Promise<SearchParams>;
|
||||||
void queryClient.prefetchQuery(crmReferenceQueryOptions());
|
};
|
||||||
|
|
||||||
|
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 (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle='Master Options'
|
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)}>
|
<MasterOptionsListingPage />
|
||||||
<MasterOptionsPage />
|
|
||||||
</HydrationBoundary>
|
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import IconsViewPage from '@/features/elements/components/icons-view-page';
|
import IconsViewPage from '@/features/elements/components/icons-view-page';
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Example Dashboard: Icons'
|
title: 'Dashboard : Icons'
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ExampleIconsPage() {
|
export default function page() {
|
||||||
return <IconsViewPage />;
|
return <IconsViewPage />;
|
||||||
}
|
}
|
||||||
@@ -19,8 +19,8 @@ export default async function ExclusivePage() {
|
|||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
<div className='mb-1 text-lg font-semibold'>Pro Plan Required</div>
|
<div className='mb-1 text-lg font-semibold'>Pro Plan Required</div>
|
||||||
<div className='text-muted-foreground'>
|
<div className='text-muted-foreground'>
|
||||||
This page is only available to workspaces on the <span className='font-semibold'>Pro</span>{' '}
|
This page is only available to workspaces on the{' '}
|
||||||
plan.
|
<span className='font-semibold'>Pro</span> plan.
|
||||||
<br />
|
<br />
|
||||||
Upgrade your workspace later through the app-owned{' '}
|
Upgrade your workspace later through the app-owned{' '}
|
||||||
<Link className='underline' href='/dashboard/billing'>
|
<Link className='underline' href='/dashboard/billing'>
|
||||||
@@ -1,26 +1,37 @@
|
|||||||
import type { ReactNode } from 'react';
|
import KBar from '@/components/kbar';
|
||||||
import AppSidebar from '@/components/layout/app-sidebar';
|
import AppSidebar from '@/components/layout/app-sidebar';
|
||||||
import Header from '@/components/layout/header';
|
import Header from '@/components/layout/header';
|
||||||
import { InfoSidebar } from '@/components/layout/info-sidebar';
|
import { InfoSidebar } from '@/components/layout/info-sidebar';
|
||||||
import KBar from '@/components/kbar';
|
|
||||||
import { InfobarProvider } from '@/components/ui/infobar';
|
import { InfobarProvider } from '@/components/ui/infobar';
|
||||||
import { SidebarInset, SidebarProvider } from '@/components/ui/sidebar';
|
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 (
|
return (
|
||||||
<KBar>
|
<KBar>
|
||||||
<InfobarProvider defaultOpen={false}>
|
<SidebarProvider defaultOpen={defaultOpen}>
|
||||||
<SidebarProvider defaultOpen>
|
<AppSidebar />
|
||||||
<AppSidebar />
|
<SidebarInset>
|
||||||
<SidebarInset>
|
<Header />
|
||||||
<Header />
|
<InfobarProvider defaultOpen={false}>
|
||||||
<div className='flex flex-1 overflow-hidden'>
|
{children}
|
||||||
<div className='flex min-w-0 flex-1 flex-col'>{children}</div>
|
<InfoSidebar side='right' />
|
||||||
<InfoSidebar side='right' />
|
</InfobarProvider>
|
||||||
</div>
|
</SidebarInset>
|
||||||
</SidebarInset>
|
</SidebarProvider>
|
||||||
</SidebarProvider>
|
|
||||||
</InfobarProvider>
|
|
||||||
</KBar>
|
</KBar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { redirect } from 'next/navigation';
|
import OverViewPage from '@/features/overview/components/overview';
|
||||||
|
|
||||||
export default function DashboardIndexPage() {
|
export default function DashboardPage() {
|
||||||
redirect('/dashboard/crm');
|
return <OverViewPage />;
|
||||||
}
|
}
|
||||||
|
|||||||
51
src/app/dashboard/product/page.tsx
Normal file
51
src/app/dashboard/product/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,23 +1,27 @@
|
|||||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
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 { getQueryClient } from '@/lib/query-client';
|
||||||
import { pokemonOptions } from '@/features/react-query-demo/api/queries';
|
import { pokemonOptions } from '@/features/react-query-demo/api/queries';
|
||||||
import { PokemonInfo } from '@/features/react-query-demo/components/pokemon-info';
|
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 { PokemonSkeleton } from '@/features/react-query-demo/components/pokemon-skeleton';
|
||||||
|
import { reactQueryInfoContent } from '@/features/react-query-demo/info-content';
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Example Dashboard: React Query'
|
title: 'Dashboard: React Query'
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ExampleReactQueryPage() {
|
export default function ReactQueryPage() {
|
||||||
const queryClient = getQueryClient();
|
const queryClient = getQueryClient();
|
||||||
|
|
||||||
|
// Prefetch on the server — data is ready before client JS loads
|
||||||
void queryClient.prefetchQuery(pokemonOptions(25));
|
void queryClient.prefetchQuery(pokemonOptions(25));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle='React Query'
|
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)}>
|
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||||
<Suspense fallback={<PokemonSkeleton />}>
|
<Suspense fallback={<PokemonSkeleton />}>
|
||||||
@@ -1,47 +1,44 @@
|
|||||||
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
|
import { auth } from '@/auth';
|
||||||
import PageContainer from '@/components/layout/page-container';
|
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 { searchParamsCache } from '@/lib/searchparams';
|
||||||
import type { SearchParams } from 'nuqs/server';
|
import type { SearchParams } from 'nuqs/server';
|
||||||
import { exampleUsersQueryOptions } from '@/features/example-dashboard/api';
|
import { usersInfoContent } from '@/features/users/info-content';
|
||||||
import { ExampleUserTable } from '@/features/example-dashboard/components/example-user-table';
|
import { UserFormSheetTrigger } from '@/features/users/components/user-form-sheet';
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: 'Dashboard: Users'
|
||||||
|
};
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
searchParams: Promise<SearchParams>;
|
searchParams: Promise<SearchParams>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const metadata = {
|
export default async function UsersPage(props: PageProps) {
|
||||||
title: 'Example Dashboard: Users'
|
|
||||||
};
|
|
||||||
|
|
||||||
export default async function ExampleUsersPage(props: PageProps) {
|
|
||||||
const searchParams = await props.searchParams;
|
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);
|
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 (
|
return (
|
||||||
<PageContainer
|
<PageContainer
|
||||||
pageTitle='Users'
|
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)}>
|
<UserListingPage />
|
||||||
<ExampleUserTable />
|
|
||||||
</HydrationBoundary>
|
|
||||||
</PageContainer>
|
</PageContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>;
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
import OverViewPage from '@/features/overview/components/overview';
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: 'Example Dashboard: Overview'
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function ExampleOverviewPage() {
|
|
||||||
return <OverViewPage />;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { redirect } from 'next/navigation';
|
|
||||||
|
|
||||||
export default function ExampleDashboardIndexPage() {
|
|
||||||
redirect('/example/dashboard/overview');
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -6,14 +6,13 @@ import ThemeProvider from '@/components/themes/theme-provider';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type { Metadata, Viewport } from 'next';
|
import type { Metadata, Viewport } from 'next';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import Script from 'next/script';
|
|
||||||
import NextTopLoader from 'nextjs-toploader';
|
import NextTopLoader from 'nextjs-toploader';
|
||||||
import { NuqsAdapter } from 'nuqs/adapters/next/app';
|
import { NuqsAdapter } from 'nuqs/adapters/next/app';
|
||||||
import '../styles/globals.css';
|
import '../styles/globals.css';
|
||||||
|
|
||||||
const META_THEME_COLORS = {
|
const META_THEME_COLORS = {
|
||||||
light: '#ffffff',
|
light: '#f9fbfa',
|
||||||
dark: '#09090b'
|
dark: '#001e2b'
|
||||||
};
|
};
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
@@ -34,16 +33,18 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
|||||||
return (
|
return (
|
||||||
<html lang='en' suppressHydrationWarning data-theme={themeToApply}>
|
<html lang='en' suppressHydrationWarning data-theme={themeToApply}>
|
||||||
<head>
|
<head>
|
||||||
<Script id='theme-color-init' strategy='beforeInteractive'>
|
<script
|
||||||
{`
|
dangerouslySetInnerHTML={{
|
||||||
try {
|
__html: `
|
||||||
// Keep the browser chrome color in sync before hydration.
|
try {
|
||||||
if (localStorage.theme === 'dark' || ((!('theme' in localStorage) || localStorage.theme === 'system') && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
// Set meta theme color
|
||||||
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', '${META_THEME_COLORS.dark}')
|
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 (_) {}
|
}
|
||||||
`}
|
} catch (_) {}
|
||||||
</Script>
|
`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { auth } from '@/auth';
|
||||||
|
import { redirect } from 'next/navigation';
|
||||||
|
|
||||||
export default function Page() {
|
export default async function Page() {
|
||||||
redirect("/dashboard");
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return redirect('/auth/sign-in');
|
||||||
|
} else {
|
||||||
|
redirect('/dashboard');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import { SetupWizard } from '@/features/setup/components/setup-wizard';
|
|
||||||
|
|
||||||
export const metadata = {
|
|
||||||
title: 'Setup Wizard'
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function SetupPage() {
|
|
||||||
return <SetupWizard />;
|
|
||||||
}
|
|
||||||
@@ -83,49 +83,38 @@ export default function AppSidebar() {
|
|||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{group.items.map((item) => {
|
{group.items.map((item) => {
|
||||||
const Icon = item.icon ? Icons[item.icon] : Icons.logo;
|
const Icon = item.icon ? Icons[item.icon] : Icons.logo;
|
||||||
const itemKey = item.url || item.title;
|
return item?.items && item?.items?.length > 0 ? (
|
||||||
const childItems = item.items?.filter((subItem) => Boolean(subItem.url)) ?? [];
|
<Collapsible
|
||||||
|
key={item.title}
|
||||||
if (childItems.length > 0) {
|
asChild
|
||||||
return (
|
defaultOpen={item.isActive}
|
||||||
<Collapsible
|
className='group/collapsible'
|
||||||
key={itemKey}
|
>
|
||||||
asChild
|
<SidebarMenuItem>
|
||||||
defaultOpen={item.isActive}
|
<CollapsibleTrigger asChild>
|
||||||
className='group/collapsible'
|
<SidebarMenuButton tooltip={item.title} isActive={pathname === item.url}>
|
||||||
>
|
{item.icon && <Icon />}
|
||||||
<SidebarMenuItem>
|
<span>{item.title}</span>
|
||||||
<CollapsibleTrigger asChild>
|
<Icons.chevronRight className='ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
|
||||||
<SidebarMenuButton tooltip={item.title} isActive={pathname === item.url}>
|
</SidebarMenuButton>
|
||||||
{item.icon && <Icon />}
|
</CollapsibleTrigger>
|
||||||
<span>{item.title}</span>
|
<CollapsibleContent>
|
||||||
<Icons.chevronRight className='ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90' />
|
<SidebarMenuSub>
|
||||||
</SidebarMenuButton>
|
{item.items?.map((subItem) => (
|
||||||
</CollapsibleTrigger>
|
<SidebarMenuSubItem key={subItem.title}>
|
||||||
<CollapsibleContent>
|
<SidebarMenuSubButton asChild isActive={pathname === subItem.url}>
|
||||||
<SidebarMenuSub>
|
<Link href={subItem.url}>
|
||||||
{childItems.map((subItem) => (
|
<span>{subItem.title}</span>
|
||||||
<SidebarMenuSubItem key={subItem.url || subItem.title}>
|
</Link>
|
||||||
<SidebarMenuSubButton asChild isActive={pathname === subItem.url}>
|
</SidebarMenuSubButton>
|
||||||
<Link href={subItem.url}>
|
</SidebarMenuSubItem>
|
||||||
<span>{subItem.title}</span>
|
))}
|
||||||
</Link>
|
</SidebarMenuSub>
|
||||||
</SidebarMenuSubButton>
|
</CollapsibleContent>
|
||||||
</SidebarMenuSubItem>
|
</SidebarMenuItem>
|
||||||
))}
|
</Collapsible>
|
||||||
</SidebarMenuSub>
|
) : (
|
||||||
</CollapsibleContent>
|
<SidebarMenuItem key={item.title}>
|
||||||
</SidebarMenuItem>
|
|
||||||
</Collapsible>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!item.url) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarMenuItem key={itemKey}>
|
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
asChild
|
asChild
|
||||||
tooltip={item.title}
|
tooltip={item.title}
|
||||||
|
|||||||
@@ -2,9 +2,13 @@
|
|||||||
* Default theme that loads when no user preference is set
|
* Default theme that loads when no user preference is set
|
||||||
* Change this value to set a different default theme
|
* Change this value to set a different default theme
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_THEME = 'vercel';
|
export const DEFAULT_THEME = 'mongodb';
|
||||||
|
|
||||||
export const THEMES = [
|
export const THEMES = [
|
||||||
|
{
|
||||||
|
name: 'MongoDB',
|
||||||
|
value: 'mongodb'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'Claude',
|
name: 'Claude',
|
||||||
value: 'claude'
|
value: 'claude'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NavGroup } from "@/types";
|
import { NavGroup } from '@/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Navigation configuration with RBAC support
|
* Navigation configuration with RBAC support
|
||||||
@@ -35,118 +35,157 @@ import { NavGroup } from "@/types";
|
|||||||
*/
|
*/
|
||||||
export const navGroups: NavGroup[] = [
|
export const navGroups: NavGroup[] = [
|
||||||
{
|
{
|
||||||
label: "Overview",
|
label: 'Overview',
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "Dashboard",
|
title: 'Dashboard',
|
||||||
url: "/dashboard",
|
url: '/dashboard',
|
||||||
icon: "dashboard",
|
icon: 'dashboard',
|
||||||
isActive: false,
|
isActive: false,
|
||||||
shortcut: ["d", "d"],
|
shortcut: ['d', 'd'],
|
||||||
items: [],
|
items: []
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Workspaces",
|
title: 'Workspaces',
|
||||||
url: "/dashboard/workspaces",
|
url: '/dashboard/workspaces',
|
||||||
icon: "workspace",
|
icon: 'workspace',
|
||||||
isActive: false,
|
isActive: false,
|
||||||
items: [],
|
items: [],
|
||||||
access: { systemRole: "super_admin" },
|
access: { systemRole: 'super_admin' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Teams",
|
title: 'Teams',
|
||||||
url: "/dashboard/workspaces/team",
|
url: '/dashboard/workspaces/team',
|
||||||
icon: "teams",
|
icon: 'teams',
|
||||||
isActive: false,
|
isActive: false,
|
||||||
items: [],
|
items: [],
|
||||||
access: { requireOrg: true, role: "admin" },
|
access: { requireOrg: true, role: 'admin' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Users",
|
title: 'Users',
|
||||||
url: "/dashboard/users",
|
url: '/dashboard/users',
|
||||||
icon: "teams",
|
icon: 'teams',
|
||||||
shortcut: ["u", "u"],
|
shortcut: ['u', 'u'],
|
||||||
isActive: false,
|
isActive: false,
|
||||||
items: [],
|
items: [],
|
||||||
access: { requireOrg: true, permission: "users:manage" },
|
access: { requireOrg: true, permission: 'users:manage' }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Kanban",
|
title: 'Kanban',
|
||||||
url: "/dashboard/kanban",
|
url: '/dashboard/kanban',
|
||||||
icon: "kanban",
|
icon: 'kanban',
|
||||||
shortcut: ["k", "k"],
|
shortcut: ['k', 'k'],
|
||||||
isActive: false,
|
isActive: false,
|
||||||
items: [],
|
items: []
|
||||||
},
|
}
|
||||||
],
|
// {
|
||||||
},
|
// title: "Chat",
|
||||||
{
|
// url: "/dashboard/chat",
|
||||||
label: "CRM",
|
// icon: "chat",
|
||||||
items: [
|
// shortcut: ["c", "c"],
|
||||||
{
|
// isActive: false,
|
||||||
title: "CRM Dashboard",
|
// items: [],
|
||||||
url: "/dashboard/crm",
|
// },
|
||||||
icon: "dashboard",
|
]
|
||||||
isActive: false,
|
}
|
||||||
items: [],
|
// {
|
||||||
access: { requireOrg: true },
|
// label: "Elements",
|
||||||
},
|
// items: [
|
||||||
{
|
// {
|
||||||
title: "Enquiries",
|
// title: "Forms",
|
||||||
url: "/dashboard/crm/enquiries",
|
// url: "#",
|
||||||
icon: "forms",
|
// icon: "forms",
|
||||||
isActive: false,
|
// isActive: true,
|
||||||
items: [],
|
// items: [
|
||||||
access: { requireOrg: true },
|
// {
|
||||||
},
|
// title: "Basic Form",
|
||||||
{
|
// url: "/dashboard/forms/basic",
|
||||||
title: "Customers",
|
// icon: "forms",
|
||||||
url: "/dashboard/crm/customers",
|
// shortcut: ["f", "f"],
|
||||||
icon: "teams",
|
// },
|
||||||
isActive: false,
|
// {
|
||||||
items: [],
|
// title: "Multi-Step Form",
|
||||||
access: { requireOrg: true },
|
// url: "/dashboard/forms/multi-step",
|
||||||
},
|
// icon: "forms",
|
||||||
{
|
// },
|
||||||
title: "Quotations",
|
// {
|
||||||
url: "/dashboard/crm/quotations",
|
// title: "Sheet & Dialog",
|
||||||
icon: "fileTypePdf",
|
// url: "/dashboard/forms/sheet-form",
|
||||||
isActive: false,
|
// icon: "forms",
|
||||||
items: [],
|
// },
|
||||||
access: { requireOrg: true },
|
// {
|
||||||
},
|
// title: "Advanced Patterns",
|
||||||
{
|
// url: "/dashboard/forms/advanced",
|
||||||
title: "Approvals",
|
// icon: "forms",
|
||||||
url: "/dashboard/crm/approvals",
|
// },
|
||||||
icon: "checks",
|
// ],
|
||||||
isActive: false,
|
// },
|
||||||
items: [],
|
// {
|
||||||
access: { requireOrg: true },
|
// title: "React Query",
|
||||||
},
|
// url: "/dashboard/react-query",
|
||||||
{
|
// icon: "code",
|
||||||
title: "CRM Settings",
|
// isActive: false,
|
||||||
url: "#",
|
// items: [],
|
||||||
icon: "settings",
|
// },
|
||||||
isActive: true,
|
// {
|
||||||
access: { requireOrg: true },
|
// title: "Icons",
|
||||||
items: [
|
// url: "/dashboard/elements/icons",
|
||||||
{
|
// icon: "palette",
|
||||||
title: "Master Options",
|
// isActive: false,
|
||||||
url: "/dashboard/crm/settings/master-options",
|
// items: [],
|
||||||
icon: "settings",
|
// },
|
||||||
},
|
// ],
|
||||||
{
|
// },
|
||||||
title: "Document Sequences",
|
// {
|
||||||
url: "/dashboard/crm/settings/document-sequences",
|
// label: "",
|
||||||
icon: "post",
|
// items: [
|
||||||
},
|
// {
|
||||||
{
|
// title: "Pro",
|
||||||
title: "Templates",
|
// url: "#",
|
||||||
url: "/dashboard/crm/settings/templates",
|
// icon: "pro",
|
||||||
icon: "page",
|
// 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",
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
|
// },
|
||||||
];
|
];
|
||||||
|
|||||||
168
src/db/schema.ts
168
src/db/schema.ts
@@ -1,84 +1,130 @@
|
|||||||
import {
|
import {
|
||||||
|
boolean,
|
||||||
doublePrecision,
|
doublePrecision,
|
||||||
integer,
|
integer,
|
||||||
jsonb,
|
jsonb,
|
||||||
numeric,
|
|
||||||
pgTable,
|
pgTable,
|
||||||
text,
|
text,
|
||||||
timestamp,
|
timestamp,
|
||||||
uniqueIndex,
|
uniqueIndex
|
||||||
} from "drizzle-orm/pg-core";
|
} from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
export const users = pgTable(
|
export const users = pgTable(
|
||||||
"users",
|
'users',
|
||||||
{
|
{
|
||||||
id: text("id").primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
name: text("name").notNull(),
|
name: text('name').notNull(),
|
||||||
email: text("email").notNull(),
|
email: text('email').notNull(),
|
||||||
passwordHash: text("password_hash").notNull(),
|
passwordHash: text('password_hash').notNull(),
|
||||||
systemRole: text("system_role").default("user").notNull(),
|
systemRole: text('system_role').default('user').notNull(),
|
||||||
image: text("image"),
|
image: text('image'),
|
||||||
activeOrganizationId: text("active_organization_id"),
|
activeOrganizationId: text('active_organization_id'),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
.defaultNow()
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
.notNull(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.defaultNow()
|
|
||||||
.notNull(),
|
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
emailIdx: uniqueIndex("users_email_idx").on(table.email),
|
emailIdx: uniqueIndex('users_email_idx').on(table.email)
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
export const organizations = pgTable(
|
export const organizations = pgTable(
|
||||||
"organizations",
|
'organizations',
|
||||||
{
|
{
|
||||||
id: text("id").primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
name: text("name").notNull(),
|
name: text('name').notNull(),
|
||||||
slug: text("slug").notNull(),
|
slug: text('slug').notNull(),
|
||||||
imageUrl: text("image_url"),
|
imageUrl: text('image_url'),
|
||||||
plan: text("plan").default("free").notNull(),
|
plan: text('plan').default('free').notNull(),
|
||||||
createdBy: text("created_by").notNull(),
|
createdBy: text('created_by').notNull(),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
.defaultNow()
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
.notNull(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.defaultNow()
|
|
||||||
.notNull(),
|
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
slugIdx: uniqueIndex("organizations_slug_idx").on(table.slug),
|
slugIdx: uniqueIndex('organizations_slug_idx').on(table.slug)
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
export const memberships = pgTable("memberships", {
|
export const memberships = pgTable('memberships', {
|
||||||
id: text("id").primaryKey(),
|
id: text('id').primaryKey(),
|
||||||
userId: text("user_id").notNull(),
|
userId: text('user_id').notNull(),
|
||||||
organizationId: text("organization_id").notNull(),
|
organizationId: text('organization_id').notNull(),
|
||||||
role: text("role").default("user").notNull(),
|
role: text('role').default('user').notNull(),
|
||||||
businessRole: text("business_role").default("viewer").notNull(),
|
businessRole: text('business_role').default('viewer').notNull(),
|
||||||
permissions: text("permissions").array().default([]).notNull(),
|
permissions: text('permissions').array().default([]).notNull(),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
.defaultNow()
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
.notNull(),
|
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.defaultNow()
|
|
||||||
.notNull(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const products = pgTable("products", {
|
export const products = pgTable('products', {
|
||||||
id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
|
id: integer('id').primaryKey().generatedAlwaysAsIdentity(),
|
||||||
organizationId: text("organization_id").notNull(),
|
organizationId: text('organization_id').notNull(),
|
||||||
name: text("name").notNull(),
|
name: text('name').notNull(),
|
||||||
category: text("category").notNull(),
|
category: text('category').notNull(),
|
||||||
description: text("description").notNull(),
|
description: text('description').notNull(),
|
||||||
photoUrl: text("photo_url").notNull(),
|
photoUrl: text('photo_url').notNull(),
|
||||||
price: doublePrecision("price").notNull(),
|
price: doublePrecision('price').notNull(),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||||
.defaultNow()
|
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull()
|
||||||
.notNull(),
|
});
|
||||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
||||||
.defaultNow()
|
export const msOptions = pgTable(
|
||||||
.notNull(),
|
'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()
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
|
||||||
});
|
|
||||||
@@ -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)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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[];
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user