LucaP The Chart of Accounts User interface.
#LucaP
Introduction
I’m building an ERP system from scratch. I’m on this journey to explore what happens under the hood in a classic ERP system, going module by module and showing you what you should be thinking about as you use them in more mundane ways, like customizations, integrations, and, of course, purchasing new systems. Last time we built the model for the Chart of Accounts, and it’s time to implement that in the user interface. We’ll review the finished template, why to use a template, add a few more fields, and then connect everything to make a usable Chart of Accounts module.
The Template
I spent a good part of the last year putting together the Luca ERP template. If you are using a database for a backend platform – and that is a very wise thing to do – about two-thirds of that work was unnecessary, as the elements of CRUD were already there. CRUD stands for Create, Read, Update and Delete, the four functions one needs to manipulate a set of data. I took the rough road and built all of that in SwiftData from scratch, for a few reasons, including promoting my course SwiftData essential training in the LinkedIn Learning library. More importantly, I wanted to get my hands dirty on building that back end.
Unfortunately, it took much longer than I expected due to a bug in the architecture of what I built. I discuss that in a previous newsletter if you are interested. Once I built that, I got the user interface together, changing my mind along the way from an unwieldy style system of fixed fields to using the Grid view of SwiftUI to simplify everything.
Why have a template? The answer is consistency and focus. The User interface for all parts of the application will look the same. More importantly, all the hard-to-do functions in each module, such as CRUD, are implemented once and applied to every module. For each module, I change the model, the user interface, and any small customizations for working with the model in a new copy of the template, then have a new module ready for production.
The template with demo code looks like this when I run it.
The title, toolbar, and bottom bar are all template code. They don’t change. The toolbar runs some functions in my code, and I will modify them if necessary.
In between is the body. There are three parts of the body code:
- The Form – this is one style of data presentation, used for a single record/row.
- The Table – this is another style of data presentation, used for multiple rows. You’ll see this as a child table to a form in a one-to-many relationship. The table is also the style for most reporting.
- The footer or summary – Used in conjunction with the table, it contains summary information for the columns in the table, such as sums, counts, or averages, though it could contain more. For example, on invoices, the table may contain line items, but a few parent fields, such as freight and taxes, might appear here.
Since consistency is part of the game, I also built a style sheet into the system. If my user interface were a web page, it would be in CSS. SwiftUI can create custom modifiers, which I discuss in detail in Chapter 12 of The Complete Guide to SwiftUI. I built custom colors and styles for Grid body elements for titles and fields, and styles for large and small buttons.
//
// LPStyleSheet.swift
// LucaPSwiftUI
//
// Created by Steven Lipton on 12/26/25.
//
import SwiftUI
struct SmallButtonStyle: ViewModifier{
func body(content: Content) -> some View {
content
.padding(5)
.frame(width:25,height:25)
.bold()
.foregroundStyle(.pitchBlack)
.border(.pitchBlack)
.background(.midMist)
}
}
struct SmallGridButtonStyle: ViewModifier{
func body(content: Content) -> some View {
content
.padding(6)
.frame(width:32,height:25)
.bold()
.foregroundStyle(.duskBlue)
.background(.mist)
.clipShape(RoundedRectangle(cornerRadius: 3))
}
}
struct ButtonStyle: ViewModifier{
var foregroundColor: Color
func body(content: Content) -> some View {
content
.padding()
.bold()
.foregroundStyle(foregroundColor)
.border(foregroundColor)
}
}
struct TitleGridStyle: ViewModifier{
var alignment: HorizontalAlignment
func body(content:Content) ->some View{
content.gridColumnAlignment(alignment).bold()
}
}
struct FieldGridStyle: ViewModifier{
var alignment: HorizontalAlignment
var borderColor: Color
func body(content:Content) ->some View{
content
.gridColumnAlignment(alignment)
.padding(5)
.border(borderColor)
.fontWeight(.regular)
}
}
extension View{
var smallButtonStyle: some View{
self.modifier(SmallButtonStyle())
}
var smallGridButtonStyle: some View{
self.modifier(SmallButtonStyle())
}
func buttonStyle(foreground:Color = .pitchBlack)-> some View{
self.modifier(ButtonStyle(foregroundColor: foreground))
}
func titleGridStyle(alignment:HorizontalAlignment = .trailing) -> some View{
self.modifier(TitleGridStyle(alignment:alignment))
}
func fieldGridStyle(alignment:HorizontalAlignment = .leading,borderColor:Color = .midMist) -> some View{
self.modifier(FieldGridStyle(alignment:alignment,borderColor: borderColor))
}
}
extension ShapeStyle where Self == Color{
static var pitchBlack: Color{
Color(red:0.078,green:0.075,blue:0.004)
}
static var darkRed: Color{
Color(red:0.573,green:0.078,blue:0.047)
}
static var midMist: Color{
Color(red:0.808,green:0.875,blue:0.851)
}
static var duskBlue: Color{
Color(red:0.180,green:0.314,blue:0.467)
}
static var mist: Color{
Color(red:0.922,green:0.988,blue:0.984)
}
}
Finally, there are body elements, namely buttons, that will have some function related to the table I’m using. selection, deletion, and inactivation of a record are the three functions, and I’ll uncomment them as needed for the module
All this lets me concentrate more on the module’s function and less on management or UI elements. I’ll make a copy of the template, rename it, and start populating it with the relevant information for the module.
More Fields
Before we go any further with all this, I want to take one step back. I mentioned roadmaps earlier this week. The roadmap for the next few newsletters to put the basic general ledger together actually looks like this:
I interlaced infrastructure into the general ledger so we have a context for those topics. In a normal development environment, I’d do those first. However, for LucaERP, having something to work with in each case will make this a lot easier to understand.
What I can see from this roadmap, however, is that there are a few things I may want to think about in advance. Finding my cash accounts for the cash flow report is one example. If other fields may be useful, adding them now saves work later. We’ll deal with data migration much later, but if I can prevent a migration problem up front, I’ll try to do so.
Another issue is the control account in reporting. I called them summary accounts last newsletter, but the technical term is control account. I can use some sophisticated logic to use the levels as I originally intended, but that’s a lot of work. It is much easier to have a flag indicating that something is a control account, and to handle these accounts in reporting and entry differently from transactional accounts.
So I’m adding two more flags to my model:
// more properties of the account for reporting purposes
var isControlAccount:Bool = false
var isCashAccount:Bool = false
Add the Model
We’re ready to add the models I created to the template. I made a folder for them in my application, and I can add them there.
Once the models are in, I register them with SwiftData:
.modelContainer(for: [
PModelPlacemarker.self, //used as a placemarker for the parentmodel
CModelPlacemarker.self, //used as a placemarker for the child model
LPChartOfAccounts.self, // G/L - Chart of Accounts
LPCompanyLocation.self, // G/L - Company Locations
])
Add the Fields
I’ll make a copy of the template next and name it LPChartOfAccountsView.
For the two primary tables, I set up a typealias for the one I’m using. There’s a placeholder if I am not. In this case, I only have a parent model that will use the form, so I’ll change ParentModel and leave ChildModel with the placeholder.
typealias ParentModel = LPChartOfAccounts
typealias ChildModel = CModelPlacemarker
Next, I’ll add the properties from LPChartOfAccounts to the view that I’ll be using. I don’t need the protocol properties—just the table ones.
@State var accountType: Int
@State var level1:Int
@State var level2:Int
@State var level3:Int
@State var location: Int
@State var accountLevel: Int
@State var accountName: String
@State var createDate:Date = Date()
@State var modifyDate:Date = Date()
// more properties of the account for reporting purposes
@State var isControlAccount:Bool = false
@State var isCashAccount:Bool = false
These will be our display copies.
Several reset and refresh methods transfer data from the table to these display variables, or reset them. I’ll fill out those methods next.
Here’s the reset method filled out:
func resetCurrentData(){
//TODO: add current data to reset here, use "", Date, etc. to reset
accountType = .asset
level1 = 0
level2 = 0
level3 = 0
location = 0
accountLevel = 3
accountName = ""
createDate = Date()
modifyDate = Date()
// more properties of the account for reporting purposes
isControlAccount = false
isCashAccount = false
// reset the key
currentKey = 0
}
I’ll take that from the parent record and populate my display variables when I load a parent record:
func refreshData(key:Int){
if let parentRecord = parentRecord(key:key){
currentChildSavedMax = parentRecord.childSavedMax
//TODO: Place the current data for the parent record here into state variables
accountType = parentRecord.accountType
level1 = parentRecord.level1
level2 = parentRecord.level2
level3 = parentRecord.level3
location = parentRecord.location
accountLevel = parentRecord.accountLevel
accountName = parentRecord.accountName
createDate = parentRecord.createDate
modifyDate = parentRecord.modifyDate
// more properties of the account for reporting purposes
isControlAccount = parentRecord.isControlAccount
isCashAccount = parentRecord.isControlAccount
} else { // place error data
resetCurrentData()
accountName = "Error - Record not found"
}
//not needed in this case, but left with the blank placeholder.
currentChildren = findChildren(parentKey: currentKey)
}
Display the Data
The data will be displayed in a grid. I only need the parent part this time, so I added the fields. In doing so, I realized I had forgotten something in the template: inactivating fields. This turns out to be more of a style sheet issue than a template issue, so I add a flag to the field modifier to inactivate and change the appearance of an inactive field.
Once added, it looks like this in the create mode:
When we inactivate fields is important. Deletion or changes to account numbers are not good for audit trails or transactions that depend on this account, so I’m going to prohibit them. Only in creation can you make an account number. Deletion will be forbidden. For updating, you can change the name only.
I can make a function that returns whether a field should or should not be active under those conditions:
func activeField(isActive:Bool) -> Bool{
return isActive && (currentMode == .create || currentMode == .update)
}
User-defined Keys
While the template was based on system-defined keys for each record, the Chart of Accounts is a user-defined key situation. Instead of incrementing to the next-largest value, the user will assign the key.
To make it more complex, the key consists of five fields. As we discussed last time, that means making a function to generate this account number. As we need an integer for the primary key, our version went like this:
var accountNumber: Int{
let actType = accountType.rawValue * 1000000000000
let l1 = level1 * 1000000000
let l2 = level2 * 1000000
let l3 = level3 * 1000
let loc = location
return actType + l1 + l2 + l3 + loc
}
As I change the five fields, I’ll update the current key to account number. For example, I did this to the accountType
Text("Level 1").titleGridStyle()
TextField("Level1", value: $level1, formatter: threeDigitFormatter)
.fieldGridStyle(isActive: activeField(isActive:currentIsActive))
.onChange(of:level1){ currentKey = accountNumber}
With this in place for each field, the currentKey, which I’ll save in create, will set the correct account number as the key.
Despite this user-defined key, you will find in many modern systems, and something SwiftData does for you: there is another key called id besides our user-generated one. SwiftData always generates a unique identifier, a UUID, for every record. If we can ensure the user key won’t change by prohibiting changes, we can use id for much faster, easier searches and references. We’ll come back to this later and in the transaction journal, but I’ll refer to each account through account.id instead of account.key in the journal.
The Chicken before the Horse: Credits and Debits
Dependencies can work both ways and can cause problems when figuring out the order to build modules. It is both a chicken-and-egg problem and a cart-before-the-horse problem, so I refer to it as a chicken-before-the-horse problem: Which exists first, and in what order do I place things? We run into that with a feature I want to add to the Chart of Accounts: the total debits and credits for that account. That would be the total from the journal transactions, but I haven’t made any yet, and I can’t make them without the Chart of Accounts.
TO handle this, I do the primary function first: get the Chart of accounts running without totals. Totals are a decoration on top of that, so they have a lower priority. I’ll come back later and add totals once the transaction journal is up and running.
Add the Actions
Several actions need to be changed in the template. Create, Delete, and Update work very differently from what the template assumes.
For a user-defined key, we won’t use all the savedMax and currentKey stuff when creating. Instead, we have something very simple:
currentKey = 0
It starts blank every time, and changes during entry. Every time we submit a new record, we’ll check for a duplicate key. If there is one, we reject the submission and send an error.
Once we have transaction entries, there will be many dependencies on the Chart of Accounts. For that reason, I never want to delete a chart of account entry. However, users may have added an account in error. We might still want to delete an account that has never been used. We have the chicken-before-the-horse problem again: we need to see transactions to determine whether the account was used. For now, we prohibit all deletions of accounts.
I could set a flag for this in the Chart of Accounts that gets turned off at the first transaction, but I prefer a different approach: a deletion utility. This approach is a security measure. No user but a superuser or administrator should be able to delete an account. I’ll eventually add it to the utilities and settings module, which I’ll control by user privilege level.
As far as users are concerned, the only thing in the delete is a message you cannot delete.
errorMessage = "Deletions not allowed in Chart of Accounts"
The last change is to update. Much of the table is columns to create the account number, which we don’t allow. The only two things that we’ll allow to change after creation are the name and whether it is a cash account. These only affect reporting and do not cause damage to the accounts.
The Okay Button
Much of the change for create, update, and delete occurs in the OK button actions. In create mode, we check for a unique key, create a new record, populate it with our key and display properties, and display it. We insert the record into the table.
func appendParentRecord(){
//check for uniqueness
if parentModel.contains(where:{$0.key == accountNumber}){
errorMessage = "Account already exists"
return
}
let newRecord = ParentModel()
//TODO: Insert the properties to save into `newRecord.`
// the protocol ones
newRecord.key = accountNumber
//columns in the table
newRecord.accountType = accountType
newRecord.level1 = level1
newRecord.level2 = level2
newRecord.level3 = level3
newRecord.location = location
newRecord.accountLevel = accountLevel
newRecord.accountName = accountName
newRecord.createDate = Date()
newRecord.modifyDate = modifyDate
newRecord.isControlAccount = isControlAccount
newRecord.isCashAccount = isCashAccount
//insert the new record
modelContext.insert(newRecord)
}
The original plan for Update mode does not work here. First of all, there’s a bug I hadn’t thought about. I deleted and inserted the new record. That will change the value of id and mess up my plans for’ id’ in othere tables as a primary key. I need to grab the record, modify it, and save it instead.
I am also changing only two things here, so I only do the assignments for those two.
Finding records
The last function we’ll deal with is find. I’ll want a way to jump to an account quickly. Once set up, there will be many accounts. I’m going to use the account type to filter the results to that account type, reducing the number in the list. Clicking an item on the list, then clicking okay, displays the appropriate record.
I’ll display this in another window above my current one,
One Last Cosmetic Change
To differentiate between active and inactive fields, I’m using a custom style, but it doesn’t look good on pickers because they dim too much.
So I’ll change it to use a text field when inactive and a picker when active.
Text("Level").titleGridStyle()
if activeField(isActive:currentIsActive){
Picker("Level", selection: $accountLevel){
Text("Level 1").tag(1)
Text("Level 2").tag(2)
Text("Level 3").tag(3)
}
.fieldGridStyle(isActive: true)
} else {
Text("Level \(accountLevel)")
.fieldGridStyle(isActive:false)
}
the result
And with that, we have our Chart of Accounts. The creation mode looks like this:
A viewed account looks like this:
An editing account has a mix of active and inactive fields.
Next steps
With that, we’ve done as much as we can on this round. I could start adding accounts, but that will take some time. One of our next two tasks is to set up our first import system, a way to pull data from an external source and populate the Chart of Accounts. We also have to set up utilities for account deletion, addition, and location editing. That requires two more infrastructure pieces before we even get to the import: the menu and the users. In our next LucaP newsletter, we’ll tackle menus and user privileges.



























