feat: Initial purchase order views, login seems to be broken though.

This commit is contained in:
2025-01-17 12:58:32 -05:00
parent e1d07008a1
commit be0b5a6033
18 changed files with 534 additions and 288 deletions

View File

@@ -28,8 +28,8 @@ struct PurchaseOrderApiController: RouteCollection {
@Sendable
func create(req: Request) async throws -> PurchaseOrder {
try await purchaseOrders.create(
req.content.decode(PurchaseOrder.Create.self),
req.auth.require(User.self).id
req.content.decode(PurchaseOrder.CreateIntermediate.self)
.toCreate(createdByID: req.auth.require(User.self).id)
)
}

View File

@@ -1,196 +1,103 @@
// import Dependencies
// import Fluent
// import Vapor
//
// struct PurchaseOrderViewController: RouteCollection {
// @Dependency(\.employees) var employees
// @Dependency(\.purchaseOrders) var purchaseOrders
// @Dependency(\.vendorBranches) var vendorBranches
//
// func boot(routes: any RoutesBuilder) throws {
// let pos = routes.protected.grouped("purchase-orders")
//
// pos.get(use: index(req:))
// pos.group("details", "close") {
// $0.get(use: detailClose(req:))
// }
// pos.post(use: create(req:))
// pos.group(":id") {
// $0.get(use: detail(req:))
// }
// }
//
// @Sendable
// func index(req: Request) async throws -> View {
// let params = try? req.query.decode(PurchaseOrderIndex.self)
// let purchaseOrdersPage = try await purchaseOrders.fetchPage(
// .init(page: params?.page ?? 1, per: params?.limit ?? 50)
// )
// let branches = try await vendorBranches.getBranches(req: req)
// let employees = try await employees.fetchAll()
// req.logger.debug("Branches: \(branches)")
// return try await req.view.render(
// "purchaseOrders/index",
// PurchaseOrderCTX(
// page: purchaseOrdersPage,
// form: .create(branches: branches, employees: employees)
// )
// )
// }
//
// @Sendable
// func detail(req: Request) async throws -> View {
// guard let id = req.parameters.get("id", as: PurchaseOrder.IDValue.self) else {
// throw Abort(.badRequest, reason: "Id not supplied.")
// }
// let purchaseOrder = try await purchaseOrders.get(id)
// return try await req.view.render("purchaseOrders/detail", ["purchaseOrderDetail": purchaseOrder])
// }
//
// @Sendable
// func detailClose(req: Request) async throws -> View {
// return try await req.view.render("purchaseOrders/detail")
// }
//
// @Sendable
// func create(req: Request) async throws -> View {
// try PurchaseOrder.FormCreate.validate(content: req)
// let createdById = try req.auth.require(User.self).requireID()
// let create = try req.content.decode(PurchaseOrder.FormCreate.self).toCreate()
// let purchaseOrder = try await purchaseOrders.create(create, createdById)
// return try await req.view.render("purchaseOrders/table-row", purchaseOrder)
// }
// }
//
// private struct PurchaseOrderIndex: Content {
// let page: Int?
// let limit: Int?
// }
//
// private struct PurchaseOrderCTX: Content {
// let purchaseOrderDetail: PurchaseOrder.DTO?
// let purchaseOrders: [PurchaseOrder.DTO]
// let page: Int
// let limit: Int
// let hasNext: Bool
// let hasPrevious: Bool
// let form: PurchaseOrderFormCTX?
//
// init(
// detail: PurchaseOrder.DTO? = nil,
// page: Page<PurchaseOrder.DTO>,
// form: PurchaseOrderFormCTX?
// ) {
// self.purchaseOrderDetail = detail
// self.purchaseOrders = page.items
// self.page = page.metadata.page
// self.limit = page.metadata.per
// self.hasNext = page.metadata.hasNext
// self.hasPrevious = page.metadata.page > 1
// self.form = form
// }
// }
//
// private extension PageMetadata {
// var hasNext: Bool {
// total > (page * per)
// }
// }
//
// private struct PurchaseOrderFormCTX: Content {
//
// let htmxForm: HtmxFormCTX<Context>
//
// struct Context: Content {
// let branches: [VendorBranch.FormDTO]
// let employees: [Employee.DTO]
// }
//
// static func create(branches: [VendorBranch.FormDTO], employees: [Employee.DTO]) -> Self {
// .init(htmxForm: .init(
// formClass: "po-form",
// formId: "po-form",
// htmxTargetUrl: .post("/purchase-orders"),
// htmxTarget: "#po-table-body",
// htmxPushUrl: false,
// htmxResetAfterRequest: true,
// htmxSwapOob: nil,
// htmxSwap: .afterbegin,
// context: .init(branches: branches, employees: employees)
// ))
// }
// }
//
// extension VendorBranch {
// struct FormDTO: Content {
// let id: UUID
// let name: String
// let vendor: Vendor.DTO
// }
//
// func toFormDTO() throws -> VendorBranch.FormDTO {
// try .init(
// id: requireID(),
// name: name,
// vendor: vendor.toDTO()
// )
// }
// }
//
// private extension PurchaseOrder {
// struct FormCreate: Content {
// let id: Int?
// let workOrder: String?
// let materials: String
// let customer: String
// let truckStock: Bool?
// let createdForID: Employee.IDValue
// let vendorBranchID: VendorBranch.IDValue
//
// // TODO: Remove.
// func toModel(createdByID: User.IDValue) -> PurchaseOrder {
// .init(
// id: id,
// workOrder: workOrder != nil ? (workOrder == "" ? nil : Int(workOrder!)) : nil,
// materials: materials,
// customer: customer,
// truckStock: truckStock ?? false,
// createdByID: createdByID,
// createdForID: createdForID,
// vendorBranchID: vendorBranchID,
// createdAt: nil,
// updatedAt: nil
// )
// }
//
// func toCreate() -> PurchaseOrder.Create {
// .init(
// id: id,
// workOrder: workOrder != nil ? (workOrder == "" ? nil : Int(workOrder!)) : nil,
// materials: materials,
// customer: customer,
// truckStock: truckStock,
// createdForID: createdForID,
// vendorBranchID: vendorBranchID
// )
// }
// }
// }
//
// private extension VendorBranchDB {
//
// func getBranches(req: Request) async throws -> [VendorBranch.FormDTO] {
// try await VendorBranch.query(on: req.db)
// .with(\.$vendor)
// .all()
// .map { try $0.toFormDTO() }
// }
// }
//
// extension PurchaseOrder.FormCreate: Validatable {
//
// static func validations(_ validations: inout Validations) {
// validations.add("materials", as: String.self, is: !.empty)
// validations.add("customer", as: String.self, is: !.empty)
// }
// }
import Dependencies
import Elementary
import SharedModels
import Vapor
import VaporElementary
struct PurchaseOrderViewController: RouteCollection {
@Dependency(\.database.employees) var employees
@Dependency(\.database.vendorBranches) var vendorBranches
@Dependency(\.database.purchaseOrders) var purchaseOrders
func boot(routes: any RoutesBuilder) throws {
let route = routes.protected.grouped("purchase-orders")
route.get(use: index)
route.get("next", use: nextPage)
route.post(use: create(req:))
route.group("create") {
$0.get(use: form)
$0.get("vendor-branch-select", use: vendorBranchSelect(req:))
$0.get("employee-select", use: employeeSelect(req:))
}
route.group(":id") {
$0.get(use: get)
}
}
@Sendable
func index(req: Request) async throws -> HTMLResponse {
let page = try? req.query.decode(IndexQuery.self)
return try await req.render { try await mainPage(PurchaseOrderForm(), page: page ?? .default) }
}
@Sendable
func nextPage(req: Request) async throws -> HTMLResponse {
let query = try req.query.decode(IndexQuery.self)
let page = try await purchaseOrders.fetchPage(.init(page: query.page, per: query.limit))
return await req.render { PurchaseOrderTable.Rows(page: page) }
}
@Sendable
func form(req: Request) async throws -> HTMLResponse {
// guard req.isHtmxRequest else {
// return try await req.render {
// try await mainPage(PurchaseOrderForm(shouldShow: true), page: .default)
// }
// }
return await req.render { PurchaseOrderForm(shouldShow: true) }
}
@Sendable
func vendorBranchSelect(req: Request) async throws -> HTMLResponse {
let branches = try await vendorBranches.fetchAllWithDetail()
return await req.render { PurchaseOrderForm.VendorSelect(vendorBranches: branches) }
}
@Sendable
func employeeSelect(req: Request) async throws -> HTMLResponse {
let employees = try await self.employees.fetchAll()
return await req.render { PurchaseOrderForm.EmployeeSelect(employees: employees) }
}
@Sendable
func get(req: Request) async throws -> HTMLResponse {
let purchaseOrder = try await purchaseOrders.get(req.ensureIDPathComponent(as: Int.self))
let form = PurchaseOrderForm(purchaseOrder: purchaseOrder, shouldShow: true)
guard req.isHtmxRequest else {
return try await req.render {
try await mainPage(form, page: .default)
}
}
return await req.render { form }
}
@Sendable
func create(req: Request) async throws -> HTMLResponse {
let create = try req.content.decode(PurchaseOrder.CreateIntermediate.self)
let user = try req.auth.require(User.self)
let purchaseOrder = try await purchaseOrders.create(create.toCreate(createdByID: user.id))
return await req.render { PurchaseOrderTable.Row(purchaseOrder: purchaseOrder) }
}
private func mainPage<C: HTML>(
_ html: C,
page: IndexQuery
) async throws -> some SendableHTMLDocument where C: Sendable {
let page = try await purchaseOrders.fetchPage(.init(page: page.page, per: page.limit))
return MainPage(displayNav: true, route: .purchaseOrders) {
div(.class("container")) {
html
PurchaseOrderTable(page: page)
}
}
}
}
struct IndexQuery: Content {
let page: Int
let limit: Int
static var `default`: Self {
.init(page: 1, limit: 25)
}
}

View File

@@ -9,15 +9,14 @@ extension RoutesBuilder {
// Used to ensure views are protected, redirects users to the login page if they're
// not authenticated.
var protected: any RoutesBuilder {
return self
// return grouped(
// UserPasswordAuthenticator(),
// UserTokenAuthenticator(),
// UserSessionAuthenticator(),
// User.redirectMiddleware { req in
// "login?next=\(req.url)"
// }
// )
// return self
return grouped(
UserPasswordAuthenticator(),
UserSessionAuthenticator(),
User.redirectMiddleware { req in
"login?next=\(req.url)"
}
)
}
func apiUnprotected(route: PathComponent) -> any RoutesBuilder {

View File

@@ -21,6 +21,15 @@
var vendors: [Vendor] = []
var vendorBranches: [VendorBranch] = []
let adminUser = User.Create(
username: Environment.get("ADMIN_USERNAME") ?? "admin",
email: Environment.get("ADMIN_EMAIL") ?? "admin@development.com",
password: Environment.get("ADMIN_PASSWORD") ?? "super-secret",
confirmPassword: Environment.get("ADMIN_PASSWORD") ?? "super-secret"
)
_ = try await database.users.create(adminUser)
for user in User.Create.generateMocks() {
let created = try await database.users.create(user)
users.append(created)
@@ -41,8 +50,13 @@
vendorBranches.append(created)
}
for purchaseOrder in PurchaseOrder.Create.generateMocks(employees: employees, vendorBranches: vendorBranches) {
_ = try await database.purchaseOrders.create(purchaseOrder, users.randomElement()!.id)
for purchaseOrder in PurchaseOrder.CreateIntermediate.generateMocks(
employees: employees,
vendorBranches: vendorBranches
) {
_ = try await database.purchaseOrders.create(
purchaseOrder.toCreate(createdByID: users.randomElement()!.id)
)
}
}
}

View File

@@ -28,7 +28,7 @@ struct MainPage<Inner: HTML>: SendableHTMLDocument where Inner: Sendable {
}
var body: some HTML {
header {
header(.class("header")) {
Logo()
if displayNav {
Navbar()

View File

@@ -0,0 +1,161 @@
import Elementary
import ElementaryHTMX
import SharedModels
struct PurchaseOrderForm: HTML {
let purchaseOrder: PurchaseOrder?
let shouldShow: Bool
init(purchaseOrder: PurchaseOrder? = nil, shouldShow: Bool = false) {
self.purchaseOrder = purchaseOrder
self.shouldShow = shouldShow
}
var content: some HTML {
Float(shouldDisplay: shouldShow, resetURL: "/purchase-orders") {
if shouldShow {
if purchaseOrder != nil {
p {
span(.class("label"), .style("margin-right: 15px;")) { "Note:" }
span { i(.style("font-size: 1em;")) {
"Vendor and Employee can not be changed once a purchase order has been created."
} }
}
}
form(
.hx.post("/purchase-orders"),
.hx.target("purchase-order-table"),
.hx.swap(.afterBegin.transition(true).swap("1s")),
.custom(
name: "hx-on::after-request",
value: "if (event.detail.successful) toggleContent('float'); window.location.href='/purchase-orders';"
)
) {
div(.class("row")) {
label(
.for("customer"), .class("label col-2"), .style("margin-right: 15px; margin-bottom: 5px;")
) { "Customer:" }
input(
.type(.text), .class("col-3"),
.name("customer"), .placeholder("Customer"),
.value(purchaseOrder?.customer ?? ""),
.required, .autofocus
)
label(
.for("workOrder"), .class("label col-2"), .style("margin-right: 15px; margin-bottom: 5px;")
) { "Work Order:" }
input(
.type(.text), .class("col-4"),
.name("workOrder"), .placeholder("Work Order: (12345)"),
.value("\(purchaseOrder?.workOrder != nil ? String(purchaseOrder!.workOrder!) : "")")
)
}
div(.class("row")) {
label(
.for("materials"), .class("label col-2"), .style("margin-right: 15px; margin-bottom: 5px;")
) { "Materials:" }
input(
.type(.text), .class("col-3"),
.name("materials"), .placeholder("Materials"),
.value(purchaseOrder?.materials ?? ""),
.required
)
label(
.for("vendorBranchID"), .class("label col-2"), .style("margin-right: 15px; margin-bottom: 5px;")
) { "Vendor:" }
if purchaseOrder == nil {
div(
.class("col-4"),
.hx.get("/purchase-orders/create/vendor-branch-select"),
.hx.target("this"),
.hx.swap(.outerHTML.transition(true).swap("0.5s")),
.hx.trigger(.event(.revealed)),
.hx.indicator(".hx-indicator")
) {
Img.spinner().attributes(.class("hx-indicator"), .style("float: left;"))
}
} else {
input(
.type(.text), .class("col-4"),
.name("vendorBranchID"),
.value("\(purchaseOrder!.vendorBranch.vendor.name) - \(purchaseOrder!.vendorBranch.name)"),
.disabled
)
}
}
div(.class("row")) {
label(
.for("createdForID"), .class("label col-2"), .style("margin-right: 15px; margin-bottom: 5px;")
) { "Employee:" }
if purchaseOrder == nil {
div(
.class("col-3"),
.hx.get("/purchase-orders/create/employee-select"),
.hx.target("this"),
.hx.swap(.outerHTML.transition(true).swap("0.5s")),
.hx.trigger(.event(.revealed)),
.hx.indicator(".hx-indicator")
) {
Img.spinner().attributes(.class("hx-indicator"), .style("float: left;"))
}
} else {
input(
.type(.text), .class("col-3"),
.value(purchaseOrder!.createdFor.fullName),
.disabled
)
}
label(
.for("truckStock"), .class("label col-2"), .style("margin-right: 15px; margin-bottom: 5px;")
) { "Truck Stock:" }
if purchaseOrder?.truckStock == true {
input(
.type(.checkbox), .class("col-2"), .name("truckStock"), .style("margin-top: 20px;"), .checked
)
} else {
input(
.type(.checkbox), .class("col-2"), .name("truckStock"), .style("margin-top: 20px;")
)
}
}
div(.class("btn-row")) {
button(.class("btn-primary"), .type(.submit)) { buttonLabel }
if purchaseOrder != nil {
Button.danger { "Delete" }
}
}
}
}
}
}
private var buttonLabel: String {
guard purchaseOrder != nil else { return "Create" }
return "Update"
}
struct VendorSelect: HTML {
let vendorBranches: [VendorBranch.Detail]
var content: some HTML<HTMLTag.select> {
select(.name("vendorBranchID"), .class("col-3")) {
for branch in vendorBranches {
option(.value(branch.id.uuidString)) { "\(branch.vendor.name) - \(branch.name)" }
}
}
}
}
struct EmployeeSelect: HTML {
let employees: [Employee]
var content: some HTML<HTMLTag.select> {
select(.name("createdForID"), .class("col-3")) {
for employee in employees {
option(.value(employee.id.uuidString)) { employee.fullName }
}
}
}
}
}

View File

@@ -0,0 +1,92 @@
import Elementary
import ElementaryHTMX
import Fluent
import SharedModels
import Vapor
struct PurchaseOrderTable: HTML {
let page: Page<PurchaseOrder>
var content: some HTML {
table {
thead {
tr {
th { "PO" }
th { "Work Order" }
th { "Customer" }
th { "Vendor" }
th { "Materials" }
th { "Created For" }
th {
Button.add()
.attributes(
.hx.get("/purchase-orders/create"),
.hx.target("#float"),
.hx.swap(.outerHTML.transition(true).swap("1s")),
.hx.pushURL(true)
)
}
}
}
tbody(.id("purchase-order-table")) {
Rows(page: page)
}
}
}
// Produces only the rows for the given page
struct Rows: HTML {
let page: Page<PurchaseOrder>
var content: some HTML {
for purchaseOrder in page.items {
Row(purchaseOrder: purchaseOrder)
}
if page.metadata.pageCount > page.metadata.page {
tr(
.hx.get("/purchase-orders/next?page=\(page.metadata.page + 1)&limit=\(page.metadata.per)"),
.hx.trigger(.event(.revealed)),
.hx.swap(.outerHTML.transition(true).swap("1s")),
.hx.target("this"),
.hx.indicator(".htmx-indicator")
) {
img(.src("/images/spinner.svg"), .class("htmx-indicator"), .width(60), .height(60))
}
}
}
}
// A single row.
struct Row: HTML {
let purchaseOrder: PurchaseOrder
var content: some HTML<HTMLTag.tr> {
tr(
.id("purchase_order_\(purchaseOrder.id)")
) {
td { "\(purchaseOrder.id)" }
td { purchaseOrder.workOrder != nil ? String(purchaseOrder.workOrder!) : "" }
td { purchaseOrder.customer }
td { purchaseOrder.vendorBranch.displayName }
td { purchaseOrder.materials }
td { purchaseOrder.createdFor.fullName }
td {
Button.detail()
.attributes(
.hx.get("/purchase-orders/\(purchaseOrder.id)"),
.hx.target("#float"),
.hx.swap(.outerHTML.transition(true).swap("0.5s")),
.hx.pushURL(true)
)
}
}
}
}
}
private extension VendorBranch.Detail {
var displayName: String {
"\(vendor.name.capitalized) - \(name.capitalized)"
}
}

View File

@@ -0,0 +1,7 @@
import Elementary
enum Img {
static func spinner(width: Int = 30, height: Int = 30) -> some HTML<HTMLTag.img> {
img(.src("/images/spinner.svg"), .width(width), .height(height))
}
}

View File

@@ -2,6 +2,7 @@ import DatabaseClientLive
import Dependencies
import Elementary
import Fluent
import SharedModels
import Vapor
import VaporElementary
@@ -10,7 +11,7 @@ func routes(_ app: Application) throws {
try app.register(collection: UserViewController())
try app.register(collection: VendorViewController())
try app.register(collection: EmployeeViewController())
// try app.register(collection: ViewController())
try app.register(collection: PurchaseOrderViewController())
app.get { _ in
HTMLResponse {
@@ -22,20 +23,37 @@ func routes(_ app: Application) throws {
}
}
app.get("login") { _ in
HTMLResponse {
app.get("login") { req in
let context = try req.query.decode(LoginContext.self)
return await req.render {
MainPage(displayNav: false, route: .login) {
UserForm(context: .login(next: nil))
UserForm(context: .login(next: context.next))
}
}
}
// app.get("users") { _ in
// HTMLResponse {
// // UserIndex()
// MainPage(displayNav: false, route: .users) {
// UserTable()
// }
// }
// }
app.post("login") { req in
@Dependency(\.database.users) var users
let loginForm = try req.content.decode(User.Login.self)
let token = try await users.login(loginForm)
let user = try await users.get(token.userID)!
req.session.authenticate(user)
return try await PurchaseOrderViewController().index(req: req)
}
let protected = app.grouped(UserPasswordAuthenticator(), UserSessionAuthenticator())
protected.get("me") { req in
let user = try req.auth.require(User.self)
return HTMLResponse {
MainPage(displayNav: false, route: .purchaseOrders) {
h1 { "You are logged in as: \(user.username)" }
}
}
}
}
private struct LoginContext: Content {
let next: String?
}