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,6 +23,7 @@ | ||
| 23 | <include file="classpath:db/changelog/pbc-orders-purchase/001-orders-purchase-init.xml"/> | 23 | <include file="classpath:db/changelog/pbc-orders-purchase/001-orders-purchase-init.xml"/> |
| 24 | <include file="classpath:db/changelog/pbc-finance/001-finance-init.xml"/> | 24 | <include file="classpath:db/changelog/pbc-finance/001-finance-init.xml"/> |
| 25 | <include file="classpath:db/changelog/pbc-finance/002-finance-status.xml"/> | 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 | <include file="classpath:db/changelog/pbc-production/001-production-init.xml"/> | 27 | <include file="classpath:db/changelog/pbc-production/001-production-init.xml"/> |
| 27 | <include file="classpath:db/changelog/pbc-production/002-production-v2.xml"/> | 28 | <include file="classpath:db/changelog/pbc-production/002-production-v2.xml"/> |
| 28 | <include file="classpath:db/changelog/pbc-production/003-production-v3.xml"/> | 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 | # pbc-finance metadata. | 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 | entities: | 3 | entities: |
| 11 | - name: JournalEntry | 4 | - name: JournalEntry |
| @@ -13,13 +6,27 @@ entities: | @@ -13,13 +6,27 @@ entities: | ||
| 13 | table: finance__journal_entry | 6 | table: finance__journal_entry |
| 14 | description: A single AR/AP journal entry derived from a sales- or purchase-order confirmation event | 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 | permissions: | 14 | permissions: |
| 17 | - key: finance.journal.read | 15 | - key: finance.journal.read |
| 18 | description: Read journal entries | 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 | menus: | 22 | menus: |
| 23 | + - path: /finance/accounts | ||
| 24 | + label: Chart of Accounts | ||
| 25 | + icon: calculator | ||
| 26 | + section: Finance | ||
| 27 | + order: 690 | ||
| 21 | - path: /finance/journal-entries | 28 | - path: /finance/journal-entries |
| 22 | - label: Journal entries | 29 | + label: Journal Entries |
| 23 | icon: book-open | 30 | icon: book-open |
| 24 | section: Finance | 31 | section: Finance |
| 25 | order: 700 | 32 | order: 700 |
platform/platform-bootstrap/src/main/kotlin/org/vibeerp/platform/bootstrap/web/SpaController.kt
| @@ -82,6 +82,7 @@ class SpaController { | @@ -82,6 +82,7 @@ class SpaController { | ||
| 82 | "/shop-floor", "/shop-floor/", "/shop-floor/**", | 82 | "/shop-floor", "/shop-floor/", "/shop-floor/**", |
| 83 | 83 | ||
| 84 | // Finance | 84 | // Finance |
| 85 | + "/accounts", "/accounts/", "/accounts/**", | ||
| 85 | "/journal-entries", "/journal-entries/", "/journal-entries/**", | 86 | "/journal-entries", "/journal-entries/", "/journal-entries/**", |
| 86 | 87 | ||
| 87 | // System / identity | 88 | // System / identity |
platform/platform-security/src/main/kotlin/org/vibeerp/platform/security/SecurityConfiguration.kt
| @@ -109,6 +109,7 @@ class SecurityConfiguration { | @@ -109,6 +109,7 @@ class SecurityConfiguration { | ||
| 109 | "/purchase-orders", "/purchase-orders/**", | 109 | "/purchase-orders", "/purchase-orders/**", |
| 110 | "/work-orders", "/work-orders/**", | 110 | "/work-orders", "/work-orders/**", |
| 111 | "/shop-floor", "/shop-floor/**", | 111 | "/shop-floor", "/shop-floor/**", |
| 112 | + "/accounts", "/accounts/**", | ||
| 112 | "/journal-entries", "/journal-entries/**", | 113 | "/journal-entries", "/journal-entries/**", |
| 113 | "/users", "/users/**", | 114 | "/users", "/users/**", |
| 114 | "/roles", "/roles/**", | 115 | "/roles", "/roles/**", |
web/src/App.tsx
| @@ -37,6 +37,7 @@ import { WorkOrdersPage } from '@/pages/WorkOrdersPage' | @@ -37,6 +37,7 @@ import { WorkOrdersPage } from '@/pages/WorkOrdersPage' | ||
| 37 | import { CreateWorkOrderPage } from '@/pages/CreateWorkOrderPage' | 37 | import { CreateWorkOrderPage } from '@/pages/CreateWorkOrderPage' |
| 38 | import { WorkOrderDetailPage } from '@/pages/WorkOrderDetailPage' | 38 | import { WorkOrderDetailPage } from '@/pages/WorkOrderDetailPage' |
| 39 | import { ShopFloorPage } from '@/pages/ShopFloorPage' | 39 | import { ShopFloorPage } from '@/pages/ShopFloorPage' |
| 40 | +import { AccountsPage } from '@/pages/AccountsPage' | ||
| 40 | import { JournalEntriesPage } from '@/pages/JournalEntriesPage' | 41 | import { JournalEntriesPage } from '@/pages/JournalEntriesPage' |
| 41 | 42 | ||
| 42 | export default function App() { | 43 | export default function App() { |
| @@ -76,6 +77,7 @@ export default function App() { | @@ -76,6 +77,7 @@ export default function App() { | ||
| 76 | <Route path="work-orders/new" element={<CreateWorkOrderPage />} /> | 77 | <Route path="work-orders/new" element={<CreateWorkOrderPage />} /> |
| 77 | <Route path="work-orders/:id" element={<WorkOrderDetailPage />} /> | 78 | <Route path="work-orders/:id" element={<WorkOrderDetailPage />} /> |
| 78 | <Route path="shop-floor" element={<ShopFloorPage />} /> | 79 | <Route path="shop-floor" element={<ShopFloorPage />} /> |
| 80 | + <Route path="accounts" element={<AccountsPage />} /> | ||
| 79 | <Route path="journal-entries" element={<JournalEntriesPage />} /> | 81 | <Route path="journal-entries" element={<JournalEntriesPage />} /> |
| 80 | </Route> | 82 | </Route> |
| 81 | <Route path="*" element={<Navigate to="/" replace />} /> | 83 | <Route path="*" element={<Navigate to="/" replace />} /> |
web/src/api/client.ts
| @@ -25,6 +25,7 @@ | @@ -25,6 +25,7 @@ | ||
| 25 | // underlying TypeError, also caught by the page boundary. | 25 | // underlying TypeError, also caught by the page boundary. |
| 26 | 26 | ||
| 27 | import type { | 27 | import type { |
| 28 | + Account, | ||
| 28 | Item, | 29 | Item, |
| 29 | JournalEntry, | 30 | JournalEntry, |
| 30 | Location, | 31 | Location, |
| @@ -276,6 +277,10 @@ export const production = { | @@ -276,6 +277,10 @@ export const production = { | ||
| 276 | // ─── Finance ───────────────────────────────────────────────────────── | 277 | // ─── Finance ───────────────────────────────────────────────────────── |
| 277 | 278 | ||
| 278 | export const finance = { | 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 | listJournalEntries: () => | 284 | listJournalEntries: () => |
| 280 | apiFetch<JournalEntry[]>('/api/v1/finance/journal-entries'), | 285 | apiFetch<JournalEntry[]>('/api/v1/finance/journal-entries'), |
| 281 | } | 286 | } |
web/src/layout/AppLayout.tsx
| @@ -63,7 +63,10 @@ const NAV: NavGroup[] = [ | @@ -63,7 +63,10 @@ const NAV: NavGroup[] = [ | ||
| 63 | }, | 63 | }, |
| 64 | { | 64 | { |
| 65 | heading: 'Finance', | 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 | heading: 'System', | 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,6 +250,17 @@ export interface ShopFloorEntry { | ||
| 250 | 250 | ||
| 251 | // ─── Finance (pbc-finance) ─────────────────────────────────────────── | 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 | export type JournalEntryType = 'AR' | 'AP' | 264 | export type JournalEntryType = 'AR' | 'AP' |
| 254 | export type JournalEntryStatus = 'POSTED' | 'SETTLED' | 'REVERSED' | 265 | export type JournalEntryStatus = 'POSTED' | 'SETTLED' | 'REVERSED' |
| 255 | 266 |
-
mentioned in commit 1f6482be