feat: working on detail views.
This commit is contained in:
@@ -147,13 +147,17 @@ input[type=text]:focus, input[type=password]:focus, input[type=email]:focus {
|
||||
background-color: #555;
|
||||
}
|
||||
|
||||
.toggle img {
|
||||
.toggle, .toggle img {
|
||||
background-color: inherit;
|
||||
width: 60px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
a.toggle, a img.toggle {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
|
||||
.toggle img:hover {
|
||||
background-color: #555;
|
||||
}
|
||||
@@ -221,13 +225,15 @@ tr.htmx-swapping td {
|
||||
overflow: auto;
|
||||
z-index: 1;
|
||||
position: fixed;
|
||||
top: 100px;
|
||||
top: 60px;
|
||||
left: 0;
|
||||
background-color: #14141f;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.closebtn {
|
||||
border: none;
|
||||
background-color: inherit;
|
||||
color: grey;
|
||||
margin-left: 50px;
|
||||
text-decoration: none;
|
||||
@@ -309,10 +315,7 @@ tr.htmx-swapping td {
|
||||
background-color: inherit;
|
||||
font-size: 1.3em;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.btn-row button:hover {
|
||||
color: blue;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-detail {
|
||||
@@ -336,3 +339,72 @@ tr.htmx-swapping td {
|
||||
.label {
|
||||
color: #00ffcc;
|
||||
}
|
||||
|
||||
.float {
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
top: 60px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background-color: #14141f;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.float .closebtn {
|
||||
position: relative;
|
||||
float: right;
|
||||
top: 0;
|
||||
right: 5px;
|
||||
font-size: 36px;
|
||||
margin-left: 50px;
|
||||
color: grey;
|
||||
}
|
||||
|
||||
.float .closebtn:hover {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.float.htmx-swapping {
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s ease-out;
|
||||
}
|
||||
|
||||
.float table {
|
||||
position: relative;
|
||||
top: 15px;
|
||||
}
|
||||
|
||||
.btn-row {
|
||||
margin-top: 40px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.btn-row button {
|
||||
float: right;
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
button.danger {
|
||||
background-color: #ff4d4d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
button.edit {
|
||||
float: right;
|
||||
background-color: #9999ff;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.danger:hover, .edit:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
form table input[type=text] {
|
||||
border: none;
|
||||
border-bottom: none;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
63
Resources/Views/employees/detail.leaf
Normal file
63
Resources/Views/employees/detail.leaf
Normal file
@@ -0,0 +1,63 @@
|
||||
<div id="employee-detail" class="float" #if(!employee): style="display:none;" #endif>
|
||||
#if(employee):
|
||||
<button class="closebtn"
|
||||
hx-get="/employees"
|
||||
hx-target="body"
|
||||
hx-push-url="true"
|
||||
hx-swap="outerHTML"
|
||||
hx-disable-elt="this"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<form hx-put="/employees/#(employee.id)"
|
||||
hx-target="#employee_#(employee.id)"
|
||||
hx-swap="outerHTML"
|
||||
hx-disable-elt="this"
|
||||
hx-on::after-request="window.location.href='/employees'; toggleContent('employee-detail');"
|
||||
>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="width: 20%;"><h3 class="label">First Name:</h3></td>
|
||||
<td><input type="text" name="firstName" value="#(employee.firstName)"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><h3 class="label">Last Name:</h3></td>
|
||||
<td><input type="text" name="lastName" value="#(employee.lastName)"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><h3 class="label">Active:</h3></td>
|
||||
<td>
|
||||
#if(employee.active):
|
||||
<a class="toggle"
|
||||
hx-patch="/employees/#(id)/toggle-active"
|
||||
hx-target="#employee_#(id)"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<img class="toggle" src="/images/toggle-on.svg" alt="Active">
|
||||
</a>
|
||||
#else:
|
||||
<a class="toggle"
|
||||
hx-patch="/employees/#(id)/toggle-active"
|
||||
hx-target="#employee_#(id)"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
<img class="toggle" src="/images/toggle-off.svg" alt="Active">
|
||||
</a>
|
||||
#endif
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="btn-row">
|
||||
<button class="edit"
|
||||
type="submit"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
<button class="danger">Delete</button>
|
||||
</div>
|
||||
</form>
|
||||
#endif
|
||||
</div>
|
||||
@@ -7,6 +7,7 @@
|
||||
<h3>Employees are who purchase orders can be generated for.</h3>
|
||||
<br>
|
||||
</div>
|
||||
#extend("employees/detail")
|
||||
#extend("form-container"): #export("formContent"):
|
||||
#extend("employees/form", form)
|
||||
#endexport #endextend
|
||||
|
||||
@@ -19,22 +19,33 @@
|
||||
</a>
|
||||
#endif
|
||||
</td>
|
||||
<td style="width: 100px;">
|
||||
<a class="btn btn-delete"
|
||||
href="javascript:void(0)"
|
||||
hx-delete="/employees/#(id)"
|
||||
hx-target="#employee-table"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to delete this employee?"
|
||||
<td style="width: 50px;">
|
||||
<button hx-get="/employees/#(id)"
|
||||
hx-target="#employee-detail"
|
||||
hx-swap="outerHTML swap:0.5s"
|
||||
hx-push-url="true"
|
||||
class="btn btn-detail"
|
||||
>
|
||||
#extend("img/trash-can")
|
||||
</a>
|
||||
<a class="btn btn-edit" hx-get="/employees/#(id)"
|
||||
hx-target="#employee-form"
|
||||
hx-on::after-request=" if(event.detail.successful) toggleContent('form')"
|
||||
>
|
||||
#extend("img/pencil")
|
||||
</a>
|
||||
›
|
||||
</button>
|
||||
</td>
|
||||
|
||||
<!-- <td style="width: 100px;"> -->
|
||||
<!-- <a class="btn btn-delete" -->
|
||||
<!-- href="javascript:void(0)" -->
|
||||
<!-- hx-delete="/employees/#(id)" -->
|
||||
<!-- hx-target="#employee-table" -->
|
||||
<!-- hx-swap="outerHTML" -->
|
||||
<!-- hx-confirm="Are you sure you want to delete this employee?" -->
|
||||
<!-- > -->
|
||||
<!-- #extend("img/trash-can") -->
|
||||
<!-- </a> -->
|
||||
<!-- <a class="btn btn-edit" hx-get="/employees/#(id)" -->
|
||||
<!-- hx-target="#employee-form" -->
|
||||
<!-- hx-on::after-request=" if(event.detail.successful) toggleContent('form')" -->
|
||||
<!-- > -->
|
||||
<!-- #extend("img/pencil") -->
|
||||
<!-- </a> -->
|
||||
<!-- </td> -->
|
||||
|
||||
</tr>
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<script src="js/main.js"></script>
|
||||
<link rel="stylesheet" href="css/main.css">
|
||||
<script src="/js/main.js"></script>
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<title>#(title)</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,46 +1,56 @@
|
||||
<div id="po-detail" class="container">
|
||||
<div id="po-detail" class="container float">
|
||||
#if(purchaseOrderDetail):
|
||||
<a href="javascript:void(0)"
|
||||
class="closebtn"
|
||||
hx-get="/purchase-orders/details/close"
|
||||
hx-target="#po-detail"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
×
|
||||
</a>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="label"><h3>Purchase Order:</h3></td>
|
||||
<td><h3>#(purchaseOrder.id)</h3></td>
|
||||
<td><h3>#(purchaseOrderDetail.id)</h3></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label"><h3>Work Order:</h3></td>
|
||||
<td><h3>#(purchaseOrder.workOrder)</h3></td>
|
||||
<td><h3>#(purchaseOrderDetail.workOrder)</h3></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label"><h3>Customer:</h3></td>
|
||||
<td><h3>#(purchaseOrder.customer)</h3></td>
|
||||
<td><h3>#(purchaseOrderDetail.customer)</h3></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label"><h3>Vendor:</h3></td>
|
||||
<td><h3>#capitalized(purchaseOrder.vendorBranch.vendor.name) - #capitalized(purchaseOrder.vendorBranch.name)</h3></td>
|
||||
<td><h3>#capitalized(purchaseOrderDetail.vendorBranch.vendor.name) - #capitalized(purchaseOrderDetail.vendorBranch.name)</h3></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label"><h3>Materials:</h3></td>
|
||||
<td><h3>#(purchaseOrder.materials)<h3></td>
|
||||
<td><h3>#(purchaseOrderDetail.materials)<h3></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label"><h3>Created For:</h3></td>
|
||||
<td><h3>#capitalized(purchaseOrder.createdFor.firstName) #capitalized(purchaseOrder.createdFor.lastName)</h3></td>
|
||||
<td><h3>#capitalized(purchaseOrderDetail.createdFor.firstName) #capitalized(purchaseOrderDetail.createdFor.lastName)</h3></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label"><h3>Truck Stock:</h3></td>
|
||||
<td><h3>#capitalized(purchaseOrder.truckStock)</h3></td>
|
||||
<td><h3>#capitalized(purchaseOrderDetail.truckStock)</h3></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label"><h3>Created By:</h3></td>
|
||||
<td><h3>#(purchaseOrder.createdBy.username)</h3></td>
|
||||
<td><h3>#(purchaseOrderDetail.createdBy.username)</h3></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label"><h3>Date:</h3></td>
|
||||
<td><h3>#date(purchaseOrder.createdAt)<h3></td>
|
||||
<td><h3>#date(purchaseOrderDetail.createdAt, "MM-dd-yyyy")<h3></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label"><h3>Updated:</h3></td>
|
||||
<td><h3>#date(purchaseOrder.updatedAt)<h3></td>
|
||||
<td><h3>#date(purchaseOrderDetail.updatedAt, "MM-dd-yyyy")<h3></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
#endif
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,16 @@
|
||||
#extend("form-container"): #export("formContent"):
|
||||
#extend("purchaseOrders/form", form)
|
||||
#endexport #endextend
|
||||
<div class="float"
|
||||
id="po-detail"
|
||||
#if(purchaseOrderDetail):
|
||||
hx-get="/purchase-orders/#(purchaseOrderDetail.id)"
|
||||
hx-target="this"
|
||||
hx-trigger="load"
|
||||
hx-swap="outerHTML"
|
||||
#endif
|
||||
>
|
||||
</div>
|
||||
<div class="btn-row">
|
||||
#if(hasPrevious):
|
||||
<button hx-get="/purchase-orders?page=#(page - 1)&limit=#(limit)"
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
<td>#(createdBy.username)</td>
|
||||
<td>#capitalized(truckStock)</td>
|
||||
<td style="text-align: center;">
|
||||
<!-- TODO: add buttons here -->
|
||||
<button class="btn btn-detail"
|
||||
hx-get="/purchase-orders/#(id)"
|
||||
hx-target="#home-content"
|
||||
hx-target="#po-detail"
|
||||
hx-push-url="true"
|
||||
hx-swap="outerHTML"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
|
||||
36
Resources/Views/users/detail.leaf
Normal file
36
Resources/Views/users/detail.leaf
Normal file
@@ -0,0 +1,36 @@
|
||||
<div id="user-detail" class="float" #if(!user): style="display:none;" #endif>
|
||||
#if(user):
|
||||
<button class="closebtn"
|
||||
hx-get="/users"
|
||||
hx-target="body"
|
||||
hx-push-url="true"
|
||||
hx-swap="outerHTML"
|
||||
hx-disable-elt="this"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="label"><h3>Username:</h3></td>
|
||||
<td><h3>#(user.username)</h3></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label"><h3>Email:</h3></td>
|
||||
<td><h3>#(user.email)</h3></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label"><h3>Created:</h3></td>
|
||||
<td><h3>#date(user.createdAt, "MM-dd-yyyy")</h3></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="label"><h3>Updated:</h3></td>
|
||||
<td><h3>#date(user.updatedAt, "MM-dd-yyyy")</h3></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="btn-row user-buttons">
|
||||
<button class="danger">Delete</button>
|
||||
</div>
|
||||
#endif
|
||||
</div>
|
||||
@@ -7,9 +7,11 @@
|
||||
<p>Users are people that can login and generate puchase orders for employees.</p>
|
||||
<br>
|
||||
</div>
|
||||
#extend("users/detail")
|
||||
#extend("form-container"): #export("formContent"):
|
||||
#extend("users/form", form)
|
||||
#endexport #endextend
|
||||
|
||||
#extend("users/table")
|
||||
</div>
|
||||
#endexport
|
||||
|
||||
14
Resources/Views/users/table-row.leaf
Normal file
14
Resources/Views/users/table-row.leaf
Normal file
@@ -0,0 +1,14 @@
|
||||
<tr id="user_#(id)">
|
||||
<td>#(username)</td>
|
||||
<td>#(email)</td>
|
||||
<td style="width: 50px;">
|
||||
<button hx-get="/users/#(id)"
|
||||
hx-target="#user-detail"
|
||||
hx-swap="outerHTML swap:0.5s"
|
||||
hx-push-url="true"
|
||||
class="btn btn-detail"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -8,20 +8,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
#for(user in users):
|
||||
<tr id="user_#(user.id)">
|
||||
<td>#(user.username)</td>
|
||||
<td>#(user.email)</td>
|
||||
<td style="width: 50px;">
|
||||
<a class="btn btn-delete"
|
||||
hx-delete="/users/#(user.id)"
|
||||
hx-target="#user-table"
|
||||
hx-swap="outerHTML"
|
||||
hx-confirm="Are you sure you want to delete this user?"
|
||||
>
|
||||
<img src="images/trash-can.svg" alt="Delete">
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
#extend("users/table-row", user)
|
||||
#endfor
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -20,7 +20,7 @@ struct EmployeeApiController: RouteCollection {
|
||||
@Sendable
|
||||
func index(req: Request) async throws -> [Employee.DTO] {
|
||||
let params = try req.query.decode(EmployeesIndexQuery.self)
|
||||
return try await employees.fetchAll(params.active ?? false)
|
||||
return try await employees.fetchAll(params.active == true ? .active : .default)
|
||||
}
|
||||
|
||||
@Sendable
|
||||
|
||||
@@ -31,7 +31,7 @@ struct VendorBranchApiController: RouteCollection {
|
||||
guard let id = req.parameters.get("vendorID", as: Vendor.IDValue.self) else {
|
||||
throw Abort(.badRequest, reason: "Vendor id not provided.")
|
||||
}
|
||||
return try await vendorBranches.fetchForVendor(id)
|
||||
return try await vendorBranches.fetchAll(.for(vendorID: id))
|
||||
}
|
||||
|
||||
@Sendable
|
||||
|
||||
@@ -13,7 +13,8 @@ struct EmployeeViewController: RouteCollection {
|
||||
protected.get("form", use: employeeForm(req:))
|
||||
protected.post(use: create(req:))
|
||||
protected.group(":employeeID") {
|
||||
$0.get(use: edit(req:))
|
||||
$0.get(use: get(req:))
|
||||
$0.get("edit", use: edit(req:))
|
||||
$0.delete(use: delete(req:))
|
||||
$0.put(use: update(req:))
|
||||
$0.patch("toggle-active", use: toggleActive(req:))
|
||||
@@ -22,15 +23,36 @@ struct EmployeeViewController: RouteCollection {
|
||||
|
||||
@Sendable
|
||||
func index(req: Request) async throws -> View {
|
||||
return try await req.view.render("employees/index", EmployeesCTX(db: employees))
|
||||
return try await renderIndex(req)
|
||||
}
|
||||
|
||||
@Sendable
|
||||
private func renderIndex(
|
||||
_ req: Request,
|
||||
_ employee: Employee.DTO? = nil,
|
||||
_ form: EmployeeFormCTX? = nil
|
||||
) async throws -> View {
|
||||
return try await req.view.render(
|
||||
"employees/index",
|
||||
EmployeesCTX(employee: employee, employees: employees.fetchAll(), form: form ?? .init())
|
||||
)
|
||||
}
|
||||
|
||||
@Sendable
|
||||
func create(req: Request) async throws -> View {
|
||||
try Employee.Create.validate(content: req)
|
||||
let model = try req.content.decode(Employee.Create.self)
|
||||
_ = try await employees.create(model)
|
||||
return try await req.view.render("employees/index", EmployeesCTX(oob: true, db: employees))
|
||||
let employee = try await employees.create(req.content.decode(Employee.Create.self))
|
||||
return try await req.view.render("employees/table-row", employee)
|
||||
}
|
||||
|
||||
@Sendable
|
||||
func get(req: Request) async throws -> View {
|
||||
let employee = try await employees.get(req.ensureIDPathComponent(key: "employeeID"))
|
||||
// Check if we've rendered the page yet.
|
||||
guard req.isHtmxRequest else {
|
||||
return try await renderIndex(req, employee)
|
||||
}
|
||||
return try await req.view.render("employees/detail", ["employee": employee])
|
||||
}
|
||||
|
||||
@Sendable
|
||||
@@ -42,6 +64,7 @@ struct EmployeeViewController: RouteCollection {
|
||||
return try await req.view.render("employees/table-row", employee)
|
||||
}
|
||||
|
||||
// TODO: I think we can just return a response and remove the table-row, here.
|
||||
@Sendable
|
||||
func delete(req: Request) async throws -> View {
|
||||
let id = try req.requireEmployeeID()
|
||||
@@ -55,7 +78,7 @@ struct EmployeeViewController: RouteCollection {
|
||||
guard let employee = try await employees.get(req.parameters.get("employeeID")) else {
|
||||
throw Abort(.notFound)
|
||||
}
|
||||
return try await req.view.render("employees/form", EmployeeFormCTX(employee: employee))
|
||||
return try await req.view.render("employees/detail", EmployeeDetailCTX(editing: true, employee: employee))
|
||||
}
|
||||
|
||||
@Sendable
|
||||
@@ -63,8 +86,10 @@ struct EmployeeViewController: RouteCollection {
|
||||
let id = try req.requireEmployeeID()
|
||||
try Employee.Update.validate(content: req)
|
||||
let updates = try req.content.decode(Employee.Update.self)
|
||||
_ = try await employees.update(id, updates)
|
||||
return try await req.view.render("employees/index", EmployeesCTX(oob: true, db: employees))
|
||||
req.logger.info("Employee updates: \(updates)")
|
||||
let employee = try await employees.update(id, updates)
|
||||
req.logger.info("Done updating employee: \(employee)")
|
||||
return try await req.view.render("employees/table-row", employee)
|
||||
}
|
||||
|
||||
@Sendable
|
||||
@@ -83,19 +108,29 @@ private extension Request {
|
||||
}
|
||||
}
|
||||
|
||||
private struct EmployeeDetailCTX: Content {
|
||||
let editing: Bool
|
||||
let employee: Employee.DTO?
|
||||
|
||||
init(editing: Bool = false, employee: Employee.DTO? = nil) {
|
||||
self.editing = editing
|
||||
self.employee = employee
|
||||
}
|
||||
}
|
||||
|
||||
private struct EmployeesCTX: Content {
|
||||
let oob: Bool
|
||||
let employee: Employee.DTO?
|
||||
let employees: [Employee.DTO]
|
||||
let form: EmployeeFormCTX
|
||||
|
||||
init(
|
||||
oob: Bool = false,
|
||||
employee: Employee? = nil,
|
||||
db: EmployeeDB
|
||||
) async throws {
|
||||
self.oob = oob
|
||||
self.employees = try await db.fetchAll()
|
||||
self.form = .init(employee: employee.map { $0.toDTO() })
|
||||
employee: Employee.DTO? = nil,
|
||||
employees: [Employee.DTO],
|
||||
form: EmployeeFormCTX? = nil
|
||||
) {
|
||||
self.employee = employee
|
||||
self.employees = employees
|
||||
self.form = form ?? .init()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,16 +3,17 @@ import Fluent
|
||||
import Vapor
|
||||
|
||||
struct PurchaseOrderViewController: RouteCollection {
|
||||
@Dependency(\.employees) var employees
|
||||
@Dependency(\.purchaseOrders) var purchaseOrders
|
||||
|
||||
private let employeesApi = EmployeeApiController()
|
||||
private let branches = VendorBranchDB()
|
||||
private let api = ApiController()
|
||||
@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:))
|
||||
@@ -25,8 +26,8 @@ struct PurchaseOrderViewController: RouteCollection {
|
||||
let purchaseOrdersPage = try await purchaseOrders.fetchPage(
|
||||
.init(page: params?.page ?? 1, per: params?.limit ?? 50)
|
||||
)
|
||||
let branches = try await self.branches.getBranches(req: req)
|
||||
let employees = try await employeesApi.index(req: req)
|
||||
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",
|
||||
@@ -43,7 +44,12 @@ struct PurchaseOrderViewController: RouteCollection {
|
||||
throw Abort(.badRequest, reason: "Id not supplied.")
|
||||
}
|
||||
let purchaseOrder = try await purchaseOrders.get(id)
|
||||
return try await req.view.render("purchaseOrders/detail", ["purchaseOrder": purchaseOrder])
|
||||
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
|
||||
@@ -62,6 +68,7 @@ private struct PurchaseOrderIndex: Content {
|
||||
}
|
||||
|
||||
private struct PurchaseOrderCTX: Content {
|
||||
let purchaseOrderDetail: PurchaseOrder.DTO?
|
||||
let purchaseOrders: [PurchaseOrder.DTO]
|
||||
let page: Int
|
||||
let limit: Int
|
||||
@@ -69,7 +76,12 @@ private struct PurchaseOrderCTX: Content {
|
||||
let hasPrevious: Bool
|
||||
let form: PurchaseOrderFormCTX?
|
||||
|
||||
init(page: Page<PurchaseOrder.DTO>, 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
|
||||
|
||||
@@ -1,31 +1,50 @@
|
||||
import Dependencies
|
||||
import Fluent
|
||||
import Vapor
|
||||
|
||||
struct UserViewController: RouteCollection {
|
||||
|
||||
@Dependency(\.users) var users
|
||||
|
||||
private let api = UserApiController()
|
||||
|
||||
func boot(routes: any RoutesBuilder) throws {
|
||||
let users = routes.protected.grouped("users")
|
||||
users.get(use: index(req:))
|
||||
users.post(use: create(req:))
|
||||
users.group(":userID") {
|
||||
users.group(":id") {
|
||||
$0.get(use: details(req:))
|
||||
$0.delete(use: delete(req:))
|
||||
}
|
||||
}
|
||||
|
||||
@Sendable
|
||||
func index(req: Request) async throws -> View {
|
||||
try await req.view.render(
|
||||
"users/index",
|
||||
UsersCTX(users: api.getSortedUsers(req: req))
|
||||
)
|
||||
try await renderIndex(req)
|
||||
}
|
||||
|
||||
@Sendable
|
||||
private func renderIndex(_ req: Request, _ user: User.DTO? = nil) async throws -> View {
|
||||
let users = try await api.getSortedUsers(req: req)
|
||||
return try await req.view.render("users/index", UsersCTX(user: user, users: users))
|
||||
}
|
||||
|
||||
@Sendable
|
||||
func create(req: Request) async throws -> View {
|
||||
_ = try await api.create(req: req)
|
||||
return try await req.view.render("users/table", ["users": api.getSortedUsers(req: req)])
|
||||
let user = try await api.create(req: req)
|
||||
return try await req.view.render("users/table-row", user)
|
||||
}
|
||||
|
||||
@Sendable
|
||||
func details(req: Request) async throws -> View {
|
||||
let user = try await users.get(req.ensureIDPathComponent())
|
||||
// Check if the page has been rendered before.
|
||||
guard req.isHtmxRequest else {
|
||||
// Not an htmx-request, so render the whole page with the details.
|
||||
return try await renderIndex(req, user)
|
||||
}
|
||||
// An htmx-request header was present, so just return the details,
|
||||
return try await req.view.render("users/detail", ["user": user])
|
||||
}
|
||||
|
||||
@Sendable
|
||||
@@ -50,11 +69,11 @@ struct UserFormCTX: Content {
|
||||
formClass: "user-form",
|
||||
formId: "user-form",
|
||||
htmxTargetUrl: .post("/login\((next != nil && next != "/") ? "?next=\(next!)" : "")"),
|
||||
htmxTarget: "body",
|
||||
htmxTarget: "user-table",
|
||||
htmxPushUrl: true,
|
||||
htmxResetAfterRequest: true,
|
||||
htmxSwapOob: nil,
|
||||
htmxSwap: nil,
|
||||
htmxSwap: .afterbegin,
|
||||
context: .init(showConfirmPassword: false, showEmailInput: false, buttonLabel: "Sign In")
|
||||
)
|
||||
)
|
||||
@@ -78,10 +97,16 @@ struct UserFormCTX: Content {
|
||||
}
|
||||
|
||||
private struct UsersCTX: Content {
|
||||
let user: User.DTO?
|
||||
let users: [User.DTO]
|
||||
let form: UserFormCTX
|
||||
|
||||
init(users: [User.DTO], form: UserFormCTX? = nil) {
|
||||
init(
|
||||
user: User.DTO? = nil,
|
||||
users: [User.DTO],
|
||||
form: UserFormCTX? = nil
|
||||
) {
|
||||
self.user = user
|
||||
self.users = users
|
||||
self.form = form ?? .create()
|
||||
}
|
||||
|
||||
@@ -15,14 +15,19 @@ extension DependencyValues {
|
||||
@DependencyClient
|
||||
struct EmployeeDB: Sendable {
|
||||
var create: @Sendable (Employee.Create) async throws -> Employee.DTO
|
||||
var fetchAll: @Sendable (Bool) async throws -> [Employee.DTO]
|
||||
var fetchAll: @Sendable (FetchRequest) async throws -> [Employee.DTO]
|
||||
var get: @Sendable (Employee.IDValue) async throws -> Employee.DTO?
|
||||
var update: @Sendable (Employee.IDValue, Employee.Update) async throws -> Employee.DTO
|
||||
var delete: @Sendable (Employee.IDValue) async throws -> Void
|
||||
var toggleActive: @Sendable (Employee.IDValue) async throws -> Employee.DTO
|
||||
|
||||
enum FetchRequest {
|
||||
case active
|
||||
case `default`
|
||||
}
|
||||
|
||||
func fetchAll() async throws -> [Employee.DTO] {
|
||||
try await fetchAll(false)
|
||||
try await fetchAll(.default)
|
||||
}
|
||||
|
||||
func get(_ id: String?) async throws -> Employee.DTO? {
|
||||
@@ -43,12 +48,12 @@ extension EmployeeDB: TestDependencyKey {
|
||||
try await model.save(on: database)
|
||||
return model.toDTO()
|
||||
},
|
||||
fetchAll: { active in
|
||||
fetchAll: { request in
|
||||
var query = Employee.query(on: database)
|
||||
.sort(\.$lastName)
|
||||
|
||||
if active {
|
||||
query = query.filter(\.$active == active)
|
||||
if request == .active {
|
||||
query = query.filter(\.$active == true)
|
||||
}
|
||||
|
||||
return try await query.all().map { $0.toDTO() }
|
||||
|
||||
@@ -15,6 +15,7 @@ struct UserDB: Sendable {
|
||||
var create: @Sendable (User.Create) async throws -> User.DTO
|
||||
var delete: @Sendable (User.IDValue) async throws -> Void
|
||||
var fetchAll: @Sendable () async throws -> [User.DTO]
|
||||
var get: @Sendable (User.IDValue) async throws -> User.DTO?
|
||||
var login: @Sendable (User) async throws -> UserToken
|
||||
}
|
||||
|
||||
@@ -46,6 +47,9 @@ extension UserDB: TestDependencyKey {
|
||||
fetchAll: {
|
||||
try await User.query(on: db).all().map { $0.toDTO() }
|
||||
},
|
||||
get: { id in
|
||||
try await User.find(id, on: db).map { $0.toDTO() }
|
||||
},
|
||||
login: { user in
|
||||
let token = try user.generateToken()
|
||||
try await token.save(on: db)
|
||||
|
||||
@@ -14,13 +14,18 @@ public extension DependencyValues {
|
||||
public struct VendorBranchDB: Sendable {
|
||||
var create: @Sendable (VendorBranch.Create, Vendor.IDValue) async throws -> VendorBranch.DTO
|
||||
var delete: @Sendable (VendorBranch.IDValue) async throws -> Void
|
||||
var fetchAll: @Sendable (Bool) async throws -> [VendorBranch.DTO]
|
||||
var fetchForVendor: @Sendable (Vendor.IDValue) async throws -> [VendorBranch.DTO]
|
||||
var fetchAll: @Sendable (FetchRequest) async throws -> [VendorBranch.DTO]
|
||||
var get: @Sendable (VendorBranch.IDValue) async throws -> VendorBranch.DTO?
|
||||
var update: @Sendable (VendorBranch.IDValue, VendorBranch.Update) async throws -> VendorBranch.DTO
|
||||
|
||||
enum FetchRequest: Equatable {
|
||||
case `default`
|
||||
case `for`(vendorID: Vendor.IDValue)
|
||||
case withVendor
|
||||
}
|
||||
|
||||
func fetchAll() async throws -> [VendorBranch.DTO] {
|
||||
try await fetchAll(false)
|
||||
try await fetchAll(.default)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,28 +48,30 @@ extension VendorBranchDB: TestDependencyKey {
|
||||
}
|
||||
try await branch.delete(on: db)
|
||||
},
|
||||
fetchAll: { withVendor in
|
||||
fetchAll: { request in
|
||||
var query = VendorBranch.query(on: db)
|
||||
if withVendor == true {
|
||||
switch request {
|
||||
case .withVendor:
|
||||
query = query.with(\.$vendor)
|
||||
|
||||
case let .for(vendorID: vendorID):
|
||||
let branches = try await Vendor.query(on: db)
|
||||
.filter(\.$id == vendorID)
|
||||
.with(\.$branches)
|
||||
.first()?
|
||||
.branches
|
||||
.map { $0.toDTO() }
|
||||
|
||||
guard let branches else { throw Abort(.badGateway, reason: "Vendor id not found.") }
|
||||
return branches
|
||||
|
||||
case .default:
|
||||
break
|
||||
}
|
||||
return try await query.all().map { $0.toDTO() }
|
||||
|
||||
},
|
||||
fetchForVendor: { vendorID in
|
||||
guard let vendor = try await Vendor.query(on: db)
|
||||
.filter(\.$id == vendorID)
|
||||
.with(\.$branches)
|
||||
.first()
|
||||
else {
|
||||
throw Abort(.notFound)
|
||||
}
|
||||
|
||||
return vendor.branches.map { $0.toDTO() }
|
||||
|
||||
},
|
||||
get: { id in
|
||||
|
||||
try await VendorBranch.find(id, on: db).map { $0.toDTO() }
|
||||
},
|
||||
update: { id, updates in
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import Vapor
|
||||
|
||||
extension Request {
|
||||
func ensureValidContent<T>(_ decoding: T.Type) throws -> T where T: Content, T: Validatable {
|
||||
try T.validate(content: self)
|
||||
return try content.decode(T.self)
|
||||
}
|
||||
}
|
||||
22
Sources/App/Extensions/Request+extensions.swift
Normal file
22
Sources/App/Extensions/Request+extensions.swift
Normal file
@@ -0,0 +1,22 @@
|
||||
import Vapor
|
||||
|
||||
extension Request {
|
||||
func ensureValidContent<T>(_ decoding: T.Type) throws -> T where T: Content, T: Validatable {
|
||||
try T.validate(content: self)
|
||||
return try content.decode(T.self)
|
||||
}
|
||||
|
||||
func ensureIDPathComponent<T: LosslessStringConvertible>(
|
||||
as decoding: T.Type = UUID.self,
|
||||
key: String = "id"
|
||||
) throws -> T {
|
||||
guard let id = parameters.get(key, as: T.self) else {
|
||||
throw Abort(.badRequest, reason: "Id not supplied.")
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
var isHtmxRequest: Bool {
|
||||
headers.contains(name: "hx-request")
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,16 @@ extension RoutesBuilder {
|
||||
// Used to ensure views are protected, redirects users to the login page if they're
|
||||
// not authenticated.
|
||||
var protected: any RoutesBuilder {
|
||||
grouped(
|
||||
#if DEBUG
|
||||
return self
|
||||
#else
|
||||
return grouped(
|
||||
User.credentialsAuthenticator(),
|
||||
User.redirectMiddleware { req in
|
||||
"login?next=\(req.url)"
|
||||
}
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
func apiUnprotected(route: PathComponent) -> any RoutesBuilder {
|
||||
@@ -20,11 +24,15 @@ extension RoutesBuilder {
|
||||
// Allows basic or token authentication for api routes and prefixes the
|
||||
// given route with "/api/v1".
|
||||
func apiProtected(route: PathComponent) -> any RoutesBuilder {
|
||||
#if DEBUG
|
||||
return apiUnprotected(route: route)
|
||||
#else
|
||||
let prefixed = grouped("api", "v1", route)
|
||||
return prefixed.grouped(
|
||||
User.authenticator(),
|
||||
UserToken.authenticator(),
|
||||
User.guardMiddleware()
|
||||
)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,22 +27,39 @@ final class Employee: Model, @unchecked Sendable {
|
||||
@Field(key: "is_active")
|
||||
var active: Bool
|
||||
|
||||
@Timestamp(key: "created_at", on: .create)
|
||||
var createdAt: Date?
|
||||
|
||||
@Timestamp(key: "updated_at", on: .update)
|
||||
var updatedAt: Date?
|
||||
|
||||
init() {}
|
||||
|
||||
init(
|
||||
id: UUID? = nil,
|
||||
firstName: String,
|
||||
lastName: String,
|
||||
active: Bool
|
||||
active: Bool,
|
||||
createdAt: Date? = nil,
|
||||
updatedAt: Date? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.firstName = firstName
|
||||
self.lastName = lastName
|
||||
self.active = active
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
func toDTO() -> DTO {
|
||||
.init(id: id, firstName: $firstName.value, lastName: $lastName.value, active: $active.value)
|
||||
.init(
|
||||
id: id,
|
||||
firstName: $firstName.value,
|
||||
lastName: $lastName.value,
|
||||
active: $active.value,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
func applyUpdates(_ updates: Update) {
|
||||
@@ -68,7 +85,11 @@ extension Employee {
|
||||
let active: Bool?
|
||||
|
||||
func toModel() -> Employee {
|
||||
.init(firstName: firstName, lastName: lastName, active: active ?? true)
|
||||
.init(
|
||||
firstName: firstName,
|
||||
lastName: lastName,
|
||||
active: active ?? true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +99,8 @@ extension Employee {
|
||||
var firstName: String?
|
||||
var lastName: String?
|
||||
var active: Bool?
|
||||
var createdAt: Date?
|
||||
var updatedAt: Date?
|
||||
|
||||
func toModel() -> Employee {
|
||||
let model = Employee()
|
||||
@@ -106,6 +129,8 @@ extension Employee {
|
||||
.field("first_name", .string, .required)
|
||||
.field("last_name", .string, .required)
|
||||
.field("is_active", .bool, .required, .sql(.default(true)))
|
||||
.field("created_at", .datetime)
|
||||
.field("updated_at", .datetime)
|
||||
.unique(on: "first_name", "last_name")
|
||||
.create()
|
||||
}
|
||||
|
||||
@@ -23,9 +23,20 @@ final class User: Model, @unchecked Sendable {
|
||||
@Field(key: "password_hash")
|
||||
var passwordHash: String
|
||||
|
||||
@Timestamp(key: "created_at", on: .create)
|
||||
var createdAt: Date?
|
||||
|
||||
@Timestamp(key: "updated_at", on: .update)
|
||||
var updatedAt: Date?
|
||||
|
||||
init() {}
|
||||
|
||||
init(id: UUID? = nil, username: String, email: String, passwordHash: String) {
|
||||
init(
|
||||
id: UUID? = nil,
|
||||
username: String,
|
||||
email: String,
|
||||
passwordHash: String
|
||||
) {
|
||||
self.id = id
|
||||
self.username = username
|
||||
self.email = email
|
||||
@@ -33,7 +44,13 @@ final class User: Model, @unchecked Sendable {
|
||||
}
|
||||
|
||||
func toDTO() -> DTO {
|
||||
.init(id: id, username: $username.value, email: $email.value)
|
||||
.init(
|
||||
id: id,
|
||||
username: $username.value,
|
||||
email: $email.value,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
func generateToken() throws -> UserToken {
|
||||
@@ -58,6 +75,8 @@ extension User {
|
||||
let id: UUID?
|
||||
let username: String?
|
||||
let email: String?
|
||||
let createdAt: Date?
|
||||
let updatedAt: Date?
|
||||
}
|
||||
|
||||
struct Migrate: AsyncMigration {
|
||||
@@ -69,6 +88,8 @@ extension User {
|
||||
.field("username", .string, .required)
|
||||
.field("email", .string, .required)
|
||||
.field("password_hash", .string, .required)
|
||||
.field("created_at", .datetime)
|
||||
.field("updated_at", .datetime)
|
||||
.unique(on: "email", "username")
|
||||
.create()
|
||||
}
|
||||
|
||||
@@ -13,6 +13,12 @@ final class Vendor: Model, @unchecked Sendable {
|
||||
@Field(key: "name")
|
||||
var name: String
|
||||
|
||||
@Timestamp(key: "created_at", on: .create)
|
||||
var createdAt: Date?
|
||||
|
||||
@Timestamp(key: "updated_at", on: .update)
|
||||
var updatedAt: Date?
|
||||
|
||||
@Children(for: \.$vendor)
|
||||
var branches: [VendorBranch]
|
||||
|
||||
@@ -29,7 +35,9 @@ final class Vendor: Model, @unchecked Sendable {
|
||||
name: $name.value,
|
||||
branches: ($branches.value != nil && $branches.value!.count > 0)
|
||||
? $branches.value!.map { $0.toDTO() }
|
||||
: (includeBranches == true) ? [] : nil
|
||||
: (includeBranches == true) ? [] : nil,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
@@ -54,6 +62,8 @@ extension Vendor {
|
||||
var id: UUID?
|
||||
var name: String?
|
||||
var branches: [VendorBranch.DTO]?
|
||||
let createdAt: Date?
|
||||
let updatedAt: Date?
|
||||
|
||||
func toModel() -> Vendor {
|
||||
let model = Vendor()
|
||||
@@ -72,6 +82,8 @@ extension Vendor {
|
||||
try await database.schema(Vendor.schema)
|
||||
.id()
|
||||
.field("name", .string, .required)
|
||||
.field("created_at", .datetime)
|
||||
.field("updated_at", .datetime)
|
||||
.unique(on: "name")
|
||||
.create()
|
||||
}
|
||||
|
||||
@@ -12,6 +12,12 @@ final class VendorBranch: Model, @unchecked Sendable {
|
||||
@Field(key: "name")
|
||||
var name: String
|
||||
|
||||
@Timestamp(key: "created_at", on: .create)
|
||||
var createdAt: Date?
|
||||
|
||||
@Timestamp(key: "updated_at", on: .update)
|
||||
var updatedAt: Date?
|
||||
|
||||
@Parent(key: "vendor_id")
|
||||
var vendor: Vendor
|
||||
|
||||
@@ -24,7 +30,13 @@ final class VendorBranch: Model, @unchecked Sendable {
|
||||
}
|
||||
|
||||
func toDTO() -> DTO {
|
||||
.init(id: id, name: $name.value, vendorId: $vendor.id)
|
||||
.init(
|
||||
id: id,
|
||||
name: $name.value,
|
||||
vendorId: $vendor.id,
|
||||
createdAt: createdAt,
|
||||
updatedAt: updatedAt
|
||||
)
|
||||
}
|
||||
|
||||
func applyUpdates(_ updates: Update) {
|
||||
@@ -50,6 +62,8 @@ extension VendorBranch {
|
||||
var id: UUID?
|
||||
var name: String?
|
||||
var vendorId: Vendor.IDValue?
|
||||
let createdAt: Date?
|
||||
let updatedAt: Date?
|
||||
|
||||
func toModel() -> VendorBranch {
|
||||
let model = VendorBranch()
|
||||
@@ -74,6 +88,8 @@ extension VendorBranch {
|
||||
.id()
|
||||
.field("name", .string, .required)
|
||||
.field("vendor_id", .uuid, .required)
|
||||
.field("created_at", .datetime)
|
||||
.field("updated_at", .datetime)
|
||||
.foreignKey("vendor_id", references: Vendor.schema, "id", onDelete: .cascade)
|
||||
.create()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user