Add top-level members list, primary member on account, member move, combined create flows
- GET /v1/members with search across all members (includes account name) - POST /members/:id/move with optional accountId (creates new account if omitted) - primary_member_id on account table, auto-set when first member added - isMinor flag on member create (manual override when no DOB provided) - Account search now includes member names - New account form includes primary contact fields, auto-generates name - Members page in sidebar with global search
This commit is contained in:
@@ -0,0 +1,323 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
|
||||||
|
// @ts-nocheck
|
||||||
|
|
||||||
|
// noinspection JSUnusedGlobalSymbols
|
||||||
|
|
||||||
|
// This file was automatically generated by TanStack Router.
|
||||||
|
// You should NOT make any changes in this file as it will be overwritten.
|
||||||
|
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||||
|
|
||||||
|
import { Route as rootRouteImport } from './routes/__root'
|
||||||
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
|
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
|
||||||
|
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
|
||||||
|
import { Route as AuthenticatedMembersRouteImport } from './routes/_authenticated/members'
|
||||||
|
import { Route as AuthenticatedAccountsIndexRouteImport } from './routes/_authenticated/accounts/index'
|
||||||
|
import { Route as AuthenticatedAccountsNewRouteImport } from './routes/_authenticated/accounts/new'
|
||||||
|
import { Route as AuthenticatedAccountsAccountIdRouteImport } from './routes/_authenticated/accounts/$accountId'
|
||||||
|
import { Route as AuthenticatedAccountsAccountIdIndexRouteImport } from './routes/_authenticated/accounts/$accountId/index'
|
||||||
|
import { Route as AuthenticatedAccountsAccountIdTaxExemptionsRouteImport } from './routes/_authenticated/accounts/$accountId/tax-exemptions'
|
||||||
|
import { Route as AuthenticatedAccountsAccountIdProcessorLinksRouteImport } from './routes/_authenticated/accounts/$accountId/processor-links'
|
||||||
|
import { Route as AuthenticatedAccountsAccountIdPaymentMethodsRouteImport } from './routes/_authenticated/accounts/$accountId/payment-methods'
|
||||||
|
import { Route as AuthenticatedAccountsAccountIdMembersRouteImport } from './routes/_authenticated/accounts/$accountId/members'
|
||||||
|
|
||||||
|
const LoginRoute = LoginRouteImport.update({
|
||||||
|
id: '/login',
|
||||||
|
path: '/login',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const AuthenticatedRoute = AuthenticatedRouteImport.update({
|
||||||
|
id: '/_authenticated',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
const AuthenticatedIndexRoute = AuthenticatedIndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthenticatedMembersRoute = AuthenticatedMembersRouteImport.update({
|
||||||
|
id: '/members',
|
||||||
|
path: '/members',
|
||||||
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthenticatedAccountsIndexRoute =
|
||||||
|
AuthenticatedAccountsIndexRouteImport.update({
|
||||||
|
id: '/accounts/',
|
||||||
|
path: '/accounts/',
|
||||||
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthenticatedAccountsNewRoute =
|
||||||
|
AuthenticatedAccountsNewRouteImport.update({
|
||||||
|
id: '/accounts/new',
|
||||||
|
path: '/accounts/new',
|
||||||
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthenticatedAccountsAccountIdRoute =
|
||||||
|
AuthenticatedAccountsAccountIdRouteImport.update({
|
||||||
|
id: '/accounts/$accountId',
|
||||||
|
path: '/accounts/$accountId',
|
||||||
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthenticatedAccountsAccountIdIndexRoute =
|
||||||
|
AuthenticatedAccountsAccountIdIndexRouteImport.update({
|
||||||
|
id: '/',
|
||||||
|
path: '/',
|
||||||
|
getParentRoute: () => AuthenticatedAccountsAccountIdRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthenticatedAccountsAccountIdTaxExemptionsRoute =
|
||||||
|
AuthenticatedAccountsAccountIdTaxExemptionsRouteImport.update({
|
||||||
|
id: '/tax-exemptions',
|
||||||
|
path: '/tax-exemptions',
|
||||||
|
getParentRoute: () => AuthenticatedAccountsAccountIdRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthenticatedAccountsAccountIdProcessorLinksRoute =
|
||||||
|
AuthenticatedAccountsAccountIdProcessorLinksRouteImport.update({
|
||||||
|
id: '/processor-links',
|
||||||
|
path: '/processor-links',
|
||||||
|
getParentRoute: () => AuthenticatedAccountsAccountIdRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthenticatedAccountsAccountIdPaymentMethodsRoute =
|
||||||
|
AuthenticatedAccountsAccountIdPaymentMethodsRouteImport.update({
|
||||||
|
id: '/payment-methods',
|
||||||
|
path: '/payment-methods',
|
||||||
|
getParentRoute: () => AuthenticatedAccountsAccountIdRoute,
|
||||||
|
} as any)
|
||||||
|
const AuthenticatedAccountsAccountIdMembersRoute =
|
||||||
|
AuthenticatedAccountsAccountIdMembersRouteImport.update({
|
||||||
|
id: '/members',
|
||||||
|
path: '/members',
|
||||||
|
getParentRoute: () => AuthenticatedAccountsAccountIdRoute,
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
export interface FileRoutesByFullPath {
|
||||||
|
'/': typeof AuthenticatedIndexRoute
|
||||||
|
'/login': typeof LoginRoute
|
||||||
|
'/members': typeof AuthenticatedMembersRoute
|
||||||
|
'/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren
|
||||||
|
'/accounts/new': typeof AuthenticatedAccountsNewRoute
|
||||||
|
'/accounts/': typeof AuthenticatedAccountsIndexRoute
|
||||||
|
'/accounts/$accountId/members': typeof AuthenticatedAccountsAccountIdMembersRoute
|
||||||
|
'/accounts/$accountId/payment-methods': typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute
|
||||||
|
'/accounts/$accountId/processor-links': typeof AuthenticatedAccountsAccountIdProcessorLinksRoute
|
||||||
|
'/accounts/$accountId/tax-exemptions': typeof AuthenticatedAccountsAccountIdTaxExemptionsRoute
|
||||||
|
'/accounts/$accountId/': typeof AuthenticatedAccountsAccountIdIndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesByTo {
|
||||||
|
'/login': typeof LoginRoute
|
||||||
|
'/members': typeof AuthenticatedMembersRoute
|
||||||
|
'/': typeof AuthenticatedIndexRoute
|
||||||
|
'/accounts/new': typeof AuthenticatedAccountsNewRoute
|
||||||
|
'/accounts': typeof AuthenticatedAccountsIndexRoute
|
||||||
|
'/accounts/$accountId/members': typeof AuthenticatedAccountsAccountIdMembersRoute
|
||||||
|
'/accounts/$accountId/payment-methods': typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute
|
||||||
|
'/accounts/$accountId/processor-links': typeof AuthenticatedAccountsAccountIdProcessorLinksRoute
|
||||||
|
'/accounts/$accountId/tax-exemptions': typeof AuthenticatedAccountsAccountIdTaxExemptionsRoute
|
||||||
|
'/accounts/$accountId': typeof AuthenticatedAccountsAccountIdIndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRoutesById {
|
||||||
|
__root__: typeof rootRouteImport
|
||||||
|
'/_authenticated': typeof AuthenticatedRouteWithChildren
|
||||||
|
'/login': typeof LoginRoute
|
||||||
|
'/_authenticated/members': typeof AuthenticatedMembersRoute
|
||||||
|
'/_authenticated/': typeof AuthenticatedIndexRoute
|
||||||
|
'/_authenticated/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren
|
||||||
|
'/_authenticated/accounts/new': typeof AuthenticatedAccountsNewRoute
|
||||||
|
'/_authenticated/accounts/': typeof AuthenticatedAccountsIndexRoute
|
||||||
|
'/_authenticated/accounts/$accountId/members': typeof AuthenticatedAccountsAccountIdMembersRoute
|
||||||
|
'/_authenticated/accounts/$accountId/payment-methods': typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute
|
||||||
|
'/_authenticated/accounts/$accountId/processor-links': typeof AuthenticatedAccountsAccountIdProcessorLinksRoute
|
||||||
|
'/_authenticated/accounts/$accountId/tax-exemptions': typeof AuthenticatedAccountsAccountIdTaxExemptionsRoute
|
||||||
|
'/_authenticated/accounts/$accountId/': typeof AuthenticatedAccountsAccountIdIndexRoute
|
||||||
|
}
|
||||||
|
export interface FileRouteTypes {
|
||||||
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
|
fullPaths:
|
||||||
|
| '/'
|
||||||
|
| '/login'
|
||||||
|
| '/members'
|
||||||
|
| '/accounts/$accountId'
|
||||||
|
| '/accounts/new'
|
||||||
|
| '/accounts/'
|
||||||
|
| '/accounts/$accountId/members'
|
||||||
|
| '/accounts/$accountId/payment-methods'
|
||||||
|
| '/accounts/$accountId/processor-links'
|
||||||
|
| '/accounts/$accountId/tax-exemptions'
|
||||||
|
| '/accounts/$accountId/'
|
||||||
|
fileRoutesByTo: FileRoutesByTo
|
||||||
|
to:
|
||||||
|
| '/login'
|
||||||
|
| '/members'
|
||||||
|
| '/'
|
||||||
|
| '/accounts/new'
|
||||||
|
| '/accounts'
|
||||||
|
| '/accounts/$accountId/members'
|
||||||
|
| '/accounts/$accountId/payment-methods'
|
||||||
|
| '/accounts/$accountId/processor-links'
|
||||||
|
| '/accounts/$accountId/tax-exemptions'
|
||||||
|
| '/accounts/$accountId'
|
||||||
|
id:
|
||||||
|
| '__root__'
|
||||||
|
| '/_authenticated'
|
||||||
|
| '/login'
|
||||||
|
| '/_authenticated/members'
|
||||||
|
| '/_authenticated/'
|
||||||
|
| '/_authenticated/accounts/$accountId'
|
||||||
|
| '/_authenticated/accounts/new'
|
||||||
|
| '/_authenticated/accounts/'
|
||||||
|
| '/_authenticated/accounts/$accountId/members'
|
||||||
|
| '/_authenticated/accounts/$accountId/payment-methods'
|
||||||
|
| '/_authenticated/accounts/$accountId/processor-links'
|
||||||
|
| '/_authenticated/accounts/$accountId/tax-exemptions'
|
||||||
|
| '/_authenticated/accounts/$accountId/'
|
||||||
|
fileRoutesById: FileRoutesById
|
||||||
|
}
|
||||||
|
export interface RootRouteChildren {
|
||||||
|
AuthenticatedRoute: typeof AuthenticatedRouteWithChildren
|
||||||
|
LoginRoute: typeof LoginRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@tanstack/react-router' {
|
||||||
|
interface FileRoutesByPath {
|
||||||
|
'/login': {
|
||||||
|
id: '/login'
|
||||||
|
path: '/login'
|
||||||
|
fullPath: '/login'
|
||||||
|
preLoaderRoute: typeof LoginRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/_authenticated': {
|
||||||
|
id: '/_authenticated'
|
||||||
|
path: ''
|
||||||
|
fullPath: '/'
|
||||||
|
preLoaderRoute: typeof AuthenticatedRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
|
'/_authenticated/': {
|
||||||
|
id: '/_authenticated/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/'
|
||||||
|
preLoaderRoute: typeof AuthenticatedIndexRouteImport
|
||||||
|
parentRoute: typeof AuthenticatedRoute
|
||||||
|
}
|
||||||
|
'/_authenticated/members': {
|
||||||
|
id: '/_authenticated/members'
|
||||||
|
path: '/members'
|
||||||
|
fullPath: '/members'
|
||||||
|
preLoaderRoute: typeof AuthenticatedMembersRouteImport
|
||||||
|
parentRoute: typeof AuthenticatedRoute
|
||||||
|
}
|
||||||
|
'/_authenticated/accounts/': {
|
||||||
|
id: '/_authenticated/accounts/'
|
||||||
|
path: '/accounts'
|
||||||
|
fullPath: '/accounts/'
|
||||||
|
preLoaderRoute: typeof AuthenticatedAccountsIndexRouteImport
|
||||||
|
parentRoute: typeof AuthenticatedRoute
|
||||||
|
}
|
||||||
|
'/_authenticated/accounts/new': {
|
||||||
|
id: '/_authenticated/accounts/new'
|
||||||
|
path: '/accounts/new'
|
||||||
|
fullPath: '/accounts/new'
|
||||||
|
preLoaderRoute: typeof AuthenticatedAccountsNewRouteImport
|
||||||
|
parentRoute: typeof AuthenticatedRoute
|
||||||
|
}
|
||||||
|
'/_authenticated/accounts/$accountId': {
|
||||||
|
id: '/_authenticated/accounts/$accountId'
|
||||||
|
path: '/accounts/$accountId'
|
||||||
|
fullPath: '/accounts/$accountId'
|
||||||
|
preLoaderRoute: typeof AuthenticatedAccountsAccountIdRouteImport
|
||||||
|
parentRoute: typeof AuthenticatedRoute
|
||||||
|
}
|
||||||
|
'/_authenticated/accounts/$accountId/': {
|
||||||
|
id: '/_authenticated/accounts/$accountId/'
|
||||||
|
path: '/'
|
||||||
|
fullPath: '/accounts/$accountId/'
|
||||||
|
preLoaderRoute: typeof AuthenticatedAccountsAccountIdIndexRouteImport
|
||||||
|
parentRoute: typeof AuthenticatedAccountsAccountIdRoute
|
||||||
|
}
|
||||||
|
'/_authenticated/accounts/$accountId/tax-exemptions': {
|
||||||
|
id: '/_authenticated/accounts/$accountId/tax-exemptions'
|
||||||
|
path: '/tax-exemptions'
|
||||||
|
fullPath: '/accounts/$accountId/tax-exemptions'
|
||||||
|
preLoaderRoute: typeof AuthenticatedAccountsAccountIdTaxExemptionsRouteImport
|
||||||
|
parentRoute: typeof AuthenticatedAccountsAccountIdRoute
|
||||||
|
}
|
||||||
|
'/_authenticated/accounts/$accountId/processor-links': {
|
||||||
|
id: '/_authenticated/accounts/$accountId/processor-links'
|
||||||
|
path: '/processor-links'
|
||||||
|
fullPath: '/accounts/$accountId/processor-links'
|
||||||
|
preLoaderRoute: typeof AuthenticatedAccountsAccountIdProcessorLinksRouteImport
|
||||||
|
parentRoute: typeof AuthenticatedAccountsAccountIdRoute
|
||||||
|
}
|
||||||
|
'/_authenticated/accounts/$accountId/payment-methods': {
|
||||||
|
id: '/_authenticated/accounts/$accountId/payment-methods'
|
||||||
|
path: '/payment-methods'
|
||||||
|
fullPath: '/accounts/$accountId/payment-methods'
|
||||||
|
preLoaderRoute: typeof AuthenticatedAccountsAccountIdPaymentMethodsRouteImport
|
||||||
|
parentRoute: typeof AuthenticatedAccountsAccountIdRoute
|
||||||
|
}
|
||||||
|
'/_authenticated/accounts/$accountId/members': {
|
||||||
|
id: '/_authenticated/accounts/$accountId/members'
|
||||||
|
path: '/members'
|
||||||
|
fullPath: '/accounts/$accountId/members'
|
||||||
|
preLoaderRoute: typeof AuthenticatedAccountsAccountIdMembersRouteImport
|
||||||
|
parentRoute: typeof AuthenticatedAccountsAccountIdRoute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthenticatedAccountsAccountIdRouteChildren {
|
||||||
|
AuthenticatedAccountsAccountIdMembersRoute: typeof AuthenticatedAccountsAccountIdMembersRoute
|
||||||
|
AuthenticatedAccountsAccountIdPaymentMethodsRoute: typeof AuthenticatedAccountsAccountIdPaymentMethodsRoute
|
||||||
|
AuthenticatedAccountsAccountIdProcessorLinksRoute: typeof AuthenticatedAccountsAccountIdProcessorLinksRoute
|
||||||
|
AuthenticatedAccountsAccountIdTaxExemptionsRoute: typeof AuthenticatedAccountsAccountIdTaxExemptionsRoute
|
||||||
|
AuthenticatedAccountsAccountIdIndexRoute: typeof AuthenticatedAccountsAccountIdIndexRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthenticatedAccountsAccountIdRouteChildren: AuthenticatedAccountsAccountIdRouteChildren =
|
||||||
|
{
|
||||||
|
AuthenticatedAccountsAccountIdMembersRoute:
|
||||||
|
AuthenticatedAccountsAccountIdMembersRoute,
|
||||||
|
AuthenticatedAccountsAccountIdPaymentMethodsRoute:
|
||||||
|
AuthenticatedAccountsAccountIdPaymentMethodsRoute,
|
||||||
|
AuthenticatedAccountsAccountIdProcessorLinksRoute:
|
||||||
|
AuthenticatedAccountsAccountIdProcessorLinksRoute,
|
||||||
|
AuthenticatedAccountsAccountIdTaxExemptionsRoute:
|
||||||
|
AuthenticatedAccountsAccountIdTaxExemptionsRoute,
|
||||||
|
AuthenticatedAccountsAccountIdIndexRoute:
|
||||||
|
AuthenticatedAccountsAccountIdIndexRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthenticatedAccountsAccountIdRouteWithChildren =
|
||||||
|
AuthenticatedAccountsAccountIdRoute._addFileChildren(
|
||||||
|
AuthenticatedAccountsAccountIdRouteChildren,
|
||||||
|
)
|
||||||
|
|
||||||
|
interface AuthenticatedRouteChildren {
|
||||||
|
AuthenticatedMembersRoute: typeof AuthenticatedMembersRoute
|
||||||
|
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
|
||||||
|
AuthenticatedAccountsAccountIdRoute: typeof AuthenticatedAccountsAccountIdRouteWithChildren
|
||||||
|
AuthenticatedAccountsNewRoute: typeof AuthenticatedAccountsNewRoute
|
||||||
|
AuthenticatedAccountsIndexRoute: typeof AuthenticatedAccountsIndexRoute
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
||||||
|
AuthenticatedMembersRoute: AuthenticatedMembersRoute,
|
||||||
|
AuthenticatedIndexRoute: AuthenticatedIndexRoute,
|
||||||
|
AuthenticatedAccountsAccountIdRoute:
|
||||||
|
AuthenticatedAccountsAccountIdRouteWithChildren,
|
||||||
|
AuthenticatedAccountsNewRoute: AuthenticatedAccountsNewRoute,
|
||||||
|
AuthenticatedAccountsIndexRoute: AuthenticatedAccountsIndexRoute,
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthenticatedRouteWithChildren = AuthenticatedRoute._addFileChildren(
|
||||||
|
AuthenticatedRouteChildren,
|
||||||
|
)
|
||||||
|
|
||||||
|
const rootRouteChildren: RootRouteChildren = {
|
||||||
|
AuthenticatedRoute: AuthenticatedRouteWithChildren,
|
||||||
|
LoginRoute: LoginRoute,
|
||||||
|
}
|
||||||
|
export const routeTree = rootRouteImport
|
||||||
|
._addFileChildren(rootRouteChildren)
|
||||||
|
._addFileTypes<FileRouteTypes>()
|
||||||
@@ -3,9 +3,15 @@ import { api } from '@/lib/api-client'
|
|||||||
import type { Member } from '@/types/account'
|
import type { Member } from '@/types/account'
|
||||||
import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas'
|
import type { PaginatedResponse, PaginationInput } from '@forte/shared/schemas'
|
||||||
|
|
||||||
|
interface MemberWithAccount extends Member {
|
||||||
|
accountName: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export const memberKeys = {
|
export const memberKeys = {
|
||||||
all: (accountId: string) => ['accounts', accountId, 'members'] as const,
|
all: (accountId: string) => ['accounts', accountId, 'members'] as const,
|
||||||
list: (accountId: string, params: PaginationInput) => [...memberKeys.all(accountId), params] as const,
|
list: (accountId: string, params: PaginationInput) => [...memberKeys.all(accountId), params] as const,
|
||||||
|
globalAll: ['members'] as const,
|
||||||
|
globalList: (params: PaginationInput) => ['members', 'list', params] as const,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function memberListOptions(accountId: string, params: PaginationInput) {
|
export function memberListOptions(accountId: string, params: PaginationInput) {
|
||||||
@@ -15,6 +21,13 @@ export function memberListOptions(accountId: string, params: PaginationInput) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function globalMemberListOptions(params: PaginationInput) {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: memberKeys.globalList(params),
|
||||||
|
queryFn: () => api.get<PaginatedResponse<MemberWithAccount>>('/v1/members', params),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const memberMutations = {
|
export const memberMutations = {
|
||||||
create: (accountId: string, data: Record<string, unknown>) =>
|
create: (accountId: string, data: Record<string, unknown>) =>
|
||||||
api.post<Member>(`/v1/accounts/${accountId}/members`, data),
|
api.post<Member>(`/v1/accounts/${accountId}/members`, data),
|
||||||
@@ -24,4 +37,9 @@ export const memberMutations = {
|
|||||||
|
|
||||||
delete: (id: string) =>
|
delete: (id: string) =>
|
||||||
api.del<Member>(`/v1/members/${id}`),
|
api.del<Member>(`/v1/members/${id}`),
|
||||||
|
|
||||||
|
move: (id: string, accountId?: string) =>
|
||||||
|
api.post<Member>(`/v1/members/${id}/move`, accountId ? { accountId } : {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type { MemberWithAccount }
|
||||||
|
|||||||
@@ -2,31 +2,26 @@
|
|||||||
|
|
||||||
@variant dark (&:is(.dark, .dark *));
|
@variant dark (&:is(.dark, .dark *));
|
||||||
|
|
||||||
@theme {
|
|
||||||
/* Defaults — overridden at runtime by theme store */
|
|
||||||
--color-background: hsl(210 20% 97%);
|
|
||||||
--color-foreground: hsl(222 47% 11%);
|
|
||||||
--color-card: hsl(0 0% 100%);
|
|
||||||
--color-card-foreground: hsl(222 47% 11%);
|
|
||||||
--color-popover: hsl(0 0% 100%);
|
|
||||||
--color-popover-foreground: hsl(222 47% 11%);
|
|
||||||
--color-primary: hsl(215 25% 27%);
|
|
||||||
--color-primary-foreground: hsl(210 40% 98%);
|
|
||||||
--color-secondary: hsl(210 40% 94%);
|
|
||||||
--color-secondary-foreground: hsl(222 47% 11%);
|
|
||||||
--color-muted: hsl(210 40% 94%);
|
|
||||||
--color-muted-foreground: hsl(215 16% 47%);
|
|
||||||
--color-accent: hsl(210 40% 94%);
|
|
||||||
--color-accent-foreground: hsl(222 47% 11%);
|
|
||||||
--color-destructive: hsl(0 72% 51%);
|
|
||||||
--color-destructive-foreground: hsl(210 40% 98%);
|
|
||||||
--color-border: hsl(214 32% 89%);
|
|
||||||
--color-input: hsl(214 32% 89%);
|
|
||||||
--color-ring: hsl(215 25% 27%);
|
|
||||||
--radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
--color-sidebar: var(--sidebar);
|
--color-sidebar: var(--sidebar);
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
@@ -35,9 +30,29 @@
|
|||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
--radius: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
--background: hsl(210 20% 97%);
|
||||||
|
--foreground: hsl(222 47% 11%);
|
||||||
|
--card: hsl(0 0% 100%);
|
||||||
|
--card-foreground: hsl(222 47% 11%);
|
||||||
|
--popover: hsl(0 0% 100%);
|
||||||
|
--popover-foreground: hsl(222 47% 11%);
|
||||||
|
--primary: hsl(215 25% 27%);
|
||||||
|
--primary-foreground: hsl(210 40% 98%);
|
||||||
|
--secondary: hsl(210 40% 94%);
|
||||||
|
--secondary-foreground: hsl(222 47% 11%);
|
||||||
|
--muted: hsl(210 40% 94%);
|
||||||
|
--muted-foreground: hsl(215 16% 47%);
|
||||||
|
--accent: hsl(210 40% 94%);
|
||||||
|
--accent-foreground: hsl(222 47% 11%);
|
||||||
|
--destructive: hsl(0 72% 51%);
|
||||||
|
--destructive-foreground: hsl(210 40% 98%);
|
||||||
|
--border: hsl(214 32% 89%);
|
||||||
|
--input: hsl(214 32% 89%);
|
||||||
|
--ring: hsl(215 25% 27%);
|
||||||
--sidebar: hsl(210 25% 95%);
|
--sidebar: hsl(210 25% 95%);
|
||||||
--sidebar-foreground: hsl(215 16% 47%);
|
--sidebar-foreground: hsl(215 16% 47%);
|
||||||
--sidebar-primary: hsl(215 25% 27%);
|
--sidebar-primary: hsl(215 25% 27%);
|
||||||
@@ -55,6 +70,8 @@ body {
|
|||||||
system-ui,
|
system-ui,
|
||||||
-apple-system,
|
-apple-system,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
|
background-color: var(--background);
|
||||||
|
color: var(--foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-input {
|
.login-input {
|
||||||
|
|||||||
@@ -1,29 +1,33 @@
|
|||||||
import { useForm } from 'react-hook-form'
|
import { useForm } from 'react-hook-form'
|
||||||
import { zodResolver } from '@hookform/resolvers/zod'
|
import { zodResolver } from '@hookform/resolvers/zod'
|
||||||
import { AccountCreateSchema } from '@forte/shared/schemas'
|
import { AccountCreateSchema } from '@forte/shared/schemas'
|
||||||
import type { AccountCreateInput } from '@forte/shared/schemas'
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
import type { Account } from '@/types/account'
|
import type { Account } from '@/types/account'
|
||||||
|
|
||||||
interface AccountFormProps {
|
interface AccountFormProps {
|
||||||
defaultValues?: Partial<Account>
|
defaultValues?: Partial<Account>
|
||||||
onSubmit: (data: AccountCreateInput) => void
|
onSubmit: (data: Record<string, unknown>) => void
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
|
includeFirstMember?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AccountForm({ defaultValues, onSubmit, loading }: AccountFormProps) {
|
export function AccountForm({ defaultValues, onSubmit, loading, includeFirstMember }: AccountFormProps) {
|
||||||
|
const optionalNameSchema = AccountCreateSchema.extend({ name: AccountCreateSchema.shape.name.optional() })
|
||||||
|
const schema = includeFirstMember ? optionalNameSchema : AccountCreateSchema
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
setValue,
|
setValue,
|
||||||
watch,
|
watch,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<AccountCreateInput>({
|
} = useForm({
|
||||||
resolver: zodResolver(AccountCreateSchema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: defaultValues?.name ?? '',
|
name: defaultValues?.name ?? '',
|
||||||
email: defaultValues?.email ?? undefined,
|
email: defaultValues?.email ?? undefined,
|
||||||
@@ -36,12 +40,31 @@ export function AccountForm({ defaultValues, onSubmit, loading }: AccountFormPro
|
|||||||
|
|
||||||
const billingMode = watch('billingMode')
|
const billingMode = watch('billingMode')
|
||||||
|
|
||||||
|
function handleFormSubmit(accountData: Record<string, unknown>) {
|
||||||
|
if (includeFirstMember) {
|
||||||
|
// Grab member fields from the native form
|
||||||
|
const form = document.getElementById('account-form') as HTMLFormElement
|
||||||
|
const formData = new FormData(form)
|
||||||
|
onSubmit({
|
||||||
|
...accountData,
|
||||||
|
memberFirstName: formData.get('memberFirstName') || undefined,
|
||||||
|
memberLastName: formData.get('memberLastName') || undefined,
|
||||||
|
memberEmail: formData.get('memberEmail') || undefined,
|
||||||
|
memberPhone: formData.get('memberPhone') || undefined,
|
||||||
|
memberDateOfBirth: formData.get('memberDateOfBirth') || undefined,
|
||||||
|
memberIsMinor: formData.get('memberIsMinor') === 'on' || undefined,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
onSubmit(accountData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 max-w-lg">
|
<form id="account-form" onSubmit={handleSubmit(handleFormSubmit)} className="space-y-4 max-w-lg">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="name">Name *</Label>
|
<Label htmlFor="name">Account Name {includeFirstMember ? '' : '*'}</Label>
|
||||||
<Input id="name" {...register('name')} />
|
<Input id="name" {...register('name')} placeholder={includeFirstMember ? 'Auto-generated from member name if blank' : 'e.g. Smith Family, Lincoln Elementary'} />
|
||||||
{errors.name && <p className="text-sm text-destructive">{errors.name.message}</p>}
|
{errors.name && !includeFirstMember && <p className="text-sm text-destructive">{String(errors.name.message)}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
@@ -88,6 +111,41 @@ export function AccountForm({ defaultValues, onSubmit, loading }: AccountFormPro
|
|||||||
<Textarea id="notes" {...register('notes')} />
|
<Textarea id="notes" {...register('notes')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{includeFirstMember && (
|
||||||
|
<>
|
||||||
|
<Separator />
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground uppercase tracking-wide">Primary Contact</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="memberFirstName">First Name</Label>
|
||||||
|
<Input id="memberFirstName" name="memberFirstName" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="memberLastName">Last Name</Label>
|
||||||
|
<Input id="memberLastName" name="memberLastName" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="memberEmail">Email</Label>
|
||||||
|
<Input id="memberEmail" name="memberEmail" type="email" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="memberDateOfBirth">Date of Birth</Label>
|
||||||
|
<Input id="memberDateOfBirth" name="memberDateOfBirth" type="date" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="memberPhone">Phone</Label>
|
||||||
|
<Input id="memberPhone" name="memberPhone" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input type="checkbox" id="memberIsMinor" name="memberIsMinor" className="rounded" />
|
||||||
|
<Label htmlFor="memberIsMinor" className="font-normal">This person is a minor</Label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Button type="submit" disabled={loading}>
|
<Button type="submit" disabled={loading}>
|
||||||
{loading ? 'Saving...' : defaultValues ? 'Update Account' : 'Create Account'}
|
{loading ? 'Saving...' : defaultValues ? 'Update Account' : 'Create Account'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export function MemberForm({ accountId, defaultValues, onSubmit, loading }: Memb
|
|||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
watch,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<MemberCreateInput>({
|
} = useForm<MemberCreateInput>({
|
||||||
resolver: zodResolver(MemberCreateSchema),
|
resolver: zodResolver(MemberCreateSchema),
|
||||||
@@ -27,12 +28,15 @@ export function MemberForm({ accountId, defaultValues, onSubmit, loading }: Memb
|
|||||||
firstName: defaultValues?.firstName ?? '',
|
firstName: defaultValues?.firstName ?? '',
|
||||||
lastName: defaultValues?.lastName ?? '',
|
lastName: defaultValues?.lastName ?? '',
|
||||||
dateOfBirth: defaultValues?.dateOfBirth ?? undefined,
|
dateOfBirth: defaultValues?.dateOfBirth ?? undefined,
|
||||||
|
isMinor: defaultValues?.isMinor ?? undefined,
|
||||||
email: defaultValues?.email ?? undefined,
|
email: defaultValues?.email ?? undefined,
|
||||||
phone: defaultValues?.phone ?? undefined,
|
phone: defaultValues?.phone ?? undefined,
|
||||||
notes: defaultValues?.notes ?? undefined,
|
notes: defaultValues?.notes ?? undefined,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const dateOfBirth = watch('dateOfBirth')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
<input type="hidden" {...register('accountId')} />
|
<input type="hidden" {...register('accountId')} />
|
||||||
@@ -48,20 +52,26 @@ export function MemberForm({ accountId, defaultValues, onSubmit, loading }: Memb
|
|||||||
{errors.lastName && <p className="text-sm text-destructive">{errors.lastName.message}</p>}
|
{errors.lastName && <p className="text-sm text-destructive">{errors.lastName.message}</p>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="memberEmail">Email</Label>
|
||||||
|
<Input id="memberEmail" type="email" {...register('email')} />
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="dateOfBirth">Date of Birth</Label>
|
<Label htmlFor="dateOfBirth">Date of Birth</Label>
|
||||||
<Input id="dateOfBirth" type="date" {...register('dateOfBirth')} />
|
<Input id="dateOfBirth" type="date" {...register('dateOfBirth')} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="memberEmail">Email</Label>
|
<Label htmlFor="memberPhone">Phone</Label>
|
||||||
<Input id="memberEmail" type="email" {...register('email')} />
|
<Input id="memberPhone" {...register('phone')} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
{!dateOfBirth && (
|
||||||
<Label htmlFor="memberPhone">Phone</Label>
|
<div className="flex items-center gap-2">
|
||||||
<Input id="memberPhone" {...register('phone')} />
|
<input type="checkbox" id="isMinor" {...register('isMinor')} className="rounded" />
|
||||||
</div>
|
<Label htmlFor="isMinor" className="font-normal">This person is a minor (under 18)</Label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="memberNotes">Notes</Label>
|
<Label htmlFor="memberNotes">Notes</Label>
|
||||||
<Textarea id="memberNotes" {...register('notes')} />
|
<Textarea id="memberNotes" {...register('notes')} />
|
||||||
|
|||||||
@@ -337,40 +337,20 @@ export const themes: ThemePreset[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const CSS_VAR_MAP: Record<keyof ThemeColors, string> = {
|
function camelToKebab(str: string): string {
|
||||||
background: '--color-background',
|
return str.replace(/[A-Z]/g, (m) => '-' + m.toLowerCase())
|
||||||
foreground: '--color-foreground',
|
|
||||||
card: '--color-card',
|
|
||||||
cardForeground: '--color-card-foreground',
|
|
||||||
popover: '--color-popover',
|
|
||||||
popoverForeground: '--color-popover-foreground',
|
|
||||||
primary: '--color-primary',
|
|
||||||
primaryForeground: '--color-primary-foreground',
|
|
||||||
secondary: '--color-secondary',
|
|
||||||
secondaryForeground: '--color-secondary-foreground',
|
|
||||||
muted: '--color-muted',
|
|
||||||
mutedForeground: '--color-muted-foreground',
|
|
||||||
accent: '--color-accent',
|
|
||||||
accentForeground: '--color-accent-foreground',
|
|
||||||
destructive: '--color-destructive',
|
|
||||||
destructiveForeground: '--color-destructive-foreground',
|
|
||||||
border: '--color-border',
|
|
||||||
input: '--color-input',
|
|
||||||
ring: '--color-ring',
|
|
||||||
sidebar: '--sidebar',
|
|
||||||
sidebarForeground: '--sidebar-foreground',
|
|
||||||
sidebarPrimary: '--sidebar-primary',
|
|
||||||
sidebarPrimaryForeground: '--sidebar-primary-foreground',
|
|
||||||
sidebarAccent: '--sidebar-accent',
|
|
||||||
sidebarAccentForeground: '--sidebar-accent-foreground',
|
|
||||||
sidebarBorder: '--sidebar-border',
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyThemeColors(colors: ThemeColors) {
|
export function applyThemeColors(colors: ThemeColors) {
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
for (const [key, cssVar] of Object.entries(CSS_VAR_MAP)) {
|
for (const [key, value] of Object.entries(colors)) {
|
||||||
const value = colors[key as keyof ThemeColors]
|
const prop = `--${camelToKebab(key)}`
|
||||||
root.style.setProperty(cssVar, `hsl(${value})`)
|
const hsl = `hsl(${value})`
|
||||||
|
root.style.setProperty(prop, hsl)
|
||||||
|
}
|
||||||
|
// Also set on body for portaled components (dialogs, dropdowns, popovers)
|
||||||
|
for (const [key, value] of Object.entries(colors)) {
|
||||||
|
document.body.style.setProperty(`--${camelToKebab(key)}`, `hsl(${value})`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root'
|
|||||||
import { Route as LoginRouteImport } from './routes/login'
|
import { Route as LoginRouteImport } from './routes/login'
|
||||||
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
|
import { Route as AuthenticatedRouteImport } from './routes/_authenticated'
|
||||||
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
|
import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index'
|
||||||
|
import { Route as AuthenticatedMembersRouteImport } from './routes/_authenticated/members'
|
||||||
import { Route as AuthenticatedAccountsIndexRouteImport } from './routes/_authenticated/accounts/index'
|
import { Route as AuthenticatedAccountsIndexRouteImport } from './routes/_authenticated/accounts/index'
|
||||||
import { Route as AuthenticatedAccountsNewRouteImport } from './routes/_authenticated/accounts/new'
|
import { Route as AuthenticatedAccountsNewRouteImport } from './routes/_authenticated/accounts/new'
|
||||||
import { Route as AuthenticatedAccountsAccountIdRouteImport } from './routes/_authenticated/accounts/$accountId'
|
import { Route as AuthenticatedAccountsAccountIdRouteImport } from './routes/_authenticated/accounts/$accountId'
|
||||||
@@ -35,6 +36,11 @@ const AuthenticatedIndexRoute = AuthenticatedIndexRouteImport.update({
|
|||||||
path: '/',
|
path: '/',
|
||||||
getParentRoute: () => AuthenticatedRoute,
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthenticatedMembersRoute = AuthenticatedMembersRouteImport.update({
|
||||||
|
id: '/members',
|
||||||
|
path: '/members',
|
||||||
|
getParentRoute: () => AuthenticatedRoute,
|
||||||
|
} as any)
|
||||||
const AuthenticatedAccountsIndexRoute =
|
const AuthenticatedAccountsIndexRoute =
|
||||||
AuthenticatedAccountsIndexRouteImport.update({
|
AuthenticatedAccountsIndexRouteImport.update({
|
||||||
id: '/accounts/',
|
id: '/accounts/',
|
||||||
@@ -87,6 +93,7 @@ const AuthenticatedAccountsAccountIdMembersRoute =
|
|||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof AuthenticatedIndexRoute
|
'/': typeof AuthenticatedIndexRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
|
'/members': typeof AuthenticatedMembersRoute
|
||||||
'/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren
|
'/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren
|
||||||
'/accounts/new': typeof AuthenticatedAccountsNewRoute
|
'/accounts/new': typeof AuthenticatedAccountsNewRoute
|
||||||
'/accounts/': typeof AuthenticatedAccountsIndexRoute
|
'/accounts/': typeof AuthenticatedAccountsIndexRoute
|
||||||
@@ -98,6 +105,7 @@ export interface FileRoutesByFullPath {
|
|||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
|
'/members': typeof AuthenticatedMembersRoute
|
||||||
'/': typeof AuthenticatedIndexRoute
|
'/': typeof AuthenticatedIndexRoute
|
||||||
'/accounts/new': typeof AuthenticatedAccountsNewRoute
|
'/accounts/new': typeof AuthenticatedAccountsNewRoute
|
||||||
'/accounts': typeof AuthenticatedAccountsIndexRoute
|
'/accounts': typeof AuthenticatedAccountsIndexRoute
|
||||||
@@ -111,6 +119,7 @@ export interface FileRoutesById {
|
|||||||
__root__: typeof rootRouteImport
|
__root__: typeof rootRouteImport
|
||||||
'/_authenticated': typeof AuthenticatedRouteWithChildren
|
'/_authenticated': typeof AuthenticatedRouteWithChildren
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
|
'/_authenticated/members': typeof AuthenticatedMembersRoute
|
||||||
'/_authenticated/': typeof AuthenticatedIndexRoute
|
'/_authenticated/': typeof AuthenticatedIndexRoute
|
||||||
'/_authenticated/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren
|
'/_authenticated/accounts/$accountId': typeof AuthenticatedAccountsAccountIdRouteWithChildren
|
||||||
'/_authenticated/accounts/new': typeof AuthenticatedAccountsNewRoute
|
'/_authenticated/accounts/new': typeof AuthenticatedAccountsNewRoute
|
||||||
@@ -126,6 +135,7 @@ export interface FileRouteTypes {
|
|||||||
fullPaths:
|
fullPaths:
|
||||||
| '/'
|
| '/'
|
||||||
| '/login'
|
| '/login'
|
||||||
|
| '/members'
|
||||||
| '/accounts/$accountId'
|
| '/accounts/$accountId'
|
||||||
| '/accounts/new'
|
| '/accounts/new'
|
||||||
| '/accounts/'
|
| '/accounts/'
|
||||||
@@ -137,6 +147,7 @@ export interface FileRouteTypes {
|
|||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to:
|
to:
|
||||||
| '/login'
|
| '/login'
|
||||||
|
| '/members'
|
||||||
| '/'
|
| '/'
|
||||||
| '/accounts/new'
|
| '/accounts/new'
|
||||||
| '/accounts'
|
| '/accounts'
|
||||||
@@ -149,6 +160,7 @@ export interface FileRouteTypes {
|
|||||||
| '__root__'
|
| '__root__'
|
||||||
| '/_authenticated'
|
| '/_authenticated'
|
||||||
| '/login'
|
| '/login'
|
||||||
|
| '/_authenticated/members'
|
||||||
| '/_authenticated/'
|
| '/_authenticated/'
|
||||||
| '/_authenticated/accounts/$accountId'
|
| '/_authenticated/accounts/$accountId'
|
||||||
| '/_authenticated/accounts/new'
|
| '/_authenticated/accounts/new'
|
||||||
@@ -188,6 +200,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthenticatedIndexRouteImport
|
preLoaderRoute: typeof AuthenticatedIndexRouteImport
|
||||||
parentRoute: typeof AuthenticatedRoute
|
parentRoute: typeof AuthenticatedRoute
|
||||||
}
|
}
|
||||||
|
'/_authenticated/members': {
|
||||||
|
id: '/_authenticated/members'
|
||||||
|
path: '/members'
|
||||||
|
fullPath: '/members'
|
||||||
|
preLoaderRoute: typeof AuthenticatedMembersRouteImport
|
||||||
|
parentRoute: typeof AuthenticatedRoute
|
||||||
|
}
|
||||||
'/_authenticated/accounts/': {
|
'/_authenticated/accounts/': {
|
||||||
id: '/_authenticated/accounts/'
|
id: '/_authenticated/accounts/'
|
||||||
path: '/accounts'
|
path: '/accounts'
|
||||||
@@ -275,6 +294,7 @@ const AuthenticatedAccountsAccountIdRouteWithChildren =
|
|||||||
)
|
)
|
||||||
|
|
||||||
interface AuthenticatedRouteChildren {
|
interface AuthenticatedRouteChildren {
|
||||||
|
AuthenticatedMembersRoute: typeof AuthenticatedMembersRoute
|
||||||
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
|
AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute
|
||||||
AuthenticatedAccountsAccountIdRoute: typeof AuthenticatedAccountsAccountIdRouteWithChildren
|
AuthenticatedAccountsAccountIdRoute: typeof AuthenticatedAccountsAccountIdRouteWithChildren
|
||||||
AuthenticatedAccountsNewRoute: typeof AuthenticatedAccountsNewRoute
|
AuthenticatedAccountsNewRoute: typeof AuthenticatedAccountsNewRoute
|
||||||
@@ -282,6 +302,7 @@ interface AuthenticatedRouteChildren {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
const AuthenticatedRouteChildren: AuthenticatedRouteChildren = {
|
||||||
|
AuthenticatedMembersRoute: AuthenticatedMembersRoute,
|
||||||
AuthenticatedIndexRoute: AuthenticatedIndexRoute,
|
AuthenticatedIndexRoute: AuthenticatedIndexRoute,
|
||||||
AuthenticatedAccountsAccountIdRoute:
|
AuthenticatedAccountsAccountIdRoute:
|
||||||
AuthenticatedAccountsAccountIdRouteWithChildren,
|
AuthenticatedAccountsAccountIdRouteWithChildren,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { Users, Sun, Moon, Monitor, LogOut, User, Palette } from 'lucide-react'
|
import { Users, UserRound, Sun, Moon, Monitor, LogOut, User, Palette } from 'lucide-react'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_authenticated')({
|
export const Route = createFileRoute('/_authenticated')({
|
||||||
beforeLoad: () => {
|
beforeLoad: () => {
|
||||||
@@ -61,6 +61,14 @@ function AuthenticatedLayout() {
|
|||||||
<Users className="h-4 w-4" />
|
<Users className="h-4 w-4" />
|
||||||
Accounts
|
Accounts
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/members"
|
||||||
|
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm text-sidebar-foreground hover:bg-sidebar-accent"
|
||||||
|
activeProps={{ className: 'flex items-center gap-2 px-3 py-2 rounded-md text-sm bg-sidebar-accent text-sidebar-accent-foreground' }}
|
||||||
|
>
|
||||||
|
<UserRound className="h-4 w-4" />
|
||||||
|
Members
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-3 border-t border-sidebar-border">
|
<div className="p-3 border-t border-sidebar-border">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { accountMutations, accountKeys } from '@/api/accounts'
|
import { accountMutations, accountKeys } from '@/api/accounts'
|
||||||
|
import { memberMutations } from '@/api/members'
|
||||||
import { AccountForm } from '@/components/accounts/account-form'
|
import { AccountForm } from '@/components/accounts/account-form'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
@@ -13,7 +14,39 @@ function NewAccountPage() {
|
|||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: accountMutations.create,
|
mutationFn: async (data: Record<string, unknown>) => {
|
||||||
|
// Extract member fields
|
||||||
|
const memberFirstName = data.memberFirstName as string | undefined
|
||||||
|
const memberLastName = data.memberLastName as string | undefined
|
||||||
|
const memberEmail = data.memberEmail as string | undefined
|
||||||
|
const memberPhone = data.memberPhone as string | undefined
|
||||||
|
const memberDateOfBirth = data.memberDateOfBirth as string | undefined
|
||||||
|
const memberIsMinor = data.memberIsMinor as boolean | undefined
|
||||||
|
|
||||||
|
// Create account (without member fields)
|
||||||
|
const { memberFirstName: _, memberLastName: __, memberEmail: ___, memberPhone: ____, memberDateOfBirth: _____, memberIsMinor: ______, ...accountData } = data
|
||||||
|
|
||||||
|
// Auto-generate account name from member if not provided
|
||||||
|
if (!accountData.name && memberFirstName && memberLastName) {
|
||||||
|
accountData.name = `${memberLastName}, ${memberFirstName}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const account = await accountMutations.create(accountData)
|
||||||
|
|
||||||
|
// Create first member if name provided
|
||||||
|
if (memberFirstName && memberLastName) {
|
||||||
|
await memberMutations.create(account.id, {
|
||||||
|
firstName: memberFirstName,
|
||||||
|
lastName: memberLastName,
|
||||||
|
email: memberEmail || undefined,
|
||||||
|
phone: memberPhone || undefined,
|
||||||
|
dateOfBirth: memberDateOfBirth || undefined,
|
||||||
|
isMinor: memberIsMinor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return account
|
||||||
|
},
|
||||||
onSuccess: (account) => {
|
onSuccess: (account) => {
|
||||||
queryClient.invalidateQueries({ queryKey: accountKeys.lists() })
|
queryClient.invalidateQueries({ queryKey: accountKeys.lists() })
|
||||||
toast.success('Account created')
|
toast.success('Account created')
|
||||||
@@ -27,7 +60,7 @@ function NewAccountPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="text-2xl font-bold">New Account</h1>
|
<h1 className="text-2xl font-bold">New Account</h1>
|
||||||
<AccountForm onSubmit={mutation.mutate} loading={mutation.isPending} />
|
<AccountForm onSubmit={mutation.mutate} loading={mutation.isPending} includeFirstMember />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
101
packages/admin/src/routes/_authenticated/members.tsx
Normal file
101
packages/admin/src/routes/_authenticated/members.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { createFileRoute, useNavigate } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { globalMemberListOptions, type MemberWithAccount } from '@/api/members'
|
||||||
|
import { usePagination } from '@/hooks/use-pagination'
|
||||||
|
import { DataTable, type Column } from '@/components/shared/data-table'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authenticated/members')({
|
||||||
|
validateSearch: (search: Record<string, unknown>) => ({
|
||||||
|
page: Number(search.page) || 1,
|
||||||
|
limit: Number(search.limit) || 25,
|
||||||
|
q: (search.q as string) || undefined,
|
||||||
|
sort: (search.sort as string) || undefined,
|
||||||
|
order: (search.order as 'asc' | 'desc') || 'asc',
|
||||||
|
}),
|
||||||
|
component: MembersListPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
const memberColumns: Column<MemberWithAccount>[] = [
|
||||||
|
{
|
||||||
|
key: 'last_name',
|
||||||
|
header: 'Name',
|
||||||
|
sortable: true,
|
||||||
|
render: (row) => <span className="font-medium">{row.firstName} {row.lastName}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'email',
|
||||||
|
header: 'Email',
|
||||||
|
sortable: true,
|
||||||
|
render: (row) => row.email ?? <span className="text-muted-foreground">-</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'phone',
|
||||||
|
header: 'Phone',
|
||||||
|
render: (row) => row.phone ?? <span className="text-muted-foreground">-</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'accountName',
|
||||||
|
header: 'Account',
|
||||||
|
render: (row) => row.accountName ?? <span className="text-muted-foreground">-</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
header: 'Status',
|
||||||
|
render: (row) => row.isMinor ? <Badge variant="secondary">Minor</Badge> : <Badge>Adult</Badge>,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
function MembersListPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { params, setPage, setSearch, setSort } = usePagination()
|
||||||
|
const [searchInput, setSearchInput] = useState(params.q ?? '')
|
||||||
|
|
||||||
|
const { data, isLoading } = useQuery(globalMemberListOptions(params))
|
||||||
|
|
||||||
|
function handleSearchSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setSearch(searchInput)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRowClick(member: MemberWithAccount) {
|
||||||
|
navigate({ to: '/accounts/$accountId/members', params: { accountId: member.accountId } })
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold">Members</h1>
|
||||||
|
|
||||||
|
<form onSubmit={handleSearchSubmit} className="flex gap-2 max-w-sm">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search members..."
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
className="pl-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" variant="secondary">Search</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
columns={memberColumns}
|
||||||
|
data={data?.data ?? []}
|
||||||
|
loading={isLoading}
|
||||||
|
page={params.page}
|
||||||
|
totalPages={data?.pagination.totalPages ?? 1}
|
||||||
|
total={data?.pagination.total ?? 0}
|
||||||
|
sort={params.sort}
|
||||||
|
order={params.order}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onSort={setSort}
|
||||||
|
onRowClick={handleRowClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- Add primary_member_id to account table
|
||||||
|
ALTER TABLE "account" ADD COLUMN "primary_member_id" uuid;
|
||||||
@@ -57,6 +57,13 @@
|
|||||||
"when": 1774662300000,
|
"when": 1774662300000,
|
||||||
"tag": "0007_accounts_lookups",
|
"tag": "0007_accounts_lookups",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 8,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1774702800000,
|
||||||
|
"tag": "0008_member_primary_account",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -31,6 +31,7 @@ export const accounts = pgTable('account', {
|
|||||||
zip?: string
|
zip?: string
|
||||||
}>(),
|
}>(),
|
||||||
billingMode: billingModeEnum('billing_mode').notNull().default('consolidated'),
|
billingMode: billingModeEnum('billing_mode').notNull().default('consolidated'),
|
||||||
|
primaryMemberId: uuid('primary_member_id'),
|
||||||
notes: text('notes'),
|
notes: text('notes'),
|
||||||
isActive: boolean('is_active').notNull().default(true),
|
isActive: boolean('is_active').notNull().default(true),
|
||||||
legacyId: varchar('legacy_id', { length: 255 }),
|
legacyId: varchar('legacy_id', { length: 255 }),
|
||||||
|
|||||||
@@ -63,7 +63,15 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
return reply.send(account)
|
return reply.send(account)
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- Members ---
|
// --- Members (top-level) ---
|
||||||
|
|
||||||
|
app.get('/members', { preHandler: [app.authenticate] }, async (request, reply) => {
|
||||||
|
const params = PaginationSchema.parse(request.query)
|
||||||
|
const result = await MemberService.list(app.db, request.companyId, params)
|
||||||
|
return reply.send(result)
|
||||||
|
})
|
||||||
|
|
||||||
|
// --- Members (scoped to account) ---
|
||||||
|
|
||||||
app.post('/accounts/:accountId/members', { preHandler: [app.authenticate] }, async (request, reply) => {
|
app.post('/accounts/:accountId/members', { preHandler: [app.authenticate] }, async (request, reply) => {
|
||||||
const { accountId } = request.params as { accountId: string }
|
const { accountId } = request.params as { accountId: string }
|
||||||
@@ -100,6 +108,27 @@ export const accountRoutes: FastifyPluginAsync = async (app) => {
|
|||||||
return reply.send(member)
|
return reply.send(member)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.post('/members/:id/move', { preHandler: [app.authenticate] }, async (request, reply) => {
|
||||||
|
const { id } = request.params as { id: string }
|
||||||
|
const { accountId } = (request.body as { accountId?: string }) ?? {}
|
||||||
|
|
||||||
|
let targetAccountId = accountId
|
||||||
|
|
||||||
|
// If no accountId provided, create a new account from the member's name
|
||||||
|
if (!targetAccountId) {
|
||||||
|
const member = await MemberService.getById(app.db, request.companyId, id)
|
||||||
|
if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } })
|
||||||
|
const account = await AccountService.create(app.db, request.companyId, {
|
||||||
|
name: `${member.firstName} ${member.lastName}`,
|
||||||
|
})
|
||||||
|
targetAccountId = account.id
|
||||||
|
}
|
||||||
|
|
||||||
|
const member = await MemberService.move(app.db, request.companyId, id, targetAccountId)
|
||||||
|
if (!member) return reply.status(404).send({ error: { message: 'Member not found', statusCode: 404 } })
|
||||||
|
return reply.send(member)
|
||||||
|
})
|
||||||
|
|
||||||
app.delete('/members/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
|
app.delete('/members/:id', { preHandler: [app.authenticate] }, async (request, reply) => {
|
||||||
const { id } = request.params as { id: string }
|
const { id } = request.params as { id: string }
|
||||||
const member = await MemberService.delete(app.db, request.companyId, id)
|
const member = await MemberService.delete(app.db, request.companyId, id)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { eq, and, sql, count } from 'drizzle-orm'
|
import { eq, and, sql, count, exists } from 'drizzle-orm'
|
||||||
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js'
|
||||||
import {
|
import {
|
||||||
accounts,
|
accounts,
|
||||||
@@ -77,10 +77,26 @@ export const AccountService = {
|
|||||||
async list(db: PostgresJsDatabase, companyId: string, params: PaginationInput) {
|
async list(db: PostgresJsDatabase, companyId: string, params: PaginationInput) {
|
||||||
const baseWhere = and(eq(accounts.companyId, companyId), eq(accounts.isActive, true))
|
const baseWhere = and(eq(accounts.companyId, companyId), eq(accounts.isActive, true))
|
||||||
|
|
||||||
const searchCondition = params.q
|
const accountSearch = params.q
|
||||||
? buildSearchCondition(params.q, [accounts.name, accounts.email, accounts.phone, accounts.accountNumber])
|
? buildSearchCondition(params.q, [accounts.name, accounts.email, accounts.phone, accounts.accountNumber])
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
|
// Also search across member names on this account
|
||||||
|
const memberSearch = params.q
|
||||||
|
? exists(
|
||||||
|
db.select({ id: members.id })
|
||||||
|
.from(members)
|
||||||
|
.where(and(
|
||||||
|
eq(members.accountId, accounts.id),
|
||||||
|
buildSearchCondition(params.q, [members.firstName, members.lastName, members.email])!,
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const searchCondition = accountSearch && memberSearch
|
||||||
|
? sql`(${accountSearch} OR ${memberSearch})`
|
||||||
|
: undefined
|
||||||
|
|
||||||
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
|
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
|
||||||
|
|
||||||
const sortableColumns: Record<string, typeof accounts.name> = {
|
const sortableColumns: Record<string, typeof accounts.name> = {
|
||||||
@@ -112,12 +128,15 @@ export const MemberService = {
|
|||||||
firstName: string
|
firstName: string
|
||||||
lastName: string
|
lastName: string
|
||||||
dateOfBirth?: string
|
dateOfBirth?: string
|
||||||
|
isMinor?: boolean
|
||||||
|
isPrimary?: boolean
|
||||||
email?: string
|
email?: string
|
||||||
phone?: string
|
phone?: string
|
||||||
notes?: string
|
notes?: string
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const minor = input.dateOfBirth ? isMinor(input.dateOfBirth) : false
|
// isMinor: explicit flag wins, else derive from DOB, else false
|
||||||
|
const minor = input.isMinor ?? (input.dateOfBirth ? isMinor(input.dateOfBirth) : false)
|
||||||
|
|
||||||
const [member] = await db
|
const [member] = await db
|
||||||
.insert(members)
|
.insert(members)
|
||||||
@@ -134,6 +153,19 @@ export const MemberService = {
|
|||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
|
|
||||||
|
// Auto-set as primary if this is the only member on the account
|
||||||
|
const [account] = await db
|
||||||
|
.select()
|
||||||
|
.from(accounts)
|
||||||
|
.where(eq(accounts.id, input.accountId))
|
||||||
|
.limit(1)
|
||||||
|
if (account && !account.primaryMemberId) {
|
||||||
|
await db
|
||||||
|
.update(accounts)
|
||||||
|
.set({ primaryMemberId: member.id, updatedAt: new Date() })
|
||||||
|
.where(eq(accounts.id, input.accountId))
|
||||||
|
}
|
||||||
|
|
||||||
return member
|
return member
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -147,6 +179,53 @@ export const MemberService = {
|
|||||||
return member ?? null
|
return member ?? null
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async list(db: PostgresJsDatabase, companyId: string, params: PaginationInput) {
|
||||||
|
const baseWhere = eq(members.companyId, companyId)
|
||||||
|
|
||||||
|
const searchCondition = params.q
|
||||||
|
? buildSearchCondition(params.q, [members.firstName, members.lastName, members.email, members.phone])
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const where = searchCondition ? and(baseWhere, searchCondition) : baseWhere
|
||||||
|
|
||||||
|
const sortableColumns: Record<string, typeof members.firstName> = {
|
||||||
|
first_name: members.firstName,
|
||||||
|
last_name: members.lastName,
|
||||||
|
email: members.email,
|
||||||
|
created_at: members.createdAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = db.select({
|
||||||
|
id: members.id,
|
||||||
|
accountId: members.accountId,
|
||||||
|
companyId: members.companyId,
|
||||||
|
firstName: members.firstName,
|
||||||
|
lastName: members.lastName,
|
||||||
|
dateOfBirth: members.dateOfBirth,
|
||||||
|
isMinor: members.isMinor,
|
||||||
|
email: members.email,
|
||||||
|
phone: members.phone,
|
||||||
|
notes: members.notes,
|
||||||
|
legacyId: members.legacyId,
|
||||||
|
createdAt: members.createdAt,
|
||||||
|
updatedAt: members.updatedAt,
|
||||||
|
accountName: accounts.name,
|
||||||
|
})
|
||||||
|
.from(members)
|
||||||
|
.leftJoin(accounts, eq(members.accountId, accounts.id))
|
||||||
|
.where(where)
|
||||||
|
.$dynamic()
|
||||||
|
query = withSort(query, params.sort, params.order, sortableColumns, members.lastName)
|
||||||
|
query = withPagination(query, params.page, params.limit)
|
||||||
|
|
||||||
|
const [data, [{ total }]] = await Promise.all([
|
||||||
|
query,
|
||||||
|
db.select({ total: count() }).from(members).where(where),
|
||||||
|
])
|
||||||
|
|
||||||
|
return paginatedResponse(data, total, params.page, params.limit)
|
||||||
|
},
|
||||||
|
|
||||||
async listByAccount(
|
async listByAccount(
|
||||||
db: PostgresJsDatabase,
|
db: PostgresJsDatabase,
|
||||||
companyId: string,
|
companyId: string,
|
||||||
@@ -181,13 +260,18 @@ export const MemberService = {
|
|||||||
firstName?: string
|
firstName?: string
|
||||||
lastName?: string
|
lastName?: string
|
||||||
dateOfBirth?: string
|
dateOfBirth?: string
|
||||||
|
isMinor?: boolean
|
||||||
email?: string
|
email?: string
|
||||||
phone?: string
|
phone?: string
|
||||||
notes?: string
|
notes?: string
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
const updates: Record<string, unknown> = { ...input, updatedAt: new Date() }
|
const updates: Record<string, unknown> = { ...input, updatedAt: new Date() }
|
||||||
if (input.dateOfBirth) {
|
|
||||||
|
// isMinor: explicit flag wins, else derive from DOB if provided
|
||||||
|
if (input.isMinor !== undefined) {
|
||||||
|
updates.isMinor = input.isMinor
|
||||||
|
} else if (input.dateOfBirth) {
|
||||||
updates.isMinor = isMinor(input.dateOfBirth)
|
updates.isMinor = isMinor(input.dateOfBirth)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,6 +284,32 @@ export const MemberService = {
|
|||||||
return member ?? null
|
return member ?? null
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async move(db: PostgresJsDatabase, companyId: string, memberId: string, targetAccountId: string) {
|
||||||
|
const member = await this.getById(db, companyId, memberId)
|
||||||
|
if (!member) return null
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(members)
|
||||||
|
.set({ accountId: targetAccountId, updatedAt: new Date() })
|
||||||
|
.where(and(eq(members.id, memberId), eq(members.companyId, companyId)))
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
// If target account has no primary, set this member
|
||||||
|
const [targetAccount] = await db
|
||||||
|
.select()
|
||||||
|
.from(accounts)
|
||||||
|
.where(and(eq(accounts.id, targetAccountId), eq(accounts.companyId, companyId)))
|
||||||
|
.limit(1)
|
||||||
|
if (targetAccount && !targetAccount.primaryMemberId) {
|
||||||
|
await db
|
||||||
|
.update(accounts)
|
||||||
|
.set({ primaryMemberId: memberId, updatedAt: new Date() })
|
||||||
|
.where(eq(accounts.id, targetAccountId))
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated
|
||||||
|
},
|
||||||
|
|
||||||
async delete(db: PostgresJsDatabase, companyId: string, id: string) {
|
async delete(db: PostgresJsDatabase, companyId: string, id: string) {
|
||||||
const [member] = await db
|
const [member] = await db
|
||||||
.delete(members)
|
.delete(members)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const MemberCreateSchema = z.object({
|
|||||||
firstName: z.string().min(1).max(100),
|
firstName: z.string().min(1).max(100),
|
||||||
lastName: z.string().min(1).max(100),
|
lastName: z.string().min(1).max(100),
|
||||||
dateOfBirth: z.string().date().optional(),
|
dateOfBirth: z.string().date().optional(),
|
||||||
|
isMinor: z.boolean().optional(),
|
||||||
email: z.string().email().optional(),
|
email: z.string().email().optional(),
|
||||||
phone: z.string().max(50).optional(),
|
phone: z.string().max(50).optional(),
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user