diff --git a/Public/css/main.css b/Public/css/main.css index be03492..b12bfbe 100644 --- a/Public/css/main.css +++ b/Public/css/main.css @@ -26,9 +26,11 @@ h1 { font-size: 2.5em; } overflow: hidden; } -header { +.header { + position: sticky; background-color: var(--dark-bg); color: var(--primary); + top: 0; padding: 10px 0; height: 60px; border-bottom: 1px solid grey; @@ -97,6 +99,10 @@ form { text-align: center; } +form .label { + text-align: right; +} + #user-form input { width: 100%; margin: 20px; @@ -142,6 +148,7 @@ select { padding: 10px 20px; width: 100%; margin-bottom: 10px; + font-size: 1.2em; } option { @@ -291,11 +298,12 @@ a.toggle, a img.toggle { .float { z-index: 1; position: absolute; - top: 60px; + top: 0; left: 0; width: 100%; background-color: #14141f; padding: 20px; + display: flex; } .float .closebtn { diff --git a/Sources/App/Controllers/Api/PurchaseOrderApiController.swift b/Sources/App/Controllers/Api/PurchaseOrderApiController.swift index ce84f34..c5c628b 100644 --- a/Sources/App/Controllers/Api/PurchaseOrderApiController.swift +++ b/Sources/App/Controllers/Api/PurchaseOrderApiController.swift @@ -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) ) } diff --git a/Sources/App/Controllers/View/PurchaseOrderViewController.swift b/Sources/App/Controllers/View/PurchaseOrderViewController.swift index 0c2fc19..da7aa0a 100644 --- a/Sources/App/Controllers/View/PurchaseOrderViewController.swift +++ b/Sources/App/Controllers/View/PurchaseOrderViewController.swift @@ -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, -// 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 -// -// 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( + _ 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) + } +} diff --git a/Sources/App/Extensions/RouteBuilder+protected.swift b/Sources/App/Extensions/RouteBuilder+protected.swift index da4f3f8..84efa26 100644 --- a/Sources/App/Extensions/RouteBuilder+protected.swift +++ b/Sources/App/Extensions/RouteBuilder+protected.swift @@ -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 { diff --git a/Sources/App/SeedCommand.swift b/Sources/App/SeedCommand.swift index efc3c79..c120122 100644 --- a/Sources/App/SeedCommand.swift +++ b/Sources/App/SeedCommand.swift @@ -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) + ) } } } diff --git a/Sources/App/Views/Main.swift b/Sources/App/Views/Main.swift index 61cfb20..f7b4739 100644 --- a/Sources/App/Views/Main.swift +++ b/Sources/App/Views/Main.swift @@ -28,7 +28,7 @@ struct MainPage: SendableHTMLDocument where Inner: Sendable { } var body: some HTML { - header { + header(.class("header")) { Logo() if displayNav { Navbar() diff --git a/Sources/App/Views/PurchaseOrders/PurchaseOrderForm.swift b/Sources/App/Views/PurchaseOrders/PurchaseOrderForm.swift new file mode 100644 index 0000000..b7b2c12 --- /dev/null +++ b/Sources/App/Views/PurchaseOrders/PurchaseOrderForm.swift @@ -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 { + 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 { + select(.name("createdForID"), .class("col-3")) { + for employee in employees { + option(.value(employee.id.uuidString)) { employee.fullName } + } + } + } + } +} diff --git a/Sources/App/Views/PurchaseOrders/PurchaseOrderTable.swift b/Sources/App/Views/PurchaseOrders/PurchaseOrderTable.swift new file mode 100644 index 0000000..65819b1 --- /dev/null +++ b/Sources/App/Views/PurchaseOrders/PurchaseOrderTable.swift @@ -0,0 +1,92 @@ +import Elementary +import ElementaryHTMX +import Fluent +import SharedModels +import Vapor + +struct PurchaseOrderTable: HTML { + + let page: Page + + 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 + + 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 { + 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)" + } +} diff --git a/Sources/App/Views/Utils/Img.swift b/Sources/App/Views/Utils/Img.swift new file mode 100644 index 0000000..64331a9 --- /dev/null +++ b/Sources/App/Views/Utils/Img.swift @@ -0,0 +1,7 @@ +import Elementary + +enum Img { + static func spinner(width: Int = 30, height: Int = 30) -> some HTML { + img(.src("/images/spinner.svg"), .width(width), .height(height)) + } +} diff --git a/Sources/App/routes.swift b/Sources/App/routes.swift index 9474696..df6107b 100644 --- a/Sources/App/routes.swift +++ b/Sources/App/routes.swift @@ -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? } diff --git a/Sources/DatabaseClient/PurchaseOrders.swift b/Sources/DatabaseClient/PurchaseOrders.swift index 9381372..1fcae86 100644 --- a/Sources/DatabaseClient/PurchaseOrders.swift +++ b/Sources/DatabaseClient/PurchaseOrders.swift @@ -7,7 +7,7 @@ import Vapor public extension DatabaseClient { @DependencyClient struct PurchaseOrders: Sendable { - public var create: @Sendable (PurchaseOrder.Create, User.ID) async throws -> PurchaseOrder + public var create: @Sendable (PurchaseOrder.Create) async throws -> PurchaseOrder public var fetchAll: @Sendable () async throws -> [PurchaseOrder] public var fetchPage: @Sendable (PageRequest) async throws -> Page public var get: @Sendable (PurchaseOrder.ID) async throws -> PurchaseOrder? diff --git a/Sources/DatabaseClient/VendorBranches.swift b/Sources/DatabaseClient/VendorBranches.swift index 47d1f59..3d57d0d 100644 --- a/Sources/DatabaseClient/VendorBranches.swift +++ b/Sources/DatabaseClient/VendorBranches.swift @@ -9,6 +9,7 @@ public extension DatabaseClient { public var create: @Sendable (VendorBranch.Create) async throws -> VendorBranch public var delete: @Sendable (VendorBranch.ID) async throws -> Void public var fetchAll: @Sendable (FetchRequest) async throws -> [VendorBranch] + public var fetchAllWithDetail: @Sendable () async throws -> [VendorBranch.Detail] public var get: @Sendable (VendorBranch.ID) async throws -> VendorBranch? public var update: @Sendable (VendorBranch.ID, VendorBranch.Update) async throws -> VendorBranch diff --git a/Sources/DatabaseClientLive/PurchaseOrders.swift b/Sources/DatabaseClientLive/PurchaseOrders.swift index f76d14f..028fae4 100644 --- a/Sources/DatabaseClientLive/PurchaseOrders.swift +++ b/Sources/DatabaseClientLive/PurchaseOrders.swift @@ -6,8 +6,8 @@ import SharedModels public extension DatabaseClient.PurchaseOrders { static func live(database: any Database) -> Self { - .init { create, createdById in - let model = try create.toModel(createdByID: createdById) + .init { create in + let model = try create.toModel() try await model.save(on: database) let fetched = try await PurchaseOrderModel.allQuery(on: database).filter(\.$id == model.id!).first()! return try fetched.toDTO() @@ -62,7 +62,7 @@ extension PurchaseOrder { extension PurchaseOrder.Create { - func toModel(createdByID: User.ID) throws -> PurchaseOrderModel { + func toModel() throws -> PurchaseOrderModel { try validate() return .init( materials: materials, @@ -157,7 +157,7 @@ final class PurchaseOrderModel: Model, Codable, @unchecked Sendable { truckStock: truckStock, createdBy: createdBy.toDTO(), createdFor: createdFor.toDTO(), - vendorBranch: vendorBranch.toDTO(), + vendorBranch: vendorBranch.toDetail(), createdAt: createdAt, updatedAt: updatedAt ) diff --git a/Sources/DatabaseClientLive/VendorBranches.swift b/Sources/DatabaseClientLive/VendorBranches.swift index 770797b..81968e6 100644 --- a/Sources/DatabaseClientLive/VendorBranches.swift +++ b/Sources/DatabaseClientLive/VendorBranches.swift @@ -34,6 +34,11 @@ public extension DatabaseClient.VendorBranches { } return try await query.all().map { try $0.toDTO() } + } fetchAllWithDetail: { + try await VendorBranchModel.query(on: db) + .with(\.$vendor) + .all() + .map { try $0.toDetail() } } get: { id in try await VendorBranchModel.find(id, on: db).map { try $0.toDTO() } } update: { id, updates in @@ -147,6 +152,16 @@ final class VendorBranchModel: Model, @unchecked Sendable { ) } + func toDetail() throws -> VendorBranch.Detail { + try .init( + id: requireID(), + name: name, + vendor: vendor.toDTO(), + createdAt: createdAt, + updatedAt: updatedAt + ) + } + func applyUpdates(_ updates: VendorBranch.Update) throws { try updates.validate() if let name = updates.name { diff --git a/Sources/SharedModels/Employee.swift b/Sources/SharedModels/Employee.swift index c254c63..5fa6bcb 100644 --- a/Sources/SharedModels/Employee.swift +++ b/Sources/SharedModels/Employee.swift @@ -24,6 +24,10 @@ public struct Employee: Codable, Equatable, Identifiable, Sendable { self.lastName = lastName self.updatedAt = updatedAt } + + public var fullName: String { + "\(firstName.capitalized) \(lastName.capitalized)" + } } public extension Employee { diff --git a/Sources/SharedModels/PurchaseOrder.swift b/Sources/SharedModels/PurchaseOrder.swift index 03745cd..642e5b6 100644 --- a/Sources/SharedModels/PurchaseOrder.swift +++ b/Sources/SharedModels/PurchaseOrder.swift @@ -10,7 +10,7 @@ public struct PurchaseOrder: Codable, Equatable, Identifiable, Sendable { public var truckStock: Bool public var createdBy: User public var createdFor: Employee - public var vendorBranch: VendorBranch + public var vendorBranch: VendorBranch.Detail public var createdAt: Date? public var updatedAt: Date? @@ -22,7 +22,7 @@ public struct PurchaseOrder: Codable, Equatable, Identifiable, Sendable { truckStock: Bool, createdBy: User, createdFor: Employee, - vendorBranch: VendorBranch, + vendorBranch: VendorBranch.Detail, createdAt: Date?, updatedAt: Date? ) { @@ -41,9 +41,40 @@ public struct PurchaseOrder: Codable, Equatable, Identifiable, Sendable { public extension PurchaseOrder { - // TODO: Add created by id. struct Create: Codable, Sendable { + public let id: Int? + public let workOrder: Int? + public let materials: String + public let customer: String + public let truckStock: Bool? + public let createdByID: User.ID + public let createdForID: Employee.ID + public let vendorBranchID: VendorBranch.ID + + public init( + id: Int? = nil, + workOrder: Int? = nil, + materials: String, + customer: String, + truckStock: Bool? = nil, + createdByID: User.ID, + createdForID: Employee.ID, + vendorBranchID: VendorBranch.ID + ) { + self.id = id + self.workOrder = workOrder + self.materials = materials + self.customer = customer + self.truckStock = truckStock + self.createdByID = createdByID + self.createdForID = createdForID + self.vendorBranchID = vendorBranchID + } + } + + struct CreateIntermediate: Codable, Sendable { + public let id: Int? public let workOrder: Int? public let materials: String @@ -69,44 +100,26 @@ public extension PurchaseOrder { self.createdForID = createdForID self.vendorBranchID = vendorBranchID } + + public func toCreate(createdByID userID: User.ID) -> PurchaseOrder.Create { + .init( + id: id, + workOrder: workOrder, + materials: materials, + customer: customer, + truckStock: truckStock, + createdByID: userID, + createdForID: createdForID, + vendorBranchID: vendorBranchID + ) + } } } #if DEBUG - // public extension PurchaseOrder { - // static func generateMocks( - // count: Int = 50, - // employees: [Employee], - // users: [User], - // vendorBranches: [VendorBranch] - // ) -> [Self] { - // @Dependency(\.date.now) var now - // @Dependency(\.uuid) var uuid - // - // var output = [Self]() - // - // for id in 0 ... count { - // output.append(.init( - // id: id, - // workOrder: Int.random(in: 0 ... 100), - // materials: "Some thing", - // customer: "\(RandomNames.firstNames.randomElement()!) \(RandomNames.lastNames.randomElement()!)", - // truckStock: Bool.random(), - // createdBy: users.randomElement()!, - // createdFor: employees.randomElement()!, - // vendorBranch: vendorBranches.randomElement()!, - // createdAt: now, - // updatedAt: now - // )) - // } - // - // return output - // } - // } - - public extension PurchaseOrder.Create { + public extension PurchaseOrder.CreateIntermediate { static func generateMocks( count: Int = 50, employees: [Employee], diff --git a/Sources/SharedModels/VendorBranch.swift b/Sources/SharedModels/VendorBranch.swift index 5af7c79..36f1d67 100644 --- a/Sources/SharedModels/VendorBranch.swift +++ b/Sources/SharedModels/VendorBranch.swift @@ -34,6 +34,28 @@ public extension VendorBranch { } } + struct Detail: Codable, Equatable, Identifiable, Sendable { + public var id: UUID + public var name: String + public var vendor: Vendor + public var createdAt: Date? + public var updatedAt: Date? + + public init( + id: UUID, + name: String, + vendor: Vendor, + createdAt: Date? = nil, + updatedAt: Date? = nil + ) { + self.id = id + self.name = name + self.vendor = vendor + self.createdAt = createdAt + self.updatedAt = updatedAt + } + } + struct Update: Codable, Sendable { public let name: String? @@ -44,30 +66,6 @@ public extension VendorBranch { } #if DEBUG - // public extension VendorBranch { - // - // static func generateMocks(countPerVendor: Int = 3, vendors: [Vendor]) -> [Self] { - // @Dependency(\.date.now) var now - // @Dependency(\.uuid) var uuid - // - // var output = [Self]() - // - // for vendor in vendors { - // for _ in 0 ... countPerVendor { - // output.append(.init( - // id: uuid(), - // name: RandomNames.cityNames.randomElement()!, - // vendorID: vendor.id, - // createdAt: now, - // updatedAt: now - // )) - // } - // } - // - // return output - // } - // } - public extension VendorBranch.Create { static func generateMocks(countPerVendor: Int = 3, vendors: [Vendor]) -> [Self] { diff --git a/justfile b/justfile new file mode 100644 index 0000000..80c46ac --- /dev/null +++ b/justfile @@ -0,0 +1,9 @@ + +seed: + swift run App seed + +rm-seed: + rm -rf seed.sqlite + +run: + ./swift-dev