Commit 4d0dd9feca9f8999afe976cc0245277262a946a8
1 parent
34c8c92f
feat(finance): chart of accounts — Account entity, seeded GL, SPA page
First step of pbc-finance GL growth: the chart of accounts.
Backend:
- Account entity (code, name, accountType: ASSET/LIABILITY/EQUITY/
REVENUE/EXPENSE, description, active)
- AccountJpaRepository + AccountService (list, findById, findByCode,
create with duplicate-code guard)
- AccountController at /api/v1/finance/accounts (GET list, GET by id,
POST create). Permission-gated: finance.account.read, .create.
- Liquibase 003-finance-accounts.xml: table + unique code index +
6 seeded accounts (1000 Cash, 1100 AR, 1200 Inventory, 2100 AP,
4100 Sales Revenue, 5100 COGS)
- finance.yml updated: Account entity + 2 permissions + menu entry
SPA:
- AccountsPage with sortable list + inline create form
- finance.listAccounts + finance.createAccount in typed API client
- Sidebar: "Chart of Accounts" above "Journal Entries" in Finance
- Route /accounts wired in App.tsx + SpaController + SecurityConfig
This is the foundation for the next step (JournalEntryLine child
entity with per-account debit/credit legs + balanced-entry
validation). The seeded chart covers the 6 accounts the existing
event subscribers will reference once the double-entry lines land.
Showing
14 changed files
with
398 additions
and
9 deletions
distribution/src/main/resources/db/changelog/master.xml
| ... | ... | @@ -23,6 +23,7 @@ |
| 23 | 23 | <include file="classpath:db/changelog/pbc-orders-purchase/001-orders-purchase-init.xml"/> |
| 24 | 24 | <include file="classpath:db/changelog/pbc-finance/001-finance-init.xml"/> |
| 25 | 25 | <include file="classpath:db/changelog/pbc-finance/002-finance-status.xml"/> |
| 26 | + <include file="classpath:db/changelog/pbc-finance/003-finance-accounts.xml"/> | |
| 26 | 27 | <include file="classpath:db/changelog/pbc-production/001-production-init.xml"/> |
| 27 | 28 | <include file="classpath:db/changelog/pbc-production/002-production-v2.xml"/> |
| 28 | 29 | <include file="classpath:db/changelog/pbc-production/003-production-v3.xml"/> | ... | ... |
distribution/src/main/resources/db/changelog/pbc-finance/003-finance-accounts.xml
0 → 100644
| 1 | +<?xml version="1.0" encoding="UTF-8"?> | |
| 2 | +<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" | |
| 3 | + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" | |
| 4 | + xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog | |
| 5 | + https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd"> | |
| 6 | + | |
| 7 | + <!-- | |
| 8 | + pbc-finance v0.30 — Chart of accounts. | |
| 9 | + | |
| 10 | + Adds the finance__account table and seeds a minimal 6-account | |
| 11 | + chart covering the accounts the event subscribers reference: | |
| 12 | + AR, AP, Revenue, COGS, Inventory, Cash. | |
| 13 | + | |
| 14 | + Operators can add accounts via POST /api/v1/finance/accounts | |
| 15 | + or through the SPA. The seeded set is NOT a full chart — it's | |
| 16 | + the minimum needed for the existing AR/AP event-driven journal | |
| 17 | + entries to reference real accounts. A richer default chart | |
| 18 | + (sub-accounts, account groups) is a future enhancement. | |
| 19 | + --> | |
| 20 | + | |
| 21 | + <changeSet id="finance-accounts-001" author="vibe_erp"> | |
| 22 | + <comment>Create finance__account table</comment> | |
| 23 | + <sql> | |
| 24 | + CREATE TABLE finance__account ( | |
| 25 | + id uuid PRIMARY KEY, | |
| 26 | + code varchar(32) NOT NULL, | |
| 27 | + name varchar(256) NOT NULL, | |
| 28 | + account_type varchar(16) NOT NULL, | |
| 29 | + description text, | |
| 30 | + active boolean NOT NULL DEFAULT true, | |
| 31 | + created_at timestamptz NOT NULL, | |
| 32 | + created_by varchar(128) NOT NULL, | |
| 33 | + updated_at timestamptz NOT NULL, | |
| 34 | + updated_by varchar(128) NOT NULL, | |
| 35 | + version bigint NOT NULL DEFAULT 0, | |
| 36 | + CONSTRAINT finance__account_type_check | |
| 37 | + CHECK (account_type IN ('ASSET', 'LIABILITY', 'EQUITY', 'REVENUE', 'EXPENSE')) | |
| 38 | + ); | |
| 39 | + CREATE UNIQUE INDEX finance__account_code_uk | |
| 40 | + ON finance__account (code); | |
| 41 | + </sql> | |
| 42 | + <rollback> | |
| 43 | + DROP TABLE finance__account; | |
| 44 | + </rollback> | |
| 45 | + </changeSet> | |
| 46 | + | |
| 47 | + <changeSet id="finance-accounts-002-seed" author="vibe_erp"> | |
| 48 | + <comment>Seed minimal chart of accounts</comment> | |
| 49 | + <sql> | |
| 50 | + INSERT INTO finance__account (id, code, name, account_type, description, created_at, created_by, updated_at, updated_by) | |
| 51 | + VALUES | |
| 52 | + (gen_random_uuid(), '1100', 'Accounts Receivable', 'ASSET', 'Money owed to the company by customers', now(), '__seed__', now(), '__seed__'), | |
| 53 | + (gen_random_uuid(), '1200', 'Inventory', 'ASSET', 'Value of goods held in warehouses', now(), '__seed__', now(), '__seed__'), | |
| 54 | + (gen_random_uuid(), '1000', 'Cash', 'ASSET', 'Cash and bank balances', now(), '__seed__', now(), '__seed__'), | |
| 55 | + (gen_random_uuid(), '2100', 'Accounts Payable', 'LIABILITY', 'Money the company owes to suppliers', now(), '__seed__', now(), '__seed__'), | |
| 56 | + (gen_random_uuid(), '4100', 'Sales Revenue', 'REVENUE', 'Income from sales orders', now(), '__seed__', now(), '__seed__'), | |
| 57 | + (gen_random_uuid(), '5100', 'Cost of Goods Sold', 'EXPENSE', 'Direct cost of goods delivered to customers', now(), '__seed__', now(), '__seed__'); | |
| 58 | + </sql> | |
| 59 | + <rollback> | |
| 60 | + DELETE FROM finance__account WHERE created_by = '__seed__'; | |
| 61 | + </rollback> | |
| 62 | + </changeSet> | |
| 63 | + | |
| 64 | +</databaseChangeLog> | ... | ... |
pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/application/AccountService.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.finance.application | |
| 2 | + | |
| 3 | +import org.springframework.stereotype.Service | |
| 4 | +import org.springframework.transaction.annotation.Transactional | |
| 5 | +import org.vibeerp.pbc.finance.domain.Account | |
| 6 | +import org.vibeerp.pbc.finance.domain.AccountType | |
| 7 | +import org.vibeerp.pbc.finance.infrastructure.AccountJpaRepository | |
| 8 | +import java.util.UUID | |
| 9 | + | |
| 10 | +@Service | |
| 11 | +@Transactional | |
| 12 | +class AccountService( | |
| 13 | + private val accounts: AccountJpaRepository, | |
| 14 | +) { | |
| 15 | + | |
| 16 | + @Transactional(readOnly = true) | |
| 17 | + fun list(): List<Account> = accounts.findAll() | |
| 18 | + | |
| 19 | + @Transactional(readOnly = true) | |
| 20 | + fun findById(id: UUID): Account? = accounts.findById(id).orElse(null) | |
| 21 | + | |
| 22 | + @Transactional(readOnly = true) | |
| 23 | + fun findByCode(code: String): Account? = accounts.findByCode(code) | |
| 24 | + | |
| 25 | + fun create(command: CreateAccountCommand): Account { | |
| 26 | + require(!accounts.existsByCode(command.code)) { | |
| 27 | + "account code '${command.code}' already exists" | |
| 28 | + } | |
| 29 | + return accounts.save( | |
| 30 | + Account( | |
| 31 | + code = command.code, | |
| 32 | + name = command.name, | |
| 33 | + accountType = command.accountType, | |
| 34 | + description = command.description, | |
| 35 | + ), | |
| 36 | + ) | |
| 37 | + } | |
| 38 | +} | |
| 39 | + | |
| 40 | +data class CreateAccountCommand( | |
| 41 | + val code: String, | |
| 42 | + val name: String, | |
| 43 | + val accountType: AccountType, | |
| 44 | + val description: String? = null, | |
| 45 | +) | ... | ... |
pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/domain/Account.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.finance.domain | |
| 2 | + | |
| 3 | +import jakarta.persistence.Column | |
| 4 | +import jakarta.persistence.Entity | |
| 5 | +import jakarta.persistence.EnumType | |
| 6 | +import jakarta.persistence.Enumerated | |
| 7 | +import jakarta.persistence.Table | |
| 8 | +import org.vibeerp.platform.persistence.audit.AuditedJpaEntity | |
| 9 | + | |
| 10 | +/** | |
| 11 | + * An account in the chart of accounts. | |
| 12 | + * | |
| 13 | + * The chart of accounts is the backbone of the GL — every journal | |
| 14 | + * entry line debits or credits one account. Accounts are typed by | |
| 15 | + * their normal balance direction: | |
| 16 | + * | |
| 17 | + * - **ASSET** — things the company owns (cash, inventory, AR) | |
| 18 | + * - **LIABILITY** — things the company owes (AP, loans) | |
| 19 | + * - **EQUITY** — owner's investment + retained earnings | |
| 20 | + * - **REVENUE** — income from operations (sales) | |
| 21 | + * - **EXPENSE** — costs incurred (COGS, wages) | |
| 22 | + * | |
| 23 | + * v1 seeds a minimal chart of accounts (6 accounts); operators can | |
| 24 | + * add accounts via the SPA or API. A richer chart with sub-accounts, | |
| 25 | + * account groups, and reporting categories is a future enhancement. | |
| 26 | + */ | |
| 27 | +@Entity | |
| 28 | +@Table(name = "finance__account") | |
| 29 | +class Account( | |
| 30 | + code: String, | |
| 31 | + name: String, | |
| 32 | + accountType: AccountType, | |
| 33 | + description: String? = null, | |
| 34 | + active: Boolean = true, | |
| 35 | +) : AuditedJpaEntity() { | |
| 36 | + | |
| 37 | + @Column(name = "code", nullable = false, length = 32) | |
| 38 | + var code: String = code | |
| 39 | + | |
| 40 | + @Column(name = "name", nullable = false, length = 256) | |
| 41 | + var name: String = name | |
| 42 | + | |
| 43 | + @Enumerated(EnumType.STRING) | |
| 44 | + @Column(name = "account_type", nullable = false, length = 16) | |
| 45 | + var accountType: AccountType = accountType | |
| 46 | + | |
| 47 | + @Column(name = "description", nullable = true) | |
| 48 | + var description: String? = description | |
| 49 | + | |
| 50 | + @Column(name = "active", nullable = false) | |
| 51 | + var active: Boolean = active | |
| 52 | + | |
| 53 | + override fun toString(): String = "Account(id=$id, code='$code', type=$accountType)" | |
| 54 | +} | |
| 55 | + | |
| 56 | +enum class AccountType { | |
| 57 | + ASSET, | |
| 58 | + LIABILITY, | |
| 59 | + EQUITY, | |
| 60 | + REVENUE, | |
| 61 | + EXPENSE, | |
| 62 | +} | ... | ... |
pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/http/AccountController.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.finance.http | |
| 2 | + | |
| 3 | +import jakarta.validation.Valid | |
| 4 | +import jakarta.validation.constraints.NotBlank | |
| 5 | +import jakarta.validation.constraints.NotNull | |
| 6 | +import jakarta.validation.constraints.Size | |
| 7 | +import org.springframework.http.HttpStatus | |
| 8 | +import org.springframework.http.ResponseEntity | |
| 9 | +import org.springframework.web.bind.annotation.GetMapping | |
| 10 | +import org.springframework.web.bind.annotation.PathVariable | |
| 11 | +import org.springframework.web.bind.annotation.PostMapping | |
| 12 | +import org.springframework.web.bind.annotation.RequestBody | |
| 13 | +import org.springframework.web.bind.annotation.RequestMapping | |
| 14 | +import org.springframework.web.bind.annotation.ResponseStatus | |
| 15 | +import org.springframework.web.bind.annotation.RestController | |
| 16 | +import org.vibeerp.pbc.finance.application.AccountService | |
| 17 | +import org.vibeerp.pbc.finance.application.CreateAccountCommand | |
| 18 | +import org.vibeerp.pbc.finance.domain.Account | |
| 19 | +import org.vibeerp.pbc.finance.domain.AccountType | |
| 20 | +import org.vibeerp.platform.security.authz.RequirePermission | |
| 21 | +import java.util.UUID | |
| 22 | + | |
| 23 | +@RestController | |
| 24 | +@RequestMapping("/api/v1/finance/accounts") | |
| 25 | +class AccountController( | |
| 26 | + private val accountService: AccountService, | |
| 27 | +) { | |
| 28 | + | |
| 29 | + @GetMapping | |
| 30 | + @RequirePermission("finance.account.read") | |
| 31 | + fun list(): List<AccountResponse> = | |
| 32 | + accountService.list().map { it.toResponse() } | |
| 33 | + | |
| 34 | + @GetMapping("/{id}") | |
| 35 | + @RequirePermission("finance.account.read") | |
| 36 | + fun get(@PathVariable id: UUID): ResponseEntity<AccountResponse> { | |
| 37 | + val account = accountService.findById(id) ?: return ResponseEntity.notFound().build() | |
| 38 | + return ResponseEntity.ok(account.toResponse()) | |
| 39 | + } | |
| 40 | + | |
| 41 | + @PostMapping | |
| 42 | + @ResponseStatus(HttpStatus.CREATED) | |
| 43 | + @RequirePermission("finance.account.create") | |
| 44 | + fun create(@RequestBody @Valid request: CreateAccountRequest): AccountResponse = | |
| 45 | + accountService.create( | |
| 46 | + CreateAccountCommand( | |
| 47 | + code = request.code, | |
| 48 | + name = request.name, | |
| 49 | + accountType = request.accountType, | |
| 50 | + description = request.description, | |
| 51 | + ), | |
| 52 | + ).toResponse() | |
| 53 | +} | |
| 54 | + | |
| 55 | +data class CreateAccountRequest( | |
| 56 | + @field:NotBlank @field:Size(max = 32) val code: String, | |
| 57 | + @field:NotBlank @field:Size(max = 256) val name: String, | |
| 58 | + @field:NotNull val accountType: AccountType, | |
| 59 | + val description: String? = null, | |
| 60 | +) | |
| 61 | + | |
| 62 | +data class AccountResponse( | |
| 63 | + val id: UUID, | |
| 64 | + val code: String, | |
| 65 | + val name: String, | |
| 66 | + val accountType: AccountType, | |
| 67 | + val description: String?, | |
| 68 | + val active: Boolean, | |
| 69 | +) | |
| 70 | + | |
| 71 | +private fun Account.toResponse() = AccountResponse( | |
| 72 | + id = this.id, | |
| 73 | + code = this.code, | |
| 74 | + name = this.name, | |
| 75 | + accountType = this.accountType, | |
| 76 | + description = this.description, | |
| 77 | + active = this.active, | |
| 78 | +) | ... | ... |
pbc/pbc-finance/src/main/kotlin/org/vibeerp/pbc/finance/infrastructure/AccountJpaRepository.kt
0 → 100644
| 1 | +package org.vibeerp.pbc.finance.infrastructure | |
| 2 | + | |
| 3 | +import org.springframework.data.jpa.repository.JpaRepository | |
| 4 | +import org.springframework.stereotype.Repository | |
| 5 | +import org.vibeerp.pbc.finance.domain.Account | |
| 6 | +import org.vibeerp.pbc.finance.domain.AccountType | |
| 7 | +import java.util.UUID | |
| 8 | + | |
| 9 | +@Repository | |
| 10 | +interface AccountJpaRepository : JpaRepository<Account, UUID> { | |
| 11 | + fun findByCode(code: String): Account? | |
| 12 | + fun existsByCode(code: String): Boolean | |
| 13 | + fun findByAccountType(accountType: AccountType): List<Account> | |
| 14 | +} | ... | ... |
pbc/pbc-finance/src/main/resources/META-INF/vibe-erp/metadata/finance.yml
| 1 | 1 | # pbc-finance metadata. |
| 2 | -# | |
| 3 | -# Loaded at boot by MetadataLoader, tagged source='core'. The minimal | |
| 4 | -# v0.16 build only carries the JournalEntry entity, the read permission, | |
| 5 | -# and a navigation menu entry. No write permissions exist because there | |
| 6 | -# is no write endpoint — entries appear automatically in reaction to | |
| 7 | -# domain events from other PBCs (SalesOrderConfirmed → AR row, | |
| 8 | -# PurchaseOrderConfirmed → AP row). | |
| 9 | 2 | |
| 10 | 3 | entities: |
| 11 | 4 | - name: JournalEntry |
| ... | ... | @@ -13,13 +6,27 @@ entities: |
| 13 | 6 | table: finance__journal_entry |
| 14 | 7 | description: A single AR/AP journal entry derived from a sales- or purchase-order confirmation event |
| 15 | 8 | |
| 9 | + - name: Account | |
| 10 | + pbc: finance | |
| 11 | + table: finance__account | |
| 12 | + description: An account in the chart of accounts (ASSET, LIABILITY, EQUITY, REVENUE, EXPENSE) | |
| 13 | + | |
| 16 | 14 | permissions: |
| 17 | 15 | - key: finance.journal.read |
| 18 | 16 | description: Read journal entries |
| 17 | + - key: finance.account.read | |
| 18 | + description: Read chart of accounts | |
| 19 | + - key: finance.account.create | |
| 20 | + description: Create new accounts in the chart | |
| 19 | 21 | |
| 20 | 22 | menus: |
| 23 | + - path: /finance/accounts | |
| 24 | + label: Chart of Accounts | |
| 25 | + icon: calculator | |
| 26 | + section: Finance | |
| 27 | + order: 690 | |
| 21 | 28 | - path: /finance/journal-entries |
| 22 | - label: Journal entries | |
| 29 | + label: Journal Entries | |
| 23 | 30 | icon: book-open |
| 24 | 31 | section: Finance |
| 25 | 32 | order: 700 | ... | ... |
platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/SpaController.kt
platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/SecurityConfiguration.kt
| ... | ... | @@ -109,6 +109,7 @@ class SecurityConfiguration { |
| 109 | 109 | "/purchase-orders", "/purchase-orders/**", |
| 110 | 110 | "/work-orders", "/work-orders/**", |
| 111 | 111 | "/shop-floor", "/shop-floor/**", |
| 112 | + "/accounts", "/accounts/**", | |
| 112 | 113 | "/journal-entries", "/journal-entries/**", |
| 113 | 114 | "/users", "/users/**", |
| 114 | 115 | "/roles", "/roles/**", | ... | ... |
web/src/App.tsx
| ... | ... | @@ -37,6 +37,7 @@ import { WorkOrdersPage } from '@/pages/WorkOrdersPage' |
| 37 | 37 | import { CreateWorkOrderPage } from '@/pages/CreateWorkOrderPage' |
| 38 | 38 | import { WorkOrderDetailPage } from '@/pages/WorkOrderDetailPage' |
| 39 | 39 | import { ShopFloorPage } from '@/pages/ShopFloorPage' |
| 40 | +import { AccountsPage } from '@/pages/AccountsPage' | |
| 40 | 41 | import { JournalEntriesPage } from '@/pages/JournalEntriesPage' |
| 41 | 42 | |
| 42 | 43 | export default function App() { |
| ... | ... | @@ -76,6 +77,7 @@ export default function App() { |
| 76 | 77 | <Route path="work-orders/new" element={<CreateWorkOrderPage />} /> |
| 77 | 78 | <Route path="work-orders/:id" element={<WorkOrderDetailPage />} /> |
| 78 | 79 | <Route path="shop-floor" element={<ShopFloorPage />} /> |
| 80 | + <Route path="accounts" element={<AccountsPage />} /> | |
| 79 | 81 | <Route path="journal-entries" element={<JournalEntriesPage />} /> |
| 80 | 82 | </Route> |
| 81 | 83 | <Route path="*" element={<Navigate to="/" replace />} /> | ... | ... |
web/src/api/client.ts
| ... | ... | @@ -25,6 +25,7 @@ |
| 25 | 25 | // underlying TypeError, also caught by the page boundary. |
| 26 | 26 | |
| 27 | 27 | import type { |
| 28 | + Account, | |
| 28 | 29 | Item, |
| 29 | 30 | JournalEntry, |
| 30 | 31 | Location, |
| ... | ... | @@ -276,6 +277,10 @@ export const production = { |
| 276 | 277 | // ─── Finance ───────────────────────────────────────────────────────── |
| 277 | 278 | |
| 278 | 279 | export const finance = { |
| 280 | + listAccounts: () => apiFetch<Account[]>('/api/v1/finance/accounts'), | |
| 281 | + createAccount: (body: { | |
| 282 | + code: string; name: string; accountType: string; description?: string | null | |
| 283 | + }) => apiFetch<Account>('/api/v1/finance/accounts', { method: 'POST', body: JSON.stringify(body) }), | |
| 279 | 284 | listJournalEntries: () => |
| 280 | 285 | apiFetch<JournalEntry[]>('/api/v1/finance/journal-entries'), |
| 281 | 286 | } | ... | ... |
web/src/layout/AppLayout.tsx
| ... | ... | @@ -63,7 +63,10 @@ const NAV: NavGroup[] = [ |
| 63 | 63 | }, |
| 64 | 64 | { |
| 65 | 65 | heading: 'Finance', |
| 66 | - items: [{ to: '/journal-entries', label: 'Journal Entries' }], | |
| 66 | + items: [ | |
| 67 | + { to: '/accounts', label: 'Chart of Accounts' }, | |
| 68 | + { to: '/journal-entries', label: 'Journal Entries' }, | |
| 69 | + ], | |
| 67 | 70 | }, |
| 68 | 71 | { |
| 69 | 72 | heading: 'System', | ... | ... |
web/src/pages/AccountsPage.tsx
0 → 100644
| 1 | +import { useEffect, useState, type FormEvent } from 'react' | |
| 2 | +import { finance } from '@/api/client' | |
| 3 | +import type { Account } from '@/types/api' | |
| 4 | +import { PageHeader } from '@/components/PageHeader' | |
| 5 | +import { Loading } from '@/components/Loading' | |
| 6 | +import { ErrorBox } from '@/components/ErrorBox' | |
| 7 | +import { DataTable, type Column } from '@/components/DataTable' | |
| 8 | + | |
| 9 | +const ACCOUNT_TYPES = ['ASSET', 'LIABILITY', 'EQUITY', 'REVENUE', 'EXPENSE'] as const | |
| 10 | + | |
| 11 | +export function AccountsPage() { | |
| 12 | + const [rows, setRows] = useState<Account[]>([]) | |
| 13 | + const [error, setError] = useState<Error | null>(null) | |
| 14 | + const [loading, setLoading] = useState(true) | |
| 15 | + const [showCreate, setShowCreate] = useState(false) | |
| 16 | + const [code, setCode] = useState('') | |
| 17 | + const [name, setName] = useState('') | |
| 18 | + const [accountType, setAccountType] = useState<string>('ASSET') | |
| 19 | + const [creating, setCreating] = useState(false) | |
| 20 | + | |
| 21 | + const load = () => { | |
| 22 | + finance | |
| 23 | + .listAccounts() | |
| 24 | + .then((rs) => setRows([...rs].sort((a, b) => a.code.localeCompare(b.code)))) | |
| 25 | + .catch((e: unknown) => setError(e instanceof Error ? e : new Error(String(e)))) | |
| 26 | + .finally(() => setLoading(false)) | |
| 27 | + } | |
| 28 | + | |
| 29 | + useEffect(() => { load() }, []) // eslint-disable-line react-hooks/exhaustive-deps | |
| 30 | + | |
| 31 | + const onCreate = async (e: FormEvent) => { | |
| 32 | + e.preventDefault() | |
| 33 | + setCreating(true) | |
| 34 | + setError(null) | |
| 35 | + try { | |
| 36 | + await finance.createAccount({ code, name, accountType }) | |
| 37 | + setCode('') | |
| 38 | + setName('') | |
| 39 | + setShowCreate(false) | |
| 40 | + load() | |
| 41 | + } catch (err: unknown) { | |
| 42 | + setError(err instanceof Error ? err : new Error(String(err))) | |
| 43 | + } finally { | |
| 44 | + setCreating(false) | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + const columns: Column<Account>[] = [ | |
| 49 | + { header: 'Code', key: 'code', render: (r) => <span className="font-mono">{r.code}</span> }, | |
| 50 | + { header: 'Name', key: 'name' }, | |
| 51 | + { header: 'Type', key: 'accountType' }, | |
| 52 | + { header: 'Description', key: 'description', render: (r) => r.description ?? '—' }, | |
| 53 | + ] | |
| 54 | + | |
| 55 | + return ( | |
| 56 | + <div> | |
| 57 | + <PageHeader | |
| 58 | + title="Chart of Accounts" | |
| 59 | + subtitle="GL accounts that journal entries debit and credit. 6 accounts seeded by default." | |
| 60 | + actions={ | |
| 61 | + <button className="btn-primary" onClick={() => setShowCreate(!showCreate)}> | |
| 62 | + {showCreate ? 'Cancel' : '+ New Account'} | |
| 63 | + </button> | |
| 64 | + } | |
| 65 | + /> | |
| 66 | + {showCreate && ( | |
| 67 | + <form onSubmit={onCreate} className="card p-4 mb-4 max-w-2xl flex flex-wrap items-end gap-3"> | |
| 68 | + <div className="w-24"> | |
| 69 | + <label className="block text-xs font-medium text-slate-700">Code</label> | |
| 70 | + <input type="text" required value={code} onChange={(e) => setCode(e.target.value)} | |
| 71 | + placeholder="1300" className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm" /> | |
| 72 | + </div> | |
| 73 | + <div className="flex-1 min-w-[150px]"> | |
| 74 | + <label className="block text-xs font-medium text-slate-700">Name</label> | |
| 75 | + <input type="text" required value={name} onChange={(e) => setName(e.target.value)} | |
| 76 | + placeholder="Prepaid expenses" className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm" /> | |
| 77 | + </div> | |
| 78 | + <div className="w-32"> | |
| 79 | + <label className="block text-xs font-medium text-slate-700">Type</label> | |
| 80 | + <select value={accountType} onChange={(e) => setAccountType(e.target.value)} | |
| 81 | + className="mt-1 w-full rounded-md border border-slate-300 px-2 py-1.5 text-sm"> | |
| 82 | + {ACCOUNT_TYPES.map((t) => <option key={t} value={t}>{t}</option>)} | |
| 83 | + </select> | |
| 84 | + </div> | |
| 85 | + <button type="submit" className="btn-primary" disabled={creating}> | |
| 86 | + {creating ? '...' : 'Create'} | |
| 87 | + </button> | |
| 88 | + </form> | |
| 89 | + )} | |
| 90 | + {loading && <Loading />} | |
| 91 | + {error && <ErrorBox error={error} />} | |
| 92 | + {!loading && !error && <DataTable rows={rows} columns={columns} />} | |
| 93 | + </div> | |
| 94 | + ) | |
| 95 | +} | ... | ... |
web/src/types/api.ts
| ... | ... | @@ -250,6 +250,17 @@ export interface ShopFloorEntry { |
| 250 | 250 | |
| 251 | 251 | // ─── Finance (pbc-finance) ─────────────────────────────────────────── |
| 252 | 252 | |
| 253 | +export type AccountType = 'ASSET' | 'LIABILITY' | 'EQUITY' | 'REVENUE' | 'EXPENSE' | |
| 254 | + | |
| 255 | +export interface Account { | |
| 256 | + id: string | |
| 257 | + code: string | |
| 258 | + name: string | |
| 259 | + accountType: AccountType | |
| 260 | + description: string | null | |
| 261 | + active: boolean | |
| 262 | +} | |
| 263 | + | |
| 253 | 264 | export type JournalEntryType = 'AR' | 'AP' |
| 254 | 265 | export type JournalEntryStatus = 'POSTED' | 'SETTLED' | 'REVERSED' |
| 255 | 266 | ... | ... |
-
mentioned in commit 1f6482be