-
Notifications
You must be signed in to change notification settings - Fork 0
OUT-3275: account dropdowns in QB settings #253
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
1dc37d8
feat(OUT-3275): GET/PATCH /api/quickbooks/accounts and supporting hel…
SandipBajracharya 6b99712
feat(OUT-3275): Other Settings accordion with QBO account dropdowns
SandipBajracharya 1a84634
test(OUT-3275): unit + integration coverage for account-ref endpoints
SandipBajracharya edbde4c
fix(OUT-3275): address pre-push review
SandipBajracharya 2eca25f
fix(OUT-3275): dedupe AccountOption type (Greptile P2)
SandipBajracharya 4298ea5
fix(OUT-3275): rename opaque locals to describe the value, not the pr…
SandipBajracharya daba030
refactor(OUT-3275): drop QBO re-validation from PATCH /accounts
SandipBajracharya 8d16f34
refactor(OUT-3275): hoist suspense + revalidateOnMount defaults into …
SandipBajracharya 4d7321d
refactor(OUT-3275): make useSwrHelper generic over the response shape
SandipBajracharya cee5f7a
refactor(OUT-3275): rename Other Settings section to Account Mapping
SandipBajracharya File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| import authenticate from '@/app/api/core/utils/authenticate' | ||
| import { NextRequest, NextResponse } from 'next/server' | ||
| import httpStatus from 'http-status' | ||
| import { AccountService } from '@/app/api/quickbooks/accounts/accounts.service' | ||
| import { TokenService } from '@/app/api/quickbooks/token/token.service' | ||
| import { AccountRefsUpdateSchema } from '@/type/common' | ||
| import { QBPortalConnectionSelectSchema } from '@/db/schema/qbPortalConnections' | ||
|
|
||
| // Explicit allowlist of what is safe to return — anything else (especially | ||
| // token/secret fields) is dropped. Adding a new column to QBPortalConnection | ||
| // does NOT widen this surface unless the column is added here too. | ||
| const SafePortalConnectionSchema = QBPortalConnectionSelectSchema.pick({ | ||
| id: true, | ||
| portalId: true, | ||
| incomeAccountRef: true, | ||
| expenseAccountRef: true, | ||
| assetAccountRef: true, | ||
| }) | ||
|
|
||
| export async function listAccounts(req: NextRequest) { | ||
| const user = await authenticate(req) | ||
| const service = new AccountService(user) | ||
| const accountsResponse = await service.listAccountsForProductMapping() | ||
| return NextResponse.json(accountsResponse) | ||
| } | ||
|
|
||
| export async function updateAccountRefs(req: NextRequest) { | ||
| const user = await authenticate(req) | ||
| const body = await req.json() | ||
| const accountRefs = AccountRefsUpdateSchema.parse(body) | ||
|
|
||
| const service = new TokenService(user) | ||
| const updatedConnection = await service.updateAccountRefs(accountRefs) | ||
|
|
||
| return NextResponse.json( | ||
| { portalConnection: SafePortalConnectionSchema.parse(updatedConnection) }, | ||
| { status: httpStatus.OK }, | ||
| ) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import { BaseService } from '@/app/api/core/services/base.service' | ||
| import { getPortalTokens } from '@/db/service/token.service' | ||
| import IntuitAPI from '@/utils/intuitAPI' | ||
| import { AccountsListResponse } from '@/type/common' | ||
|
|
||
| export class AccountService extends BaseService { | ||
| async listAccountsForProductMapping(): Promise<AccountsListResponse> { | ||
| const tokens = await getPortalTokens(this.user.workspaceId) | ||
|
|
||
| const intuitApi = new IntuitAPI(tokens) | ||
| const { income, expense, asset } = | ||
| await intuitApi.getAccountsForProductMapping() | ||
|
|
||
| return { | ||
| options: { | ||
| income: income.map((a) => ({ id: a.Id, name: a.Name })), | ||
| expense: expense.map((a) => ({ id: a.Id, name: a.Name })), | ||
| asset: asset.map((a) => ({ id: a.Id, name: a.Name })), | ||
| }, | ||
| selected: { | ||
| incomeAccountRef: tokens.incomeAccountRef, | ||
| expenseAccountRef: tokens.expenseAccountRef, | ||
| assetAccountRef: tokens.assetAccountRef, | ||
| }, | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import { withErrorHandler } from '@/app/api/core/utils/withErrorHandler' | ||
| import { | ||
| listAccounts, | ||
| updateAccountRefs, | ||
| } from '@/app/api/quickbooks/accounts/accounts.controller' | ||
|
|
||
| export const maxDuration = 300 // 5 minutes | ||
|
|
||
| export const GET = withErrorHandler(listAccounts) | ||
| export const PATCH = withErrorHandler(updateAccountRefs) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
168 changes: 168 additions & 0 deletions
168
src/components/dashboard/settings/sections/account/AccountMapping.tsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,168 @@ | ||
| import { AccountsListResponseUi, AccountMappingState } from '@/hook/useSettings' | ||
| import useClickOutside from '@/hook/useClickOutside' | ||
| import { AccountOption } from '@/type/common' | ||
| import { Icon, Spinner } from 'copilot-design-system' | ||
| import { useRef, useState } from 'react' | ||
|
|
||
| type AccountMappingProps = { | ||
| options: AccountsListResponseUi['options'] | undefined | ||
| settingState: AccountMappingState | ||
| changeSettings: (field: keyof AccountMappingState, value: string) => void | ||
| isLoading: boolean | ||
| error: unknown | ||
| isDisconnected: boolean | ||
| } | ||
|
|
||
| function AccountSelect({ | ||
| label, | ||
| description, | ||
| value, | ||
| options, | ||
| placeholder, | ||
| onChange, | ||
| }: { | ||
| label: string | ||
| description: string | ||
| value: string | ||
| options: AccountOption[] | undefined | ||
| placeholder: string | ||
| onChange: (id: string) => void | ||
| }) { | ||
| const [isOpen, setIsOpen] = useState(false) | ||
| const dropdownRef = useRef<HTMLDivElement>(null) | ||
| const buttonRef = useRef<HTMLButtonElement>(null) | ||
|
|
||
| useClickOutside(dropdownRef, () => setIsOpen(false), [buttonRef]) | ||
|
|
||
| const disabled = !options || options.length === 0 | ||
| const selected = options?.find((o) => o.id === value) | ||
| // Defends against an account being deleted in QBO between load and save — | ||
| // surfaces the stale id with a hint instead of silently snapping to empty. | ||
| const optionMissing = !!value && !selected | ||
|
|
||
| const labelId = `account-select-label-${label.replace(/\s+/g, '-').toLowerCase()}` | ||
| return ( | ||
| <div className="mb-5"> | ||
| <label id={labelId} className="block text-sm font-medium mb-1"> | ||
| {label} | ||
| </label> | ||
| <p className="text-xs text-gray-500 mb-2">{description}</p> | ||
| <div className="relative"> | ||
| <button | ||
| ref={buttonRef} | ||
| type="button" | ||
| disabled={disabled} | ||
| onClick={() => setIsOpen((v) => !v)} | ||
| aria-haspopup="listbox" | ||
| aria-expanded={isOpen} | ||
| aria-labelledby={labelId} | ||
| className="w-full bg-gray-100 hover:bg-gray-150 grid grid-cols-6 md:grid-cols-14 py-2 pl-4 pr-3 border border-gray-200 rounded text-left disabled:opacity-50 focus:outline-none focus:border-gray-200" | ||
| > | ||
| <div className="col-span-5 md:col-span-13 text-sm text-gray-600 break-all lg:break-normal"> | ||
| {selected ? ( | ||
| selected.name | ||
| ) : optionMissing ? ( | ||
| <span className="text-gray-500"> | ||
| Please select {label.toLowerCase()} | ||
| </span> | ||
| ) : ( | ||
| <span className="text-gray-400"> | ||
| {disabled ? 'No matching accounts in QuickBooks' : placeholder} | ||
| </span> | ||
| )} | ||
| </div> | ||
| <div className="col-span-1 ml-auto my-auto"> | ||
| <Icon | ||
| icon="ChevronDown" | ||
| width={16} | ||
| height={16} | ||
| className="text-gray-500" | ||
| /> | ||
| </div> | ||
| </button> | ||
| {isOpen && !disabled && ( | ||
| <div | ||
| ref={dropdownRef} | ||
| role="listbox" | ||
| aria-labelledby={labelId} | ||
| className="absolute right-0 left-0 top-full mt-[-1px] bg-white border border-gray-150 !shadow-popover-050 rounded-sm z-100" | ||
| > | ||
| <div className="max-h-56 overflow-y-auto"> | ||
| {options?.map((o) => ( | ||
| <button | ||
| key={o.id} | ||
| type="button" | ||
| role="option" | ||
| aria-selected={o.id === value} | ||
| onClick={() => { | ||
| onChange(o.id) | ||
| setIsOpen(false) | ||
| }} | ||
| className="w-full px-3 py-1.5 text-sm hover:bg-gray-100 focus:outline-none transition-colors cursor-pointer text-left text-gray-600 line-clamp-1 break-all lg:break-normal" | ||
| > | ||
| {o.name} | ||
| </button> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| )} | ||
| </div> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| export default function AccountMapping({ | ||
| options, | ||
| settingState, | ||
| changeSettings, | ||
| isLoading, | ||
| error, | ||
| isDisconnected, | ||
| }: AccountMappingProps) { | ||
| if (isDisconnected) { | ||
| return ( | ||
| <div className="mt-2 mb-6 text-sm text-gray-600"> | ||
| Connect to QuickBooks to manage account settings. | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| if (isLoading) return <Spinner size={5} /> | ||
|
|
||
| if (error) { | ||
| return ( | ||
| <div className="mt-2 mb-6 text-sm text-red-600"> | ||
| Could not load accounts. Reload to retry. | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| return ( | ||
| <div className="mt-2 mb-6"> | ||
| <AccountSelect | ||
| label="Income account" | ||
| description="Default income account assigned to services synced from Assembly to QuickBooks." | ||
| value={settingState.incomeAccountRef} | ||
| options={options?.income} | ||
| placeholder="Select an income account" | ||
| onChange={(id) => changeSettings('incomeAccountRef', id)} | ||
| /> | ||
| <AccountSelect | ||
| label="Expense account" | ||
| description="Account where absorbed invoice payment fees are recorded as expenses in QuickBooks." | ||
| value={settingState.expenseAccountRef} | ||
| options={options?.expense} | ||
| placeholder="Select an expense account" | ||
| onChange={(id) => changeSettings('expenseAccountRef', id)} | ||
| /> | ||
| <AccountSelect | ||
| label="Bank account" | ||
| description="Account the absorbed invoice payment fees are paid out of, paired with the expense account above." | ||
| value={settingState.assetAccountRef} | ||
| options={options?.asset} | ||
| placeholder="Select a bank account" | ||
| onChange={(id) => changeSettings('assetAccountRef', id)} | ||
| /> | ||
| </div> | ||
| ) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.