Compare commits
39 Commits
8fb313fddc
...
0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
f5afc6e32b
|
|||
|
9709eaaf8e
|
|||
|
4ecd4dba7b
|
|||
|
7471e11bd2
|
|||
|
1b88f81b5f
|
|||
|
86307dfa05
|
|||
|
356e020e3b
|
|||
|
9b5b891744
|
|||
|
658ea9f12e
|
|||
|
7f734e912b
|
|||
|
b5d1f87380
|
|||
|
450791b37e
|
|||
|
71848c607a
|
|||
|
62a82ed674
|
|||
|
dfee50de8e
|
|||
|
f990c4b6db
|
|||
|
930db145a8
|
|||
|
df600a5471
|
|||
|
432533c940
|
|||
|
fa9e8cffb0
|
|||
|
c2aedfac1a
|
|||
|
894bd561ff
|
|||
|
6416b29627
|
|||
|
6aaf39f63c
|
|||
|
0a68177aa8
|
|||
|
f7c6373255
|
|||
|
f835fc7c51
|
|||
|
51edff5a8a
|
|||
|
a7f40efba9
|
|||
|
1446540109
|
|||
|
20065ebf10
|
|||
|
a356aa2a13
|
|||
|
07818d24ed
|
|||
|
7083178844
|
|||
|
30fddb9dce
|
|||
|
9356ccb1c9
|
|||
|
79b7892d9a
|
|||
|
f8bed40670
|
|||
|
dbf7e3b1b4
|
65
.gitea/workflows/release.yaml
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
name: Create and publish a Docker image
|
||||||
|
|
||||||
|
# Configures this workflow to run every time a change is pushed to the branch called `release`.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
# branches: ['main']
|
||||||
|
tags:
|
||||||
|
- '*.*.*'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Defines two custom environment variables for the workflow. These are used for the Container registry domain,
|
||||||
|
# and a name for the Docker image that this workflow builds.
|
||||||
|
env:
|
||||||
|
REGISTRY: git.housh.dev
|
||||||
|
IMAGE_NAME: ${{ gitea.repository }}
|
||||||
|
|
||||||
|
# There is a single job in this workflow. It's configured to run on the latest available version of Ubuntu.
|
||||||
|
jobs:
|
||||||
|
build-and-push-image:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
# Sets the permissions granted to the `GITHUB_TOKEN` for the actions in this job.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
attestations: write
|
||||||
|
id-token: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
# Uses the `docker/login-action` action to log in to the Container registry registry using the account
|
||||||
|
# and password that will publish the packages. Once published, the packages are scoped to the account defined here.
|
||||||
|
- name: Log in to the Container registry
|
||||||
|
uses: docker/login-action@65b78e6e13532edd9afa3aa52ac7964289d1a9c1
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.CONTAINER_TOKEN }}
|
||||||
|
# This step uses [docker/metadata-action](https://github.com/docker/metadata-action#about) to extract tags and labels
|
||||||
|
# that will be applied to the specified image. The `id` "meta" allows the output of this step to be referenced in a
|
||||||
|
# subsequent step. The `images` value provides the base name for the tags and labels.
|
||||||
|
- name: Extract metadata (tags, labels) for Docker
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7
|
||||||
|
with:
|
||||||
|
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=ref,event=branch
|
||||||
|
type=semver,pattern={{version}}
|
||||||
|
type=sha
|
||||||
|
type=raw,value=prod
|
||||||
|
# This step uses the `docker/build-push-action` action to build the image, based on your repository's `Dockerfile`. If
|
||||||
|
# the build succeeds, it pushes the image to GitHub Packages. It uses the `context` parameter to define the build's context
|
||||||
|
# as the set of files located in the specified path. For more information, see "[Usage](https://github.com/docker/build-push-action#usage)"
|
||||||
|
# in the README of the `docker/build-push-action` repository.
|
||||||
|
# It uses the `tags` and `labels` parameters to tag and label the image with the output from the "meta" step.
|
||||||
|
- name: Build and push Docker image
|
||||||
|
id: push
|
||||||
|
uses: docker/build-push-action@f2a1d5e99d037542a71f64918e516c093c6f3fc4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: docker/Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
@@ -105,6 +105,7 @@ let package = Package(
|
|||||||
name: "ViewController",
|
name: "ViewController",
|
||||||
dependencies: [
|
dependencies: [
|
||||||
.target(name: "DatabaseClient"),
|
.target(name: "DatabaseClient"),
|
||||||
|
.target(name: "ManualDClient"),
|
||||||
.target(name: "ManualDCore"),
|
.target(name: "ManualDCore"),
|
||||||
.target(name: "Styleguide"),
|
.target(name: "Styleguide"),
|
||||||
.product(name: "Dependencies", package: "swift-dependencies"),
|
.product(name: "Dependencies", package: "swift-dependencies"),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@plugin "daisyui" {
|
@plugin "daisyui" {
|
||||||
themes: light --default, dark --prefersdark, dracula;
|
themes: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
BIN
Public/files/ManD.Groups.pdf
Executable file
BIN
Public/files/ManD.Groups.pdf_original
Executable file
BIN
Public/images/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
Public/images/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 327 KiB |
BIN
Public/images/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
Public/images/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 1013 B |
BIN
Public/images/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 3.1 KiB |
BIN
Public/images/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
Public/images/mand_logo.png
Normal file
|
After Width: | Height: | Size: 2.2 MiB |
BIN
Public/images/mand_logo_md.webp
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
Public/images/mand_logo_sm.webp
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
Public/images/mand_logo_trimmed.png
Normal file
|
After Width: | Height: | Size: 592 KiB |
1
Public/site.webmanifest
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||||
@@ -31,6 +31,13 @@ extension SiteRoute.Api.ProjectRoute {
|
|||||||
case .delete(let id):
|
case .delete(let id):
|
||||||
try await database.projects.delete(id)
|
try await database.projects.delete(id)
|
||||||
return nil
|
return nil
|
||||||
|
case .detail(let id, let route):
|
||||||
|
switch route {
|
||||||
|
case .completedSteps:
|
||||||
|
// FIX:
|
||||||
|
fatalError()
|
||||||
|
|
||||||
|
}
|
||||||
case .get(let id):
|
case .get(let id):
|
||||||
guard let project = try await database.projects.get(id) else {
|
guard let project = try await database.projects.get(id) else {
|
||||||
logger.error("Project not found for id: \(id)")
|
logger.error("Project not found for id: \(id)")
|
||||||
|
|||||||
@@ -14,10 +14,9 @@ private let viewRouteMiddleware: [any Middleware] = [
|
|||||||
extension SiteRoute.View {
|
extension SiteRoute.View {
|
||||||
var middleware: [any Middleware]? {
|
var middleware: [any Middleware]? {
|
||||||
switch self {
|
switch self {
|
||||||
case .project,
|
case .project, .user:
|
||||||
.effectiveLength:
|
|
||||||
return viewRouteMiddleware
|
return viewRouteMiddleware
|
||||||
case .login, .signup:
|
case .login, .signup, .test:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ extension DatabaseClient {
|
|||||||
public var delete: @Sendable (ComponentPressureLoss.ID) async throws -> Void
|
public var delete: @Sendable (ComponentPressureLoss.ID) async throws -> Void
|
||||||
public var fetch: @Sendable (Project.ID) async throws -> [ComponentPressureLoss]
|
public var fetch: @Sendable (Project.ID) async throws -> [ComponentPressureLoss]
|
||||||
public var get: @Sendable (ComponentPressureLoss.ID) async throws -> ComponentPressureLoss?
|
public var get: @Sendable (ComponentPressureLoss.ID) async throws -> ComponentPressureLoss?
|
||||||
|
public var update:
|
||||||
|
@Sendable (ComponentPressureLoss.ID, ComponentPressureLoss.Update) async throws ->
|
||||||
|
ComponentPressureLoss
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,6 +46,17 @@ extension DatabaseClient.ComponentLoss {
|
|||||||
},
|
},
|
||||||
get: { id in
|
get: { id in
|
||||||
try await ComponentLossModel.find(id, on: database).map { try $0.toDTO() }
|
try await ComponentLossModel.find(id, on: database).map { try $0.toDTO() }
|
||||||
|
},
|
||||||
|
update: { id, updates in
|
||||||
|
try updates.validate()
|
||||||
|
guard let model = try await ComponentLossModel.find(id, on: database) else {
|
||||||
|
throw NotFoundError()
|
||||||
|
}
|
||||||
|
model.applyUpdates(updates)
|
||||||
|
if model.hasChanges {
|
||||||
|
try await model.save(on: database)
|
||||||
|
}
|
||||||
|
return try model.toDTO()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -68,6 +82,24 @@ extension ComponentPressureLoss.Create {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension ComponentPressureLoss.Update {
|
||||||
|
func validate() throws(ValidationError) {
|
||||||
|
if let name {
|
||||||
|
guard !name.isEmpty else {
|
||||||
|
throw ValidationError("Component loss name should not be empty.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let value {
|
||||||
|
guard value > 0 else {
|
||||||
|
throw ValidationError("Component loss value should be greater than 0.")
|
||||||
|
}
|
||||||
|
guard value < 1.0 else {
|
||||||
|
throw ValidationError("Component loss value should be less than 1.0.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension ComponentPressureLoss {
|
extension ComponentPressureLoss {
|
||||||
struct Migrate: AsyncMigration {
|
struct Migrate: AsyncMigration {
|
||||||
let name = "CreateComponentLoss"
|
let name = "CreateComponentLoss"
|
||||||
@@ -142,4 +174,13 @@ final class ComponentLossModel: Model, @unchecked Sendable {
|
|||||||
updatedAt: updatedAt!
|
updatedAt: updatedAt!
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyUpdates(_ updates: ComponentPressureLoss.Update) {
|
||||||
|
if let name = updates.name, name != self.name {
|
||||||
|
self.name = name
|
||||||
|
}
|
||||||
|
if let value = updates.value, value != self.value {
|
||||||
|
self.value = value
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ extension DatabaseClient {
|
|||||||
public var create: @Sendable (EffectiveLength.Create) async throws -> EffectiveLength
|
public var create: @Sendable (EffectiveLength.Create) async throws -> EffectiveLength
|
||||||
public var delete: @Sendable (EffectiveLength.ID) async throws -> Void
|
public var delete: @Sendable (EffectiveLength.ID) async throws -> Void
|
||||||
public var fetch: @Sendable (Project.ID) async throws -> [EffectiveLength]
|
public var fetch: @Sendable (Project.ID) async throws -> [EffectiveLength]
|
||||||
|
public var fetchMax: @Sendable (Project.ID) async throws -> EffectiveLength.MaxContainer
|
||||||
public var get: @Sendable (EffectiveLength.ID) async throws -> EffectiveLength?
|
public var get: @Sendable (EffectiveLength.ID) async throws -> EffectiveLength?
|
||||||
|
public var update:
|
||||||
|
@Sendable (EffectiveLength.ID, EffectiveLength.Update) async throws -> EffectiveLength
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,8 +40,35 @@ extension DatabaseClient.EffectiveLengthClient: TestDependencyKey {
|
|||||||
.all()
|
.all()
|
||||||
.map { try $0.toDTO() }
|
.map { try $0.toDTO() }
|
||||||
},
|
},
|
||||||
|
fetchMax: { projectID in
|
||||||
|
let effectiveLengths = try await EffectiveLengthModel.query(on: database)
|
||||||
|
.with(\.$project)
|
||||||
|
.filter(\.$project.$id, .equal, projectID)
|
||||||
|
.all()
|
||||||
|
.map { try $0.toDTO() }
|
||||||
|
|
||||||
|
return .init(
|
||||||
|
supply: effectiveLengths.filter({ $0.type == .supply })
|
||||||
|
.sorted(by: { $0.totalEquivalentLength > $1.totalEquivalentLength })
|
||||||
|
.first,
|
||||||
|
return: effectiveLengths.filter({ $0.type == .return })
|
||||||
|
.sorted(by: { $0.totalEquivalentLength > $1.totalEquivalentLength })
|
||||||
|
.first
|
||||||
|
)
|
||||||
|
|
||||||
|
},
|
||||||
get: { id in
|
get: { id in
|
||||||
try await EffectiveLengthModel.find(id, on: database).map { try $0.toDTO() }
|
try await EffectiveLengthModel.find(id, on: database).map { try $0.toDTO() }
|
||||||
|
},
|
||||||
|
update: { id, updates in
|
||||||
|
guard let model = try await EffectiveLengthModel.find(id, on: database) else {
|
||||||
|
throw NotFoundError()
|
||||||
|
}
|
||||||
|
try model.applyUpdates(updates)
|
||||||
|
if model.hasChanges {
|
||||||
|
try await model.save(on: database)
|
||||||
|
}
|
||||||
|
return try model.toDTO()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -155,4 +185,19 @@ final class EffectiveLengthModel: Model, @unchecked Sendable {
|
|||||||
updatedAt: updatedAt!
|
updatedAt: updatedAt!
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func applyUpdates(_ updates: EffectiveLength.Update) throws {
|
||||||
|
if let name = updates.name, name != self.name {
|
||||||
|
self.name = name
|
||||||
|
}
|
||||||
|
if let type = updates.type, type.rawValue != self.type {
|
||||||
|
self.type = type.rawValue
|
||||||
|
}
|
||||||
|
if let straightLengths = updates.straightLengths, straightLengths != self.straightLengths {
|
||||||
|
self.straightLengths = straightLengths
|
||||||
|
}
|
||||||
|
if let groups = updates.groups {
|
||||||
|
self.groups = try JSONEncoder().encode(groups)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ extension DatabaseClient {
|
|||||||
public var delete: @Sendable (EquipmentInfo.ID) async throws -> Void
|
public var delete: @Sendable (EquipmentInfo.ID) async throws -> Void
|
||||||
public var fetch: @Sendable (Project.ID) async throws -> EquipmentInfo?
|
public var fetch: @Sendable (Project.ID) async throws -> EquipmentInfo?
|
||||||
public var get: @Sendable (EquipmentInfo.ID) async throws -> EquipmentInfo?
|
public var get: @Sendable (EquipmentInfo.ID) async throws -> EquipmentInfo?
|
||||||
public var update: @Sendable (EquipmentInfo.Update) async throws -> EquipmentInfo
|
public var update:
|
||||||
|
@Sendable (EquipmentInfo.ID, EquipmentInfo.Update) async throws -> EquipmentInfo
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,13 +47,15 @@ extension DatabaseClient.Equipment {
|
|||||||
get: { id in
|
get: { id in
|
||||||
try await EquipmentModel.find(id, on: database).map { try $0.toDTO() }
|
try await EquipmentModel.find(id, on: database).map { try $0.toDTO() }
|
||||||
},
|
},
|
||||||
update: { request in
|
update: { id, updates in
|
||||||
guard let model = try await EquipmentModel.find(request.id, on: database) else {
|
guard let model = try await EquipmentModel.find(id, on: database) else {
|
||||||
throw NotFoundError()
|
throw NotFoundError()
|
||||||
}
|
}
|
||||||
guard request.hasUpdates else { return try model.toDTO() }
|
try updates.validate()
|
||||||
try model.applyUpdates(request)
|
model.applyUpdates(updates)
|
||||||
try await model.save(on: database)
|
if model.hasChanges {
|
||||||
|
try await model.save(on: database)
|
||||||
|
}
|
||||||
return try model.toDTO()
|
return try model.toDTO()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -196,8 +199,7 @@ final class EquipmentModel: Model, @unchecked Sendable {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyUpdates(_ updates: EquipmentInfo.Update) throws {
|
func applyUpdates(_ updates: EquipmentInfo.Update) {
|
||||||
try updates.validate()
|
|
||||||
if let staticPressure = updates.staticPressure {
|
if let staticPressure = updates.staticPressure {
|
||||||
self.staticPressure = staticPressure
|
self.staticPressure = staticPressure
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
// TODO: Move to ManualDCore
|
||||||
public struct ValidationError: Error {
|
public struct ValidationError: Error {
|
||||||
public let message: String
|
public let message: String
|
||||||
|
|
||||||
@@ -8,4 +9,6 @@ public struct ValidationError: Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct NotFoundError: Error {}
|
public struct NotFoundError: Error {
|
||||||
|
public init() {}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ public struct DatabaseClient: Sendable {
|
|||||||
public var componentLoss: ComponentLoss
|
public var componentLoss: ComponentLoss
|
||||||
public var effectiveLength: EffectiveLengthClient
|
public var effectiveLength: EffectiveLengthClient
|
||||||
public var users: Users
|
public var users: Users
|
||||||
|
public var userProfile: UserProfile
|
||||||
|
public var trunkSizes: TrunkSizes
|
||||||
}
|
}
|
||||||
|
|
||||||
extension DatabaseClient: TestDependencyKey {
|
extension DatabaseClient: TestDependencyKey {
|
||||||
@@ -29,7 +31,9 @@ extension DatabaseClient: TestDependencyKey {
|
|||||||
equipment: .testValue,
|
equipment: .testValue,
|
||||||
componentLoss: .testValue,
|
componentLoss: .testValue,
|
||||||
effectiveLength: .testValue,
|
effectiveLength: .testValue,
|
||||||
users: .testValue
|
users: .testValue,
|
||||||
|
userProfile: .testValue,
|
||||||
|
trunkSizes: .testValue
|
||||||
)
|
)
|
||||||
|
|
||||||
public static func live(database: any Database) -> Self {
|
public static func live(database: any Database) -> Self {
|
||||||
@@ -40,7 +44,9 @@ extension DatabaseClient: TestDependencyKey {
|
|||||||
equipment: .live(database: database),
|
equipment: .live(database: database),
|
||||||
componentLoss: .live(database: database),
|
componentLoss: .live(database: database),
|
||||||
effectiveLength: .live(database: database),
|
effectiveLength: .live(database: database),
|
||||||
users: .live(database: database)
|
users: .live(database: database),
|
||||||
|
userProfile: .live(database: database),
|
||||||
|
trunkSizes: .live(database: database)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,10 +69,12 @@ extension DatabaseClient.Migrations: DependencyKey {
|
|||||||
Project.Migrate(),
|
Project.Migrate(),
|
||||||
User.Migrate(),
|
User.Migrate(),
|
||||||
User.Token.Migrate(),
|
User.Token.Migrate(),
|
||||||
|
User.Profile.Migrate(),
|
||||||
ComponentPressureLoss.Migrate(),
|
ComponentPressureLoss.Migrate(),
|
||||||
EquipmentInfo.Migrate(),
|
EquipmentInfo.Migrate(),
|
||||||
Room.Migrate(),
|
Room.Migrate(),
|
||||||
EffectiveLength.Migrate(),
|
EffectiveLength.Migrate(),
|
||||||
|
DuctSizing.TrunkSize.Migrate(),
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,9 +10,10 @@ extension DatabaseClient {
|
|||||||
public var create: @Sendable (User.ID, Project.Create) async throws -> Project
|
public var create: @Sendable (User.ID, Project.Create) async throws -> Project
|
||||||
public var delete: @Sendable (Project.ID) async throws -> Void
|
public var delete: @Sendable (Project.ID) async throws -> Void
|
||||||
public var get: @Sendable (Project.ID) async throws -> Project?
|
public var get: @Sendable (Project.ID) async throws -> Project?
|
||||||
|
public var getCompletedSteps: @Sendable (Project.ID) async throws -> Project.CompletedSteps
|
||||||
public var getSensibleHeatRatio: @Sendable (Project.ID) async throws -> Double?
|
public var getSensibleHeatRatio: @Sendable (Project.ID) async throws -> Double?
|
||||||
public var fetch: @Sendable (User.ID, PageRequest) async throws -> Page<Project>
|
public var fetch: @Sendable (User.ID, PageRequest) async throws -> Page<Project>
|
||||||
public var update: @Sendable (Project.Update) async throws -> Project
|
public var update: @Sendable (Project.ID, Project.Update) async throws -> Project
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +36,42 @@ extension DatabaseClient.Projects: TestDependencyKey {
|
|||||||
get: { id in
|
get: { id in
|
||||||
try await ProjectModel.find(id, on: database).map { try $0.toDTO() }
|
try await ProjectModel.find(id, on: database).map { try $0.toDTO() }
|
||||||
},
|
},
|
||||||
|
getCompletedSteps: { id in
|
||||||
|
let roomsCount = try await RoomModel.query(on: database)
|
||||||
|
.with(\.$project)
|
||||||
|
.filter(\.$project.$id == id)
|
||||||
|
.count()
|
||||||
|
|
||||||
|
let equivalentLengths = try await EffectiveLengthModel.query(on: database)
|
||||||
|
.with(\.$project)
|
||||||
|
.filter(\.$project.$id == id)
|
||||||
|
.all()
|
||||||
|
|
||||||
|
var equivalentLengthsCompleted = false
|
||||||
|
|
||||||
|
if equivalentLengths.filter({ $0.type == "supply" }).first != nil,
|
||||||
|
equivalentLengths.filter({ $0.type == "return" }).first != nil
|
||||||
|
{
|
||||||
|
equivalentLengthsCompleted = true
|
||||||
|
}
|
||||||
|
|
||||||
|
let componentLosses = try await ComponentLossModel.query(on: database)
|
||||||
|
.with(\.$project)
|
||||||
|
.filter(\.$project.$id == id)
|
||||||
|
.count()
|
||||||
|
|
||||||
|
let equipmentInfo = try await EquipmentModel.query(on: database)
|
||||||
|
.with(\.$project)
|
||||||
|
.filter(\.$project.$id == id)
|
||||||
|
.first()
|
||||||
|
|
||||||
|
return .init(
|
||||||
|
equipmentInfo: equipmentInfo != nil,
|
||||||
|
rooms: roomsCount > 0,
|
||||||
|
equivalentLength: equivalentLengthsCompleted,
|
||||||
|
frictionRate: componentLosses > 0
|
||||||
|
)
|
||||||
|
},
|
||||||
getSensibleHeatRatio: { id in
|
getSensibleHeatRatio: { id in
|
||||||
guard let model = try await ProjectModel.find(id, on: database) else {
|
guard let model = try await ProjectModel.find(id, on: database) else {
|
||||||
throw NotFoundError()
|
throw NotFoundError()
|
||||||
@@ -49,12 +86,13 @@ extension DatabaseClient.Projects: TestDependencyKey {
|
|||||||
.paginate(request)
|
.paginate(request)
|
||||||
.map { try $0.toDTO() }
|
.map { try $0.toDTO() }
|
||||||
},
|
},
|
||||||
update: { updates in
|
update: { id, updates in
|
||||||
guard let model = try await ProjectModel.find(updates.id, on: database) else {
|
guard let model = try await ProjectModel.find(id, on: database) else {
|
||||||
throw NotFoundError()
|
throw NotFoundError()
|
||||||
}
|
}
|
||||||
try updates.validate()
|
try updates.validate()
|
||||||
if model.applyUpdates(updates) {
|
model.applyUpdates(updates)
|
||||||
|
if model.hasChanges {
|
||||||
try await model.save(on: database)
|
try await model.save(on: database)
|
||||||
}
|
}
|
||||||
return try model.toDTO()
|
return try model.toDTO()
|
||||||
@@ -247,34 +285,26 @@ final class ProjectModel: Model, @unchecked Sendable {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyUpdates(_ updates: Project.Update) -> Bool {
|
func applyUpdates(_ updates: Project.Update) {
|
||||||
var hasUpdates = false
|
|
||||||
if let name = updates.name, name != self.name {
|
if let name = updates.name, name != self.name {
|
||||||
hasUpdates = true
|
|
||||||
self.name = name
|
self.name = name
|
||||||
}
|
}
|
||||||
if let streetAddress = updates.streetAddress, streetAddress != self.streetAddress {
|
if let streetAddress = updates.streetAddress, streetAddress != self.streetAddress {
|
||||||
hasUpdates = true
|
|
||||||
self.streetAddress = streetAddress
|
self.streetAddress = streetAddress
|
||||||
}
|
}
|
||||||
if let city = updates.city, city != self.city {
|
if let city = updates.city, city != self.city {
|
||||||
hasUpdates = true
|
|
||||||
self.city = city
|
self.city = city
|
||||||
}
|
}
|
||||||
if let state = updates.state, state != self.state {
|
if let state = updates.state, state != self.state {
|
||||||
hasUpdates = true
|
|
||||||
self.state = state
|
self.state = state
|
||||||
}
|
}
|
||||||
if let zipCode = updates.zipCode, zipCode != self.zipCode {
|
if let zipCode = updates.zipCode, zipCode != self.zipCode {
|
||||||
hasUpdates = true
|
|
||||||
self.zipCode = zipCode
|
self.zipCode = zipCode
|
||||||
}
|
}
|
||||||
if let sensibleHeatRatio = updates.sensibleHeatRatio,
|
if let sensibleHeatRatio = updates.sensibleHeatRatio,
|
||||||
sensibleHeatRatio != self.sensibleHeatRatio
|
sensibleHeatRatio != self.sensibleHeatRatio
|
||||||
{
|
{
|
||||||
hasUpdates = true
|
|
||||||
self.sensibleHeatRatio = sensibleHeatRatio
|
self.sensibleHeatRatio = sensibleHeatRatio
|
||||||
}
|
}
|
||||||
return hasUpdates
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
175
Sources/DatabaseClient/RectangularDuct.swift
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
// import Dependencies
|
||||||
|
// import DependenciesMacros
|
||||||
|
// import Fluent
|
||||||
|
// import Foundation
|
||||||
|
// import ManualDCore
|
||||||
|
//
|
||||||
|
// extension DatabaseClient {
|
||||||
|
// @DependencyClient
|
||||||
|
// public struct RectangularDuct: Sendable {
|
||||||
|
// public var create:
|
||||||
|
// @Sendable (DuctSizing.RectangularDuct.Create) async throws -> DuctSizing.RectangularDuct
|
||||||
|
// public var delete: @Sendable (DuctSizing.RectangularDuct.ID) async throws -> Void
|
||||||
|
// public var fetch: @Sendable (Room.ID) async throws -> [DuctSizing.RectangularDuct]
|
||||||
|
// public var get:
|
||||||
|
// @Sendable (DuctSizing.RectangularDuct.ID) async throws -> DuctSizing.RectangularDuct?
|
||||||
|
// public var update:
|
||||||
|
// @Sendable (DuctSizing.RectangularDuct.ID, DuctSizing.RectangularDuct.Update) async throws ->
|
||||||
|
// DuctSizing.RectangularDuct
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// extension DatabaseClient.RectangularDuct: TestDependencyKey {
|
||||||
|
// public static let testValue = Self()
|
||||||
|
//
|
||||||
|
// public static func live(database: any Database) -> Self {
|
||||||
|
// .init(
|
||||||
|
// create: { request in
|
||||||
|
// try request.validate()
|
||||||
|
// let model = request.toModel()
|
||||||
|
// try await model.save(on: database)
|
||||||
|
// return try model.toDTO()
|
||||||
|
// },
|
||||||
|
// delete: { id in
|
||||||
|
// guard let model = try await RectangularDuctModel.find(id, on: database) else {
|
||||||
|
// throw NotFoundError()
|
||||||
|
// }
|
||||||
|
// try await model.delete(on: database)
|
||||||
|
// },
|
||||||
|
// fetch: { roomID in
|
||||||
|
// try await RectangularDuctModel.query(on: database)
|
||||||
|
// .with(\.$room)
|
||||||
|
// .filter(\.$room.$id == roomID)
|
||||||
|
// .all()
|
||||||
|
// .map { try $0.toDTO() }
|
||||||
|
// },
|
||||||
|
// get: { id in
|
||||||
|
// try await RectangularDuctModel.find(id, on: database)
|
||||||
|
// .map { try $0.toDTO() }
|
||||||
|
// },
|
||||||
|
// update: { id, updates in
|
||||||
|
// guard let model = try await RectangularDuctModel.find(id, on: database) else {
|
||||||
|
// throw NotFoundError()
|
||||||
|
// }
|
||||||
|
// try updates.validate()
|
||||||
|
// model.applyUpdates(updates)
|
||||||
|
// if model.hasChanges {
|
||||||
|
// try await model.save(on: database)
|
||||||
|
// }
|
||||||
|
// return try model.toDTO()
|
||||||
|
// }
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// extension DuctSizing.RectangularDuct.Create {
|
||||||
|
//
|
||||||
|
// func validate() throws(ValidationError) {
|
||||||
|
// guard height > 0 else {
|
||||||
|
// throw ValidationError("Rectangular duct size height should be greater than 0.")
|
||||||
|
// }
|
||||||
|
// if let register {
|
||||||
|
// guard register > 0 else {
|
||||||
|
// throw ValidationError("Rectangular duct size register should be greater than 0.")
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func toModel() -> RectangularDuctModel {
|
||||||
|
// .init(roomID: roomID, height: height)
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// extension DuctSizing.RectangularDuct.Update {
|
||||||
|
//
|
||||||
|
// func validate() throws(ValidationError) {
|
||||||
|
// if let height {
|
||||||
|
// guard height > 0 else {
|
||||||
|
// throw ValidationError("Rectangular duct size height should be greater than 0.")
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// if let register {
|
||||||
|
// guard register > 0 else {
|
||||||
|
// throw ValidationError("Rectangular duct size register should be greater than 0.")
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// extension DuctSizing.RectangularDuct {
|
||||||
|
// struct Migrate: AsyncMigration {
|
||||||
|
// let name = "CreateRectangularDuct"
|
||||||
|
//
|
||||||
|
// func prepare(on database: any Database) async throws {
|
||||||
|
// try await database.schema(RectangularDuctModel.schema)
|
||||||
|
// .id()
|
||||||
|
// .field("register", .int8)
|
||||||
|
// .field("height", .int8, .required)
|
||||||
|
// .field("roomID", .uuid, .required, .references(RoomModel.schema, "id", onDelete: .cascade))
|
||||||
|
// .field("createdAt", .datetime)
|
||||||
|
// .field("updatedAt", .datetime)
|
||||||
|
// .create()
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func revert(on database: any Database) async throws {
|
||||||
|
// try await database.schema(RectangularDuctModel.schema).delete()
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// final class RectangularDuctModel: Model, @unchecked Sendable {
|
||||||
|
//
|
||||||
|
// static let schema = "rectangularDuct"
|
||||||
|
//
|
||||||
|
// @ID(key: .id)
|
||||||
|
// var id: UUID?
|
||||||
|
//
|
||||||
|
// @Parent(key: "roomID")
|
||||||
|
// var room: RoomModel
|
||||||
|
//
|
||||||
|
// @Field(key: "height")
|
||||||
|
// var height: Int
|
||||||
|
//
|
||||||
|
// @Field(key: "register")
|
||||||
|
// var register: Int?
|
||||||
|
//
|
||||||
|
// @Timestamp(key: "createdAt", on: .create, format: .iso8601)
|
||||||
|
// var createdAt: Date?
|
||||||
|
//
|
||||||
|
// @Timestamp(key: "updatedAt", on: .update, format: .iso8601)
|
||||||
|
// var updatedAt: Date?
|
||||||
|
//
|
||||||
|
// init() {}
|
||||||
|
//
|
||||||
|
// init(
|
||||||
|
// id: UUID? = nil,
|
||||||
|
// roomID: Room.ID,
|
||||||
|
// register: Int? = nil,
|
||||||
|
// height: Int
|
||||||
|
// ) {
|
||||||
|
// self.id = id
|
||||||
|
// $room.id = roomID
|
||||||
|
// self.register = register
|
||||||
|
// self.height = height
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func toDTO() throws -> DuctSizing.RectangularDuct {
|
||||||
|
// return try .init(
|
||||||
|
// id: requireID(),
|
||||||
|
// roomID: $room.id,
|
||||||
|
// register: register,
|
||||||
|
// height: height,
|
||||||
|
// createdAt: createdAt!,
|
||||||
|
// updatedAt: updatedAt!
|
||||||
|
// )
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// func applyUpdates(_ updates: DuctSizing.RectangularDuct.Update) {
|
||||||
|
// if let height = updates.height, height != self.height {
|
||||||
|
// self.height = height
|
||||||
|
// }
|
||||||
|
// if let register = updates.register, register != self.register {
|
||||||
|
// self.register = register
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
@@ -9,9 +9,13 @@ extension DatabaseClient {
|
|||||||
public struct Rooms: Sendable {
|
public struct Rooms: Sendable {
|
||||||
public var create: @Sendable (Room.Create) async throws -> Room
|
public var create: @Sendable (Room.Create) async throws -> Room
|
||||||
public var delete: @Sendable (Room.ID) async throws -> Void
|
public var delete: @Sendable (Room.ID) async throws -> Void
|
||||||
|
public var deleteRectangularSize:
|
||||||
|
@Sendable (Room.ID, DuctSizing.RectangularDuct.ID) async throws -> Room
|
||||||
public var get: @Sendable (Room.ID) async throws -> Room?
|
public var get: @Sendable (Room.ID) async throws -> Room?
|
||||||
public var fetch: @Sendable (Project.ID) async throws -> [Room]
|
public var fetch: @Sendable (Project.ID) async throws -> [Room]
|
||||||
public var update: @Sendable (Room.Update) async throws -> Room
|
public var update: @Sendable (Room.ID, Room.Update) async throws -> Room
|
||||||
|
public var updateRectangularSize:
|
||||||
|
@Sendable (Room.ID, DuctSizing.RectangularDuct) async throws -> Room
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +35,18 @@ extension DatabaseClient.Rooms: TestDependencyKey {
|
|||||||
}
|
}
|
||||||
try await model.delete(on: database)
|
try await model.delete(on: database)
|
||||||
},
|
},
|
||||||
|
deleteRectangularSize: { roomID, rectangularDuctID in
|
||||||
|
guard let model = try await RoomModel.find(roomID, on: database) else {
|
||||||
|
throw NotFoundError()
|
||||||
|
}
|
||||||
|
model.rectangularSizes?.removeAll {
|
||||||
|
$0.id == rectangularDuctID
|
||||||
|
}
|
||||||
|
if model.hasChanges {
|
||||||
|
try await model.save(on: database)
|
||||||
|
}
|
||||||
|
return try model.toDTO()
|
||||||
|
},
|
||||||
get: { id in
|
get: { id in
|
||||||
try await RoomModel.find(id, on: database).map { try $0.toDTO() }
|
try await RoomModel.find(id, on: database).map { try $0.toDTO() }
|
||||||
},
|
},
|
||||||
@@ -38,19 +54,34 @@ extension DatabaseClient.Rooms: TestDependencyKey {
|
|||||||
try await RoomModel.query(on: database)
|
try await RoomModel.query(on: database)
|
||||||
.with(\.$project)
|
.with(\.$project)
|
||||||
.filter(\.$project.$id, .equal, projectID)
|
.filter(\.$project.$id, .equal, projectID)
|
||||||
|
.sort(\.$name, .ascending)
|
||||||
.all()
|
.all()
|
||||||
.map { try $0.toDTO() }
|
.map { try $0.toDTO() }
|
||||||
},
|
},
|
||||||
update: { updates in
|
update: { id, updates in
|
||||||
guard let model = try await RoomModel.find(updates.id, on: database) else {
|
guard let model = try await RoomModel.find(id, on: database) else {
|
||||||
throw NotFoundError()
|
throw NotFoundError()
|
||||||
}
|
}
|
||||||
|
|
||||||
try updates.validate()
|
try updates.validate()
|
||||||
if model.applyUpdates(updates) {
|
model.applyUpdates(updates)
|
||||||
|
if model.hasChanges {
|
||||||
try await model.save(on: database)
|
try await model.save(on: database)
|
||||||
}
|
}
|
||||||
return try model.toDTO()
|
return try model.toDTO()
|
||||||
|
},
|
||||||
|
updateRectangularSize: { id, size in
|
||||||
|
guard let model = try await RoomModel.find(id, on: database) else {
|
||||||
|
throw NotFoundError()
|
||||||
|
}
|
||||||
|
var rectangularSizes = model.rectangularSizes ?? []
|
||||||
|
rectangularSizes.removeAll {
|
||||||
|
$0.id == size.id
|
||||||
|
}
|
||||||
|
rectangularSizes.append(size)
|
||||||
|
model.rectangularSizes = rectangularSizes
|
||||||
|
try await model.save(on: database)
|
||||||
|
return try model.toDTO()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -134,6 +165,7 @@ extension Room {
|
|||||||
.field("coolingTotal", .double, .required)
|
.field("coolingTotal", .double, .required)
|
||||||
.field("coolingSensible", .double)
|
.field("coolingSensible", .double)
|
||||||
.field("registerCount", .int8, .required)
|
.field("registerCount", .int8, .required)
|
||||||
|
.field("rectangularSizes", .array)
|
||||||
.field("createdAt", .datetime)
|
.field("createdAt", .datetime)
|
||||||
.field("updatedAt", .datetime)
|
.field("updatedAt", .datetime)
|
||||||
.field(
|
.field(
|
||||||
@@ -171,6 +203,9 @@ final class RoomModel: Model, @unchecked Sendable {
|
|||||||
@Field(key: "registerCount")
|
@Field(key: "registerCount")
|
||||||
var registerCount: Int
|
var registerCount: Int
|
||||||
|
|
||||||
|
@Field(key: "rectangularSizes")
|
||||||
|
var rectangularSizes: [DuctSizing.RectangularDuct]?
|
||||||
|
|
||||||
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
|
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
|
||||||
var createdAt: Date?
|
var createdAt: Date?
|
||||||
|
|
||||||
@@ -189,6 +224,7 @@ final class RoomModel: Model, @unchecked Sendable {
|
|||||||
coolingTotal: Double,
|
coolingTotal: Double,
|
||||||
coolingSensible: Double? = nil,
|
coolingSensible: Double? = nil,
|
||||||
registerCount: Int,
|
registerCount: Int,
|
||||||
|
rectangularSizes: [DuctSizing.RectangularDuct]? = nil,
|
||||||
createdAt: Date? = nil,
|
createdAt: Date? = nil,
|
||||||
updatedAt: Date? = nil,
|
updatedAt: Date? = nil,
|
||||||
projectID: Project.ID
|
projectID: Project.ID
|
||||||
@@ -199,6 +235,7 @@ final class RoomModel: Model, @unchecked Sendable {
|
|||||||
self.coolingTotal = coolingTotal
|
self.coolingTotal = coolingTotal
|
||||||
self.coolingSensible = coolingSensible
|
self.coolingSensible = coolingSensible
|
||||||
self.registerCount = registerCount
|
self.registerCount = registerCount
|
||||||
|
self.rectangularSizes = rectangularSizes
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
$project.id = projectID
|
$project.id = projectID
|
||||||
@@ -213,35 +250,33 @@ final class RoomModel: Model, @unchecked Sendable {
|
|||||||
coolingTotal: coolingTotal,
|
coolingTotal: coolingTotal,
|
||||||
coolingSensible: coolingSensible,
|
coolingSensible: coolingSensible,
|
||||||
registerCount: registerCount,
|
registerCount: registerCount,
|
||||||
|
rectangularSizes: rectangularSizes,
|
||||||
createdAt: createdAt!,
|
createdAt: createdAt!,
|
||||||
updatedAt: updatedAt!
|
updatedAt: updatedAt!
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func applyUpdates(_ updates: Room.Update) -> Bool {
|
func applyUpdates(_ updates: Room.Update) {
|
||||||
var hasUpdates = false
|
|
||||||
|
|
||||||
if let name = updates.name, name != self.name {
|
if let name = updates.name, name != self.name {
|
||||||
hasUpdates = true
|
|
||||||
self.name = name
|
self.name = name
|
||||||
}
|
}
|
||||||
if let heatingLoad = updates.heatingLoad, heatingLoad != self.heatingLoad {
|
if let heatingLoad = updates.heatingLoad, heatingLoad != self.heatingLoad {
|
||||||
hasUpdates = true
|
|
||||||
self.heatingLoad = heatingLoad
|
self.heatingLoad = heatingLoad
|
||||||
}
|
}
|
||||||
if let coolingTotal = updates.coolingTotal, coolingTotal != self.coolingTotal {
|
if let coolingTotal = updates.coolingTotal, coolingTotal != self.coolingTotal {
|
||||||
hasUpdates = true
|
|
||||||
self.coolingTotal = coolingTotal
|
self.coolingTotal = coolingTotal
|
||||||
}
|
}
|
||||||
if let coolingSensible = updates.coolingSensible, coolingSensible != self.coolingSensible {
|
if let coolingSensible = updates.coolingSensible, coolingSensible != self.coolingSensible {
|
||||||
hasUpdates = true
|
|
||||||
self.coolingSensible = coolingSensible
|
self.coolingSensible = coolingSensible
|
||||||
}
|
}
|
||||||
if let registerCount = updates.registerCount, registerCount != self.registerCount {
|
if let registerCount = updates.registerCount, registerCount != self.registerCount {
|
||||||
hasUpdates = true
|
|
||||||
self.registerCount = registerCount
|
self.registerCount = registerCount
|
||||||
}
|
}
|
||||||
return hasUpdates
|
if let rectangularSizes = updates.rectangularSizes, rectangularSizes != self.rectangularSizes {
|
||||||
|
self.rectangularSizes = rectangularSizes
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
356
Sources/DatabaseClient/TrunkSizes.swift
Normal file
@@ -0,0 +1,356 @@
|
|||||||
|
import Dependencies
|
||||||
|
import DependenciesMacros
|
||||||
|
import Fluent
|
||||||
|
import Foundation
|
||||||
|
import ManualDCore
|
||||||
|
|
||||||
|
extension DatabaseClient {
|
||||||
|
@DependencyClient
|
||||||
|
public struct TrunkSizes: Sendable {
|
||||||
|
public var create: @Sendable (DuctSizing.TrunkSize.Create) async throws -> DuctSizing.TrunkSize
|
||||||
|
public var delete: @Sendable (DuctSizing.TrunkSize.ID) async throws -> Void
|
||||||
|
public var fetch: @Sendable (Project.ID) async throws -> [DuctSizing.TrunkSize]
|
||||||
|
public var get: @Sendable (DuctSizing.TrunkSize.ID) async throws -> DuctSizing.TrunkSize?
|
||||||
|
public var update:
|
||||||
|
@Sendable (DuctSizing.TrunkSize.ID, DuctSizing.TrunkSize.Update) async throws ->
|
||||||
|
DuctSizing.TrunkSize
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DatabaseClient.TrunkSizes: TestDependencyKey {
|
||||||
|
public static let testValue = Self()
|
||||||
|
|
||||||
|
public static func live(database: any Database) -> Self {
|
||||||
|
.init(
|
||||||
|
create: { request in
|
||||||
|
try request.validate()
|
||||||
|
|
||||||
|
let trunk = request.toModel()
|
||||||
|
var roomProxies = [DuctSizing.TrunkSize.RoomProxy]()
|
||||||
|
|
||||||
|
try await trunk.save(on: database)
|
||||||
|
|
||||||
|
for (roomID, registers) in request.rooms {
|
||||||
|
guard let room = try await RoomModel.find(roomID, on: database) else {
|
||||||
|
throw NotFoundError()
|
||||||
|
}
|
||||||
|
let model = try TrunkRoomModel(
|
||||||
|
trunkID: trunk.requireID(),
|
||||||
|
roomID: room.requireID(),
|
||||||
|
registers: registers,
|
||||||
|
type: request.type
|
||||||
|
)
|
||||||
|
try await model.save(on: database)
|
||||||
|
try await roomProxies.append(model.toDTO(on: database))
|
||||||
|
}
|
||||||
|
|
||||||
|
return try .init(
|
||||||
|
id: trunk.requireID(),
|
||||||
|
projectID: trunk.$project.id,
|
||||||
|
type: .init(rawValue: trunk.type)!,
|
||||||
|
rooms: roomProxies
|
||||||
|
)
|
||||||
|
},
|
||||||
|
delete: { id in
|
||||||
|
guard let model = try await TrunkModel.find(id, on: database) else {
|
||||||
|
throw NotFoundError()
|
||||||
|
}
|
||||||
|
try await model.delete(on: database)
|
||||||
|
},
|
||||||
|
fetch: { projectID in
|
||||||
|
try await TrunkModel.query(on: database)
|
||||||
|
.with(\.$project)
|
||||||
|
.with(\.$rooms)
|
||||||
|
.filter(\.$project.$id == projectID)
|
||||||
|
.all()
|
||||||
|
.toDTO(on: database)
|
||||||
|
},
|
||||||
|
get: { id in
|
||||||
|
guard let model = try await TrunkModel.find(id, on: database) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return try await model.toDTO(on: database)
|
||||||
|
},
|
||||||
|
update: { id, updates in
|
||||||
|
guard
|
||||||
|
let model =
|
||||||
|
try await TrunkModel
|
||||||
|
.query(on: database)
|
||||||
|
.with(\.$rooms)
|
||||||
|
.filter(\.$id == id)
|
||||||
|
.first()
|
||||||
|
else {
|
||||||
|
throw NotFoundError()
|
||||||
|
}
|
||||||
|
try updates.validate()
|
||||||
|
try await model.applyUpdates(updates, on: database)
|
||||||
|
return try await model.toDTO(on: database)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DuctSizing.TrunkSize.Create {
|
||||||
|
|
||||||
|
func validate() throws(ValidationError) {
|
||||||
|
guard rooms.count > 0 else {
|
||||||
|
throw ValidationError("Trunk size should have associated rooms / registers.")
|
||||||
|
}
|
||||||
|
if let height {
|
||||||
|
guard height > 0 else {
|
||||||
|
throw ValidationError("Trunk size height should be greater than 0.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toModel() -> TrunkModel {
|
||||||
|
.init(
|
||||||
|
projectID: projectID,
|
||||||
|
type: type,
|
||||||
|
height: height,
|
||||||
|
name: name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DuctSizing.TrunkSize.Update {
|
||||||
|
func validate() throws(ValidationError) {
|
||||||
|
if let rooms {
|
||||||
|
guard rooms.count > 0 else {
|
||||||
|
throw ValidationError("Trunk size should have associated rooms / registers.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let height {
|
||||||
|
guard height > 0 else {
|
||||||
|
throw ValidationError("Trunk size height should be greater than 0.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DuctSizing.TrunkSize {
|
||||||
|
|
||||||
|
struct Migrate: AsyncMigration {
|
||||||
|
let name = "CreateTrunkSize"
|
||||||
|
|
||||||
|
func prepare(on database: any Database) async throws {
|
||||||
|
try await database.schema(TrunkModel.schema)
|
||||||
|
.id()
|
||||||
|
.field("height", .int8)
|
||||||
|
.field("name", .string)
|
||||||
|
.field("type", .string, .required)
|
||||||
|
.field(
|
||||||
|
"projectID", .uuid, .required, .references(ProjectModel.schema, "id", onDelete: .cascade)
|
||||||
|
)
|
||||||
|
.create()
|
||||||
|
|
||||||
|
try await database.schema(TrunkRoomModel.schema)
|
||||||
|
.id()
|
||||||
|
.field("registers", .array(of: .int), .required)
|
||||||
|
.field("type", .string, .required)
|
||||||
|
.field(
|
||||||
|
"trunkID", .uuid, .required, .references(TrunkModel.schema, "id", onDelete: .cascade)
|
||||||
|
)
|
||||||
|
.field(
|
||||||
|
"roomID", .uuid, .required, .references(RoomModel.schema, "id", onDelete: .cascade)
|
||||||
|
)
|
||||||
|
.unique(on: "trunkID", "roomID", "type")
|
||||||
|
.create()
|
||||||
|
}
|
||||||
|
|
||||||
|
func revert(on database: any Database) async throws {
|
||||||
|
try await database.schema(TrunkRoomModel.schema).delete()
|
||||||
|
try await database.schema(TrunkModel.schema).delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pivot table for associating rooms and trunks.
|
||||||
|
final class TrunkRoomModel: Model, @unchecked Sendable {
|
||||||
|
|
||||||
|
static let schema = "room+trunk"
|
||||||
|
|
||||||
|
@ID(key: .id)
|
||||||
|
var id: UUID?
|
||||||
|
|
||||||
|
@Parent(key: "trunkID")
|
||||||
|
var trunk: TrunkModel
|
||||||
|
|
||||||
|
@Parent(key: "roomID")
|
||||||
|
var room: RoomModel
|
||||||
|
|
||||||
|
@Field(key: "registers")
|
||||||
|
var registers: [Int]
|
||||||
|
|
||||||
|
@Field(key: "type")
|
||||||
|
var type: String
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID? = nil,
|
||||||
|
trunkID: TrunkModel.IDValue,
|
||||||
|
roomID: RoomModel.IDValue,
|
||||||
|
registers: [Int],
|
||||||
|
type: DuctSizing.TrunkSize.TrunkType
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
$trunk.id = trunkID
|
||||||
|
$room.id = roomID
|
||||||
|
self.registers = registers
|
||||||
|
self.type = type.rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDTO(on database: any Database) async throws -> DuctSizing.TrunkSize.RoomProxy {
|
||||||
|
guard let room = try await RoomModel.find($room.id, on: database) else {
|
||||||
|
throw NotFoundError()
|
||||||
|
}
|
||||||
|
return .init(
|
||||||
|
room: try room.toDTO(),
|
||||||
|
registers: registers
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
final class TrunkModel: Model, @unchecked Sendable {
|
||||||
|
|
||||||
|
static let schema = "trunk"
|
||||||
|
|
||||||
|
@ID(key: .id)
|
||||||
|
var id: UUID?
|
||||||
|
|
||||||
|
@Parent(key: "projectID")
|
||||||
|
var project: ProjectModel
|
||||||
|
|
||||||
|
@OptionalField(key: "height")
|
||||||
|
var height: Int?
|
||||||
|
|
||||||
|
@Field(key: "type")
|
||||||
|
var type: String
|
||||||
|
|
||||||
|
@OptionalField(key: "name")
|
||||||
|
var name: String?
|
||||||
|
|
||||||
|
@Children(for: \.$trunk)
|
||||||
|
var rooms: [TrunkRoomModel]
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID? = nil,
|
||||||
|
projectID: Project.ID,
|
||||||
|
type: DuctSizing.TrunkSize.TrunkType,
|
||||||
|
height: Int? = nil,
|
||||||
|
name: String? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
$project.id = projectID
|
||||||
|
self.height = height
|
||||||
|
self.type = type.rawValue
|
||||||
|
self.name = name
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDTO(on database: any Database) async throws -> DuctSizing.TrunkSize {
|
||||||
|
let rooms = try await withThrowingTaskGroup(of: DuctSizing.TrunkSize.RoomProxy.self) { group in
|
||||||
|
for room in self.rooms {
|
||||||
|
group.addTask {
|
||||||
|
try await room.toDTO(on: database)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await group.reduce(into: [DuctSizing.TrunkSize.RoomProxy]()) {
|
||||||
|
$0.append($1)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
return try .init(
|
||||||
|
id: requireID(),
|
||||||
|
projectID: $project.id,
|
||||||
|
type: .init(rawValue: type)!,
|
||||||
|
rooms: rooms,
|
||||||
|
height: height,
|
||||||
|
name: name
|
||||||
|
)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyUpdates(
|
||||||
|
_ updates: DuctSizing.TrunkSize.Update,
|
||||||
|
on database: any Database
|
||||||
|
) async throws {
|
||||||
|
if let type = updates.type, type.rawValue != self.type {
|
||||||
|
self.type = type.rawValue
|
||||||
|
}
|
||||||
|
if let height = updates.height, height != self.height {
|
||||||
|
self.height = height
|
||||||
|
}
|
||||||
|
if let name = updates.name, name != self.name {
|
||||||
|
self.name = name
|
||||||
|
}
|
||||||
|
if hasChanges {
|
||||||
|
try await self.save(on: database)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let updateRooms = updates.rooms else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update rooms.
|
||||||
|
let rooms = try await TrunkRoomModel.query(on: database)
|
||||||
|
.with(\.$room)
|
||||||
|
.filter(\.$trunk.$id == requireID())
|
||||||
|
.all()
|
||||||
|
|
||||||
|
for (roomID, registers) in updateRooms {
|
||||||
|
if let currRoom = rooms.first(where: { $0.$room.id == roomID }) {
|
||||||
|
database.logger.debug("CURRENT ROOM: \(currRoom.room.name)")
|
||||||
|
if registers != currRoom.registers {
|
||||||
|
database.logger.debug("Updating registers for: \(currRoom.room.name)")
|
||||||
|
currRoom.registers = registers
|
||||||
|
}
|
||||||
|
if currRoom.hasChanges {
|
||||||
|
try await currRoom.save(on: database)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
database.logger.debug("CREATING NEW TrunkRoomModel")
|
||||||
|
let newModel = try TrunkRoomModel(
|
||||||
|
trunkID: requireID(),
|
||||||
|
roomID: roomID,
|
||||||
|
registers: registers,
|
||||||
|
type: .init(rawValue: type)!
|
||||||
|
)
|
||||||
|
try await newModel.save(on: database)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let roomsToDelete = rooms.filter {
|
||||||
|
!updateRooms.keys.contains($0.$room.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
for room in roomsToDelete {
|
||||||
|
try await room.delete(on: database)
|
||||||
|
}
|
||||||
|
|
||||||
|
database.logger.debug("DONE WITH UPDATES")
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Array where Element == TrunkModel {
|
||||||
|
|
||||||
|
func toDTO(on database: any Database) async throws -> [DuctSizing.TrunkSize] {
|
||||||
|
try await withThrowingTaskGroup(of: DuctSizing.TrunkSize.self) { group in
|
||||||
|
for model in self {
|
||||||
|
group.addTask {
|
||||||
|
try await model.toDTO(on: database)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return try await group.reduce(into: [DuctSizing.TrunkSize]()) {
|
||||||
|
$0.append($1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
283
Sources/DatabaseClient/UserProfile.swift
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
import Dependencies
|
||||||
|
import DependenciesMacros
|
||||||
|
import Fluent
|
||||||
|
import ManualDCore
|
||||||
|
import Vapor
|
||||||
|
|
||||||
|
extension DatabaseClient {
|
||||||
|
@DependencyClient
|
||||||
|
public struct UserProfile: Sendable {
|
||||||
|
public var create: @Sendable (User.Profile.Create) async throws -> User.Profile
|
||||||
|
public var delete: @Sendable (User.Profile.ID) async throws -> Void
|
||||||
|
public var fetch: @Sendable (User.ID) async throws -> User.Profile?
|
||||||
|
public var get: @Sendable (User.Profile.ID) async throws -> User.Profile?
|
||||||
|
public var update: @Sendable (User.Profile.ID, User.Profile.Update) async throws -> User.Profile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DatabaseClient.UserProfile: TestDependencyKey {
|
||||||
|
|
||||||
|
public static let testValue = Self()
|
||||||
|
|
||||||
|
public static func live(database: any Database) -> Self {
|
||||||
|
.init(
|
||||||
|
create: { profile in
|
||||||
|
try profile.validate()
|
||||||
|
let model = profile.toModel()
|
||||||
|
try await model.save(on: database)
|
||||||
|
return try model.toDTO()
|
||||||
|
},
|
||||||
|
delete: { id in
|
||||||
|
guard let model = try await UserProfileModel.find(id, on: database) else {
|
||||||
|
throw NotFoundError()
|
||||||
|
}
|
||||||
|
try await model.delete(on: database)
|
||||||
|
},
|
||||||
|
fetch: { userID in
|
||||||
|
try await UserProfileModel.query(on: database)
|
||||||
|
.with(\.$user)
|
||||||
|
.filter(\.$user.$id == userID)
|
||||||
|
.first()
|
||||||
|
.map { try $0.toDTO() }
|
||||||
|
},
|
||||||
|
get: { id in
|
||||||
|
try await UserProfileModel.find(id, on: database)
|
||||||
|
.map { try $0.toDTO() }
|
||||||
|
},
|
||||||
|
update: { id, updates in
|
||||||
|
guard let model = try await UserProfileModel.find(id, on: database) else {
|
||||||
|
throw NotFoundError()
|
||||||
|
}
|
||||||
|
try updates.validate()
|
||||||
|
model.applyUpdates(updates)
|
||||||
|
if model.hasChanges {
|
||||||
|
try await model.save(on: database)
|
||||||
|
}
|
||||||
|
return try model.toDTO()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension User.Profile.Create {
|
||||||
|
|
||||||
|
func validate() throws(ValidationError) {
|
||||||
|
guard !firstName.isEmpty else {
|
||||||
|
throw ValidationError("User first name should not be empty.")
|
||||||
|
}
|
||||||
|
guard !lastName.isEmpty else {
|
||||||
|
throw ValidationError("User last name should not be empty.")
|
||||||
|
}
|
||||||
|
guard !companyName.isEmpty else {
|
||||||
|
throw ValidationError("User company name should not be empty.")
|
||||||
|
}
|
||||||
|
guard !streetAddress.isEmpty else {
|
||||||
|
throw ValidationError("User street address should not be empty.")
|
||||||
|
}
|
||||||
|
guard !city.isEmpty else {
|
||||||
|
throw ValidationError("User city should not be empty.")
|
||||||
|
}
|
||||||
|
guard !state.isEmpty else {
|
||||||
|
throw ValidationError("User state should not be empty.")
|
||||||
|
}
|
||||||
|
guard !zipCode.isEmpty else {
|
||||||
|
throw ValidationError("User zip code should not be empty.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toModel() -> UserProfileModel {
|
||||||
|
.init(
|
||||||
|
userID: userID,
|
||||||
|
firstName: firstName,
|
||||||
|
lastName: lastName,
|
||||||
|
companyName: companyName,
|
||||||
|
streetAddress: streetAddress,
|
||||||
|
city: city,
|
||||||
|
state: state,
|
||||||
|
zipCode: zipCode,
|
||||||
|
theme: theme
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension User.Profile.Update {
|
||||||
|
|
||||||
|
func validate() throws(ValidationError) {
|
||||||
|
if let firstName {
|
||||||
|
guard !firstName.isEmpty else {
|
||||||
|
throw ValidationError("User first name should not be empty.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let lastName {
|
||||||
|
guard !lastName.isEmpty else {
|
||||||
|
throw ValidationError("User last name should not be empty.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let companyName {
|
||||||
|
guard !companyName.isEmpty else {
|
||||||
|
throw ValidationError("User company name should not be empty.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let streetAddress {
|
||||||
|
guard !streetAddress.isEmpty else {
|
||||||
|
throw ValidationError("User street address should not be empty.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let city {
|
||||||
|
guard !city.isEmpty else {
|
||||||
|
throw ValidationError("User city should not be empty.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let state {
|
||||||
|
guard !state.isEmpty else {
|
||||||
|
throw ValidationError("User state should not be empty.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let zipCode {
|
||||||
|
guard !zipCode.isEmpty else {
|
||||||
|
throw ValidationError("User zip code should not be empty.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension User.Profile {
|
||||||
|
|
||||||
|
struct Migrate: AsyncMigration {
|
||||||
|
let name = "Create UserProfile"
|
||||||
|
|
||||||
|
func prepare(on database: any Database) async throws {
|
||||||
|
try await database.schema(UserProfileModel.schema)
|
||||||
|
.id()
|
||||||
|
.field("firstName", .string, .required)
|
||||||
|
.field("lastName", .string, .required)
|
||||||
|
.field("companyName", .string, .required)
|
||||||
|
.field("streetAddress", .string, .required)
|
||||||
|
.field("city", .string, .required)
|
||||||
|
.field("state", .string, .required)
|
||||||
|
.field("zipCode", .string, .required)
|
||||||
|
.field("theme", .string)
|
||||||
|
.field("userID", .uuid, .references(UserModel.schema, "id", onDelete: .cascade))
|
||||||
|
.field("createdAt", .datetime)
|
||||||
|
.field("updatedAt", .datetime)
|
||||||
|
.unique(on: "userID")
|
||||||
|
.create()
|
||||||
|
}
|
||||||
|
|
||||||
|
func revert(on database: any Database) async throws {
|
||||||
|
try await database.schema(UserProfileModel.schema).delete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class UserProfileModel: Model, @unchecked Sendable {
|
||||||
|
|
||||||
|
static let schema = "user_profile"
|
||||||
|
|
||||||
|
@ID(key: .id)
|
||||||
|
var id: UUID?
|
||||||
|
|
||||||
|
@Parent(key: "userID")
|
||||||
|
var user: UserModel
|
||||||
|
|
||||||
|
@Field(key: "firstName")
|
||||||
|
var firstName: String
|
||||||
|
|
||||||
|
@Field(key: "lastName")
|
||||||
|
var lastName: String
|
||||||
|
|
||||||
|
@Field(key: "companyName")
|
||||||
|
var companyName: String
|
||||||
|
|
||||||
|
@Field(key: "streetAddress")
|
||||||
|
var streetAddress: String
|
||||||
|
|
||||||
|
@Field(key: "city")
|
||||||
|
var city: String
|
||||||
|
|
||||||
|
@Field(key: "state")
|
||||||
|
var state: String
|
||||||
|
|
||||||
|
@Field(key: "zipCode")
|
||||||
|
var zipCode: String
|
||||||
|
|
||||||
|
@Field(key: "theme")
|
||||||
|
var theme: String?
|
||||||
|
|
||||||
|
@Timestamp(key: "createdAt", on: .create, format: .iso8601)
|
||||||
|
var createdAt: Date?
|
||||||
|
|
||||||
|
@Timestamp(key: "updatedAt", on: .update, format: .iso8601)
|
||||||
|
var updatedAt: Date?
|
||||||
|
|
||||||
|
init() {}
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: UUID? = nil,
|
||||||
|
userID: User.ID,
|
||||||
|
firstName: String,
|
||||||
|
lastName: String,
|
||||||
|
companyName: String,
|
||||||
|
streetAddress: String,
|
||||||
|
city: String,
|
||||||
|
state: String,
|
||||||
|
zipCode: String,
|
||||||
|
theme: Theme? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
$user.id = userID
|
||||||
|
self.firstName = firstName
|
||||||
|
self.lastName = lastName
|
||||||
|
self.companyName = companyName
|
||||||
|
self.streetAddress = streetAddress
|
||||||
|
self.city = city
|
||||||
|
self.state = state
|
||||||
|
self.zipCode = zipCode
|
||||||
|
self.theme = theme?.rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
func toDTO() throws -> User.Profile {
|
||||||
|
try .init(
|
||||||
|
id: requireID(),
|
||||||
|
userID: $user.id,
|
||||||
|
firstName: firstName,
|
||||||
|
lastName: lastName,
|
||||||
|
companyName: companyName,
|
||||||
|
streetAddress: streetAddress,
|
||||||
|
city: city,
|
||||||
|
state: state,
|
||||||
|
zipCode: zipCode,
|
||||||
|
theme: self.theme.flatMap(Theme.init),
|
||||||
|
createdAt: createdAt!,
|
||||||
|
updatedAt: updatedAt!
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyUpdates(_ updates: User.Profile.Update) {
|
||||||
|
if let firstName = updates.firstName, firstName != self.firstName {
|
||||||
|
self.firstName = firstName
|
||||||
|
}
|
||||||
|
if let lastName = updates.lastName, lastName != self.lastName {
|
||||||
|
self.lastName = lastName
|
||||||
|
}
|
||||||
|
if let companyName = updates.companyName, companyName != self.companyName {
|
||||||
|
self.companyName = companyName
|
||||||
|
}
|
||||||
|
if let streetAddress = updates.streetAddress, streetAddress != self.streetAddress {
|
||||||
|
self.streetAddress = streetAddress
|
||||||
|
}
|
||||||
|
if let city = updates.city, city != self.city {
|
||||||
|
self.city = city
|
||||||
|
}
|
||||||
|
if let state = updates.state, state != self.state {
|
||||||
|
self.state = state
|
||||||
|
}
|
||||||
|
if let zipCode = updates.zipCode, zipCode != self.zipCode {
|
||||||
|
self.zipCode = zipCode
|
||||||
|
}
|
||||||
|
if let theme = updates.theme, theme.rawValue != self.theme {
|
||||||
|
self.theme = theme.rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -80,12 +80,11 @@ extension User {
|
|||||||
func prepare(on database: any Database) async throws {
|
func prepare(on database: any Database) async throws {
|
||||||
try await database.schema(UserModel.schema)
|
try await database.schema(UserModel.schema)
|
||||||
.id()
|
.id()
|
||||||
.field("username", .string, .required)
|
|
||||||
.field("email", .string, .required)
|
.field("email", .string, .required)
|
||||||
.field("password_hash", .string, .required)
|
.field("password_hash", .string, .required)
|
||||||
.field("createdAt", .datetime)
|
.field("createdAt", .datetime)
|
||||||
.field("updatedAt", .datetime)
|
.field("updatedAt", .datetime)
|
||||||
.unique(on: "email", "username")
|
.unique(on: "email")
|
||||||
.create()
|
.create()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +103,8 @@ extension User.Token {
|
|||||||
.id()
|
.id()
|
||||||
.field("value", .string, .required)
|
.field("value", .string, .required)
|
||||||
.field("user_id", .uuid, .required, .references(UserModel.schema, "id"))
|
.field("user_id", .uuid, .required, .references(UserModel.schema, "id"))
|
||||||
|
.field("createdAt", .datetime)
|
||||||
|
.field("updatedAt", .datetime)
|
||||||
.unique(on: "value")
|
.unique(on: "value")
|
||||||
.create()
|
.create()
|
||||||
}
|
}
|
||||||
@@ -126,13 +127,10 @@ extension User.Create {
|
|||||||
|
|
||||||
func toModel() throws -> UserModel {
|
func toModel() throws -> UserModel {
|
||||||
try validate()
|
try validate()
|
||||||
return try .init(username: username, email: email, passwordHash: User.hashPassword(password))
|
return try .init(email: email, passwordHash: User.hashPassword(password))
|
||||||
}
|
}
|
||||||
|
|
||||||
func validate() throws {
|
func validate() throws {
|
||||||
guard !username.isEmpty else {
|
|
||||||
throw ValidationError("Username should not be empty.")
|
|
||||||
}
|
|
||||||
guard !email.isEmpty else {
|
guard !email.isEmpty else {
|
||||||
throw ValidationError("Email should not be empty")
|
throw ValidationError("Email should not be empty")
|
||||||
}
|
}
|
||||||
@@ -152,9 +150,6 @@ final class UserModel: Model, @unchecked Sendable {
|
|||||||
@ID(key: .id)
|
@ID(key: .id)
|
||||||
var id: UUID?
|
var id: UUID?
|
||||||
|
|
||||||
@Field(key: "username")
|
|
||||||
var username: String
|
|
||||||
|
|
||||||
@Field(key: "email")
|
@Field(key: "email")
|
||||||
var email: String
|
var email: String
|
||||||
|
|
||||||
@@ -174,12 +169,10 @@ final class UserModel: Model, @unchecked Sendable {
|
|||||||
|
|
||||||
init(
|
init(
|
||||||
id: UUID? = nil,
|
id: UUID? = nil,
|
||||||
username: String,
|
|
||||||
email: String,
|
email: String,
|
||||||
passwordHash: String
|
passwordHash: String
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.username = username
|
|
||||||
self.email = email
|
self.email = email
|
||||||
self.passwordHash = passwordHash
|
self.passwordHash = passwordHash
|
||||||
}
|
}
|
||||||
@@ -188,7 +181,6 @@ final class UserModel: Model, @unchecked Sendable {
|
|||||||
try .init(
|
try .init(
|
||||||
id: requireID(),
|
id: requireID(),
|
||||||
email: email,
|
email: email,
|
||||||
username: username,
|
|
||||||
createdAt: createdAt!,
|
createdAt: createdAt!,
|
||||||
updatedAt: updatedAt!
|
updatedAt: updatedAt!
|
||||||
)
|
)
|
||||||
@@ -237,7 +229,7 @@ final class UserTokenModel: Model, Codable, @unchecked Sendable {
|
|||||||
|
|
||||||
extension User: Authenticatable {}
|
extension User: Authenticatable {}
|
||||||
extension User: SessionAuthenticatable {
|
extension User: SessionAuthenticatable {
|
||||||
public var sessionID: String { username }
|
public var sessionID: String { email }
|
||||||
}
|
}
|
||||||
|
|
||||||
public struct UserPasswordAuthenticator: AsyncBasicAuthenticator {
|
public struct UserPasswordAuthenticator: AsyncBasicAuthenticator {
|
||||||
@@ -248,7 +240,7 @@ public struct UserPasswordAuthenticator: AsyncBasicAuthenticator {
|
|||||||
public func authenticate(basic: BasicAuthorization, for request: Request) async throws {
|
public func authenticate(basic: BasicAuthorization, for request: Request) async throws {
|
||||||
guard
|
guard
|
||||||
let user = try await UserModel.query(on: request.db)
|
let user = try await UserModel.query(on: request.db)
|
||||||
.filter(\UserModel.$username == basic.username)
|
.filter(\UserModel.$email == basic.username)
|
||||||
.first(),
|
.first(),
|
||||||
try user.verifyPassword(basic.password)
|
try user.verifyPassword(basic.password)
|
||||||
else {
|
else {
|
||||||
@@ -284,7 +276,7 @@ public struct UserSessionAuthenticator: AsyncSessionAuthenticator {
|
|||||||
public func authenticate(sessionID: User.SessionID, for request: Request) async throws {
|
public func authenticate(sessionID: User.SessionID, for request: Request) async throws {
|
||||||
guard
|
guard
|
||||||
let user = try await UserModel.query(on: request.db)
|
let user = try await UserModel.query(on: request.db)
|
||||||
.filter(\UserModel.$username == sessionID)
|
.filter(\UserModel.$email == sessionID)
|
||||||
.first()
|
.first()
|
||||||
else {
|
else {
|
||||||
throw Abort(.unauthorized)
|
throw Abort(.unauthorized)
|
||||||
|
|||||||
@@ -1,6 +1,51 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import ManualDCore
|
import ManualDCore
|
||||||
|
|
||||||
|
extension Room {
|
||||||
|
|
||||||
|
var heatingLoadPerRegister: Double {
|
||||||
|
|
||||||
|
heatingLoad / Double(registerCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func coolingSensiblePerRegister(projectSHR: Double) -> Double {
|
||||||
|
let sensible = coolingSensible ?? (coolingTotal * projectSHR)
|
||||||
|
return sensible / Double(registerCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DuctSizing.TrunkSize.RoomProxy {
|
||||||
|
|
||||||
|
// We need to make sure if registers got removed after a trunk
|
||||||
|
// was already made / saved that we do not include registers that
|
||||||
|
// no longer exist.
|
||||||
|
private var actualRegisterCount: Int {
|
||||||
|
guard registers.count <= room.registerCount else {
|
||||||
|
return room.registerCount
|
||||||
|
}
|
||||||
|
return registers.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalHeatingLoad: Double {
|
||||||
|
room.heatingLoadPerRegister * Double(actualRegisterCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func totalCoolingSensible(projectSHR: Double) -> Double {
|
||||||
|
room.coolingSensiblePerRegister(projectSHR: projectSHR) * Double(actualRegisterCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DuctSizing.TrunkSize {
|
||||||
|
|
||||||
|
var totalHeatingLoad: Double {
|
||||||
|
rooms.reduce(into: 0) { $0 += $1.totalHeatingLoad }
|
||||||
|
}
|
||||||
|
|
||||||
|
func totalCoolingSensible(projectSHR: Double) -> Double {
|
||||||
|
rooms.reduce(into: 0) { $0 += $1.totalCoolingSensible(projectSHR: projectSHR) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension ComponentPressureLosses {
|
extension ComponentPressureLosses {
|
||||||
var totalLosses: Double { values.reduce(0) { $0 + $1 } }
|
var totalLosses: Double { values.reduce(0) { $0 + $1 } }
|
||||||
}
|
}
|
||||||
@@ -19,6 +64,8 @@ func roundSize(_ size: Double) throws -> Int {
|
|||||||
throw ManualDError(message: "Size should be less than 24.")
|
throw ManualDError(message: "Size should be less than 24.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// let size = size.rounded(.toNearestOrEven)
|
||||||
|
|
||||||
switch size {
|
switch size {
|
||||||
case 0..<4:
|
case 0..<4:
|
||||||
return 4
|
return 4
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ extension ManualDClient: DependencyKey {
|
|||||||
throw ManualDError(message: "Total Effective Length should be greater than 0.")
|
throw ManualDError(message: "Total Effective Length should be greater than 0.")
|
||||||
}
|
}
|
||||||
|
|
||||||
let totalComponentLosses = request.componentPressureLosses.totalLosses
|
let totalComponentLosses = request.componentPressureLosses.total
|
||||||
let availableStaticPressure = request.externalStaticPressure - totalComponentLosses
|
let availableStaticPressure = request.externalStaticPressure - totalComponentLosses
|
||||||
let frictionRate = availableStaticPressure * 100.0 / Double(request.totalEffectiveLength)
|
let frictionRate = availableStaticPressure * 100.0 / Double(request.totalEffectiveLength)
|
||||||
return .init(availableStaticPressure: availableStaticPressure, frictionRate: frictionRate)
|
return .init(availableStaticPressure: availableStaticPressure, frictionRate: frictionRate)
|
||||||
@@ -38,23 +38,7 @@ extension ManualDClient: DependencyKey {
|
|||||||
},
|
},
|
||||||
equivalentRectangularDuct: { request in
|
equivalentRectangularDuct: { request in
|
||||||
let width = (Double.pi * (pow(Double(request.roundSize) / 2.0, 2.0))) / Double(request.height)
|
let width = (Double.pi * (pow(Double(request.roundSize) / 2.0, 2.0))) / Double(request.height)
|
||||||
// Round the width up or fail (really should never fail since we know the input is a number).
|
return .init(height: request.height, width: Int(width.rounded(.toNearestOrEven)))
|
||||||
guard let widthStr = numberFormatter.string(for: width),
|
|
||||||
let widthInt = Int(widthStr)
|
|
||||||
else {
|
|
||||||
throw ManualDError(
|
|
||||||
message: "Failed to convert to to rectangular duct size, width: \(width)"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return .init(height: request.height, width: widthInt)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private let numberFormatter: NumberFormatter = {
|
|
||||||
let formatter = NumberFormatter()
|
|
||||||
formatter.maximumFractionDigits = 0
|
|
||||||
formatter.minimumFractionDigits = 0
|
|
||||||
formatter.roundingMode = .ceiling
|
|
||||||
return formatter
|
|
||||||
}()
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Dependencies
|
import Dependencies
|
||||||
import DependenciesMacros
|
import DependenciesMacros
|
||||||
|
import Logging
|
||||||
import ManualDCore
|
import ManualDCore
|
||||||
|
|
||||||
@DependencyClient
|
@DependencyClient
|
||||||
@@ -9,6 +10,146 @@ public struct ManualDClient: Sendable {
|
|||||||
public var totalEffectiveLength: @Sendable (TotalEffectiveLengthRequest) async throws -> Int
|
public var totalEffectiveLength: @Sendable (TotalEffectiveLengthRequest) async throws -> Int
|
||||||
public var equivalentRectangularDuct:
|
public var equivalentRectangularDuct:
|
||||||
@Sendable (EquivalentRectangularDuctRequest) async throws -> EquivalentRectangularDuctResponse
|
@Sendable (EquivalentRectangularDuctRequest) async throws -> EquivalentRectangularDuctResponse
|
||||||
|
|
||||||
|
public func calculateSizes(
|
||||||
|
rooms: [Room],
|
||||||
|
trunks: [DuctSizing.TrunkSize],
|
||||||
|
equipmentInfo: EquipmentInfo,
|
||||||
|
maxSupplyLength: EffectiveLength,
|
||||||
|
maxReturnLength: EffectiveLength,
|
||||||
|
designFrictionRate: Double,
|
||||||
|
projectSHR: Double,
|
||||||
|
logger: Logger? = nil
|
||||||
|
) async throws -> (rooms: [DuctSizing.RoomContainer], trunks: [DuctSizing.TrunkContainer]) {
|
||||||
|
try await (
|
||||||
|
calculateSizes(
|
||||||
|
rooms: rooms, equipmentInfo: equipmentInfo,
|
||||||
|
maxSupplyLength: maxSupplyLength, maxReturnLength: maxReturnLength,
|
||||||
|
designFrictionRate: designFrictionRate, projectSHR: projectSHR
|
||||||
|
),
|
||||||
|
calculateSizes(
|
||||||
|
rooms: rooms, trunks: trunks, equipmentInfo: equipmentInfo,
|
||||||
|
maxSupplyLength: maxSupplyLength, maxReturnLength: maxReturnLength,
|
||||||
|
designFrictionRate: designFrictionRate, projectSHR: projectSHR)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func calculateSizes(
|
||||||
|
rooms: [Room],
|
||||||
|
equipmentInfo: EquipmentInfo,
|
||||||
|
maxSupplyLength: EffectiveLength,
|
||||||
|
maxReturnLength: EffectiveLength,
|
||||||
|
designFrictionRate: Double,
|
||||||
|
projectSHR: Double,
|
||||||
|
logger: Logger? = nil
|
||||||
|
) async throws -> [DuctSizing.RoomContainer] {
|
||||||
|
|
||||||
|
var retval: [DuctSizing.RoomContainer] = []
|
||||||
|
let totalHeatingLoad = rooms.totalHeatingLoad
|
||||||
|
let totalCoolingSensible = rooms.totalCoolingSensible(shr: projectSHR)
|
||||||
|
|
||||||
|
for room in rooms {
|
||||||
|
let heatingLoad = room.heatingLoadPerRegister
|
||||||
|
let coolingLoad = room.coolingSensiblePerRegister(projectSHR: projectSHR)
|
||||||
|
let heatingPercent = heatingLoad / totalHeatingLoad
|
||||||
|
let coolingPercent = coolingLoad / totalCoolingSensible
|
||||||
|
let heatingCFM = heatingPercent * Double(equipmentInfo.heatingCFM)
|
||||||
|
let coolingCFM = coolingPercent * Double(equipmentInfo.coolingCFM)
|
||||||
|
let designCFM = DuctSizing.DesignCFM(heating: heatingCFM, cooling: coolingCFM)
|
||||||
|
let sizes = try await self.ductSize(
|
||||||
|
.init(designCFM: Int(designCFM.value), frictionRate: designFrictionRate)
|
||||||
|
)
|
||||||
|
|
||||||
|
for n in 1...room.registerCount {
|
||||||
|
|
||||||
|
var rectangularWidth: Int? = nil
|
||||||
|
let rectangularSize = room.rectangularSizes?
|
||||||
|
.first(where: { $0.register == nil || $0.register == n })
|
||||||
|
|
||||||
|
if let rectangularSize {
|
||||||
|
let response = try await self.equivalentRectangularDuct(
|
||||||
|
.init(round: sizes.finalSize, height: rectangularSize.height)
|
||||||
|
)
|
||||||
|
rectangularWidth = response.width
|
||||||
|
}
|
||||||
|
|
||||||
|
retval.append(
|
||||||
|
.init(
|
||||||
|
roomID: room.id,
|
||||||
|
roomName: "\(room.name)-\(n)",
|
||||||
|
roomRegister: n,
|
||||||
|
heatingLoad: heatingLoad,
|
||||||
|
coolingLoad: coolingLoad,
|
||||||
|
heatingCFM: heatingCFM,
|
||||||
|
coolingCFM: coolingCFM,
|
||||||
|
designCFM: designCFM,
|
||||||
|
roundSize: sizes.ductulatorSize,
|
||||||
|
finalSize: sizes.finalSize,
|
||||||
|
velocity: sizes.velocity,
|
||||||
|
flexSize: sizes.flexSize,
|
||||||
|
rectangularSize: rectangularSize,
|
||||||
|
rectangularWidth: rectangularWidth
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return retval
|
||||||
|
}
|
||||||
|
|
||||||
|
func calculateSizes(
|
||||||
|
rooms: [Room],
|
||||||
|
trunks: [DuctSizing.TrunkSize],
|
||||||
|
equipmentInfo: EquipmentInfo,
|
||||||
|
maxSupplyLength: EffectiveLength,
|
||||||
|
maxReturnLength: EffectiveLength,
|
||||||
|
designFrictionRate: Double,
|
||||||
|
projectSHR: Double,
|
||||||
|
logger: Logger? = nil
|
||||||
|
) async throws -> [DuctSizing.TrunkContainer] {
|
||||||
|
|
||||||
|
var retval = [DuctSizing.TrunkContainer]()
|
||||||
|
let totalHeatingLoad = rooms.totalHeatingLoad
|
||||||
|
let totalCoolingSensible = rooms.totalCoolingSensible(shr: projectSHR)
|
||||||
|
|
||||||
|
for trunk in trunks {
|
||||||
|
let heatingLoad = trunk.totalHeatingLoad
|
||||||
|
let coolingLoad = trunk.totalCoolingSensible(projectSHR: projectSHR)
|
||||||
|
let heatingPercent = heatingLoad / totalHeatingLoad
|
||||||
|
let coolingPercent = coolingLoad / totalCoolingSensible
|
||||||
|
let heatingCFM = heatingPercent * Double(equipmentInfo.heatingCFM)
|
||||||
|
let coolingCFM = coolingPercent * Double(equipmentInfo.coolingCFM)
|
||||||
|
let designCFM = DuctSizing.DesignCFM(heating: heatingCFM, cooling: coolingCFM)
|
||||||
|
let sizes = try await self.ductSize(
|
||||||
|
.init(designCFM: Int(designCFM.value), frictionRate: designFrictionRate)
|
||||||
|
)
|
||||||
|
var width: Int? = nil
|
||||||
|
if let height = trunk.height {
|
||||||
|
let rectangularSize = try await self.equivalentRectangularDuct(
|
||||||
|
.init(round: sizes.finalSize, height: height)
|
||||||
|
)
|
||||||
|
width = rectangularSize.width
|
||||||
|
}
|
||||||
|
|
||||||
|
retval.append(
|
||||||
|
.init(
|
||||||
|
trunk: trunk,
|
||||||
|
ductSize: .init(
|
||||||
|
designCFM: designCFM,
|
||||||
|
roundSize: sizes.ductulatorSize,
|
||||||
|
finalSize: sizes.finalSize,
|
||||||
|
velocity: sizes.velocity,
|
||||||
|
flexSize: sizes.flexSize,
|
||||||
|
height: trunk.height,
|
||||||
|
width: width
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return retval
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ManualDClient: TestDependencyKey {
|
extension ManualDClient: TestDependencyKey {
|
||||||
@@ -63,12 +204,12 @@ extension ManualDClient {
|
|||||||
public struct FrictionRateRequest: Codable, Equatable, Sendable {
|
public struct FrictionRateRequest: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
public let externalStaticPressure: Double
|
public let externalStaticPressure: Double
|
||||||
public let componentPressureLosses: ComponentPressureLosses
|
public let componentPressureLosses: [ComponentPressureLoss]
|
||||||
public let totalEffectiveLength: Int
|
public let totalEffectiveLength: Int
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
externalStaticPressure: Double,
|
externalStaticPressure: Double,
|
||||||
componentPressureLosses: ComponentPressureLosses,
|
componentPressureLosses: [ComponentPressureLoss],
|
||||||
totalEffectiveLength: Int
|
totalEffectiveLength: Int
|
||||||
) {
|
) {
|
||||||
self.externalStaticPressure = externalStaticPressure
|
self.externalStaticPressure = externalStaticPressure
|
||||||
|
|||||||
@@ -52,6 +52,26 @@ extension ComponentPressureLoss {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct Update: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
public let name: String?
|
||||||
|
public let value: Double?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
name: String? = nil,
|
||||||
|
value: Double? = nil
|
||||||
|
) {
|
||||||
|
self.name = name
|
||||||
|
self.value = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Array where Element == ComponentPressureLoss {
|
||||||
|
public var total: Double {
|
||||||
|
reduce(into: 0) { $0 += $1.value }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public typealias ComponentPressureLosses = [String: Double]
|
public typealias ComponentPressureLosses = [String: Double]
|
||||||
|
|||||||
248
Sources/ManualDCore/DuctSizing.swift
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import Dependencies
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum DuctSizing {
|
||||||
|
|
||||||
|
public struct RectangularDuct: Codable, Equatable, Identifiable, Sendable {
|
||||||
|
|
||||||
|
public let id: UUID
|
||||||
|
public let register: Int?
|
||||||
|
public let height: Int
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: UUID = .init(),
|
||||||
|
register: Int? = nil,
|
||||||
|
height: Int,
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.register = register
|
||||||
|
self.height = height
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct SizeContainer: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
public let designCFM: DesignCFM
|
||||||
|
public let roundSize: Double
|
||||||
|
public let finalSize: Int
|
||||||
|
public let velocity: Int
|
||||||
|
public let flexSize: Int
|
||||||
|
public let height: Int?
|
||||||
|
public let width: Int?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
designCFM: DuctSizing.DesignCFM,
|
||||||
|
roundSize: Double,
|
||||||
|
finalSize: Int,
|
||||||
|
velocity: Int,
|
||||||
|
flexSize: Int,
|
||||||
|
height: Int? = nil,
|
||||||
|
width: Int? = nil
|
||||||
|
) {
|
||||||
|
self.designCFM = designCFM
|
||||||
|
self.roundSize = roundSize
|
||||||
|
self.finalSize = finalSize
|
||||||
|
self.velocity = velocity
|
||||||
|
self.flexSize = flexSize
|
||||||
|
self.height = height
|
||||||
|
self.width = width
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Uses SizeContainer
|
||||||
|
|
||||||
|
public struct RoomContainer: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
public let roomID: Room.ID
|
||||||
|
public let roomName: String
|
||||||
|
public let roomRegister: Int
|
||||||
|
public let heatingLoad: Double
|
||||||
|
public let coolingLoad: Double
|
||||||
|
public let heatingCFM: Double
|
||||||
|
public let coolingCFM: Double
|
||||||
|
public let designCFM: DesignCFM
|
||||||
|
public let roundSize: Double
|
||||||
|
public let finalSize: Int
|
||||||
|
public let velocity: Int
|
||||||
|
public let flexSize: Int
|
||||||
|
public let rectangularSize: RectangularDuct?
|
||||||
|
public let rectangularWidth: Int?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
roomID: Room.ID,
|
||||||
|
roomName: String,
|
||||||
|
roomRegister: Int,
|
||||||
|
heatingLoad: Double,
|
||||||
|
coolingLoad: Double,
|
||||||
|
heatingCFM: Double,
|
||||||
|
coolingCFM: Double,
|
||||||
|
designCFM: DesignCFM,
|
||||||
|
roundSize: Double,
|
||||||
|
finalSize: Int,
|
||||||
|
velocity: Int,
|
||||||
|
flexSize: Int,
|
||||||
|
rectangularSize: RectangularDuct? = nil,
|
||||||
|
rectangularWidth: Int? = nil
|
||||||
|
) {
|
||||||
|
self.roomID = roomID
|
||||||
|
self.roomName = roomName
|
||||||
|
self.roomRegister = roomRegister
|
||||||
|
self.heatingLoad = heatingLoad
|
||||||
|
self.coolingLoad = coolingLoad
|
||||||
|
self.heatingCFM = heatingCFM
|
||||||
|
self.coolingCFM = coolingCFM
|
||||||
|
self.designCFM = designCFM
|
||||||
|
self.roundSize = roundSize
|
||||||
|
self.finalSize = finalSize
|
||||||
|
self.velocity = velocity
|
||||||
|
self.flexSize = flexSize
|
||||||
|
self.rectangularSize = rectangularSize
|
||||||
|
self.rectangularWidth = rectangularWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum DesignCFM: Codable, Equatable, Sendable {
|
||||||
|
case heating(Double)
|
||||||
|
case cooling(Double)
|
||||||
|
|
||||||
|
public init(heating: Double, cooling: Double) {
|
||||||
|
if heating >= cooling {
|
||||||
|
self = .heating(heating)
|
||||||
|
} else {
|
||||||
|
self = .cooling(cooling)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public var value: Double {
|
||||||
|
switch self {
|
||||||
|
case .heating(let value): return value
|
||||||
|
case .cooling(let value): return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DuctSizing {
|
||||||
|
|
||||||
|
// Represents the database model that the duct sizes have been calculated
|
||||||
|
// for.
|
||||||
|
@dynamicMemberLookup
|
||||||
|
public struct TrunkContainer: Codable, Equatable, Identifiable, Sendable {
|
||||||
|
public var id: TrunkSize.ID { trunk.id }
|
||||||
|
|
||||||
|
public let trunk: TrunkSize
|
||||||
|
public let ductSize: SizeContainer
|
||||||
|
|
||||||
|
public init(
|
||||||
|
trunk: TrunkSize,
|
||||||
|
ductSize: SizeContainer
|
||||||
|
) {
|
||||||
|
self.trunk = trunk
|
||||||
|
self.ductSize = ductSize
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscript<T>(dynamicMember keyPath: KeyPath<DuctSizing.TrunkSize, T>) -> T {
|
||||||
|
trunk[keyPath: keyPath]
|
||||||
|
}
|
||||||
|
|
||||||
|
public subscript<T>(dynamicMember keyPath: KeyPath<DuctSizing.SizeContainer, T>) -> T {
|
||||||
|
ductSize[keyPath: keyPath]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Add an optional label that the user can set.
|
||||||
|
|
||||||
|
// Represents the database model.
|
||||||
|
public struct TrunkSize: Codable, Equatable, Identifiable, Sendable {
|
||||||
|
|
||||||
|
public let id: UUID
|
||||||
|
public let projectID: Project.ID
|
||||||
|
public let type: TrunkType
|
||||||
|
public let rooms: [RoomProxy]
|
||||||
|
public let height: Int?
|
||||||
|
public let name: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: UUID,
|
||||||
|
projectID: Project.ID,
|
||||||
|
type: DuctSizing.TrunkSize.TrunkType,
|
||||||
|
rooms: [DuctSizing.TrunkSize.RoomProxy],
|
||||||
|
height: Int? = nil,
|
||||||
|
name: String? = nil
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.projectID = projectID
|
||||||
|
self.type = type
|
||||||
|
self.rooms = rooms
|
||||||
|
self.height = height
|
||||||
|
self.name = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DuctSizing.TrunkSize {
|
||||||
|
public struct Create: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
public let projectID: Project.ID
|
||||||
|
public let type: TrunkType
|
||||||
|
public let rooms: [Room.ID: [Int]]
|
||||||
|
public let height: Int?
|
||||||
|
public let name: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
projectID: Project.ID,
|
||||||
|
type: DuctSizing.TrunkSize.TrunkType,
|
||||||
|
rooms: [Room.ID: [Int]],
|
||||||
|
height: Int? = nil,
|
||||||
|
name: String? = nil
|
||||||
|
) {
|
||||||
|
self.projectID = projectID
|
||||||
|
self.type = type
|
||||||
|
self.rooms = rooms
|
||||||
|
self.height = height
|
||||||
|
self.name = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Update: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
public let type: TrunkType?
|
||||||
|
public let rooms: [Room.ID: [Int]]?
|
||||||
|
public let height: Int?
|
||||||
|
public let name: String?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
type: DuctSizing.TrunkSize.TrunkType? = nil,
|
||||||
|
rooms: [Room.ID: [Int]]? = nil,
|
||||||
|
height: Int? = nil,
|
||||||
|
name: String? = nil
|
||||||
|
) {
|
||||||
|
self.type = type
|
||||||
|
self.rooms = rooms
|
||||||
|
self.height = height
|
||||||
|
self.name = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Make registers non-optional
|
||||||
|
public struct RoomProxy: Codable, Equatable, Identifiable, Sendable {
|
||||||
|
|
||||||
|
public var id: Room.ID { room.id }
|
||||||
|
public let room: Room
|
||||||
|
public let registers: [Int]
|
||||||
|
|
||||||
|
public init(room: Room, registers: [Int]) {
|
||||||
|
self.room = room
|
||||||
|
self.registers = registers
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TrunkType: String, CaseIterable, Codable, Equatable, Sendable {
|
||||||
|
case `return`
|
||||||
|
case supply
|
||||||
|
|
||||||
|
public static let allCases = [Self.supply, .return]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,26 @@ extension EffectiveLength {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct Update: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
public let name: String?
|
||||||
|
public let type: EffectiveLengthType?
|
||||||
|
public let straightLengths: [Int]?
|
||||||
|
public let groups: [Group]?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
name: String? = nil,
|
||||||
|
type: EffectiveLength.EffectiveLengthType? = nil,
|
||||||
|
straightLengths: [Int]? = nil,
|
||||||
|
groups: [EffectiveLength.Group]? = nil
|
||||||
|
) {
|
||||||
|
self.name = name
|
||||||
|
self.type = type
|
||||||
|
self.straightLengths = straightLengths
|
||||||
|
self.groups = groups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public enum EffectiveLengthType: String, CaseIterable, Codable, Sendable {
|
public enum EffectiveLengthType: String, CaseIterable, Codable, Sendable {
|
||||||
case `return`
|
case `return`
|
||||||
case supply
|
case supply
|
||||||
@@ -85,6 +105,38 @@ extension EffectiveLength {
|
|||||||
self.quantity = quantity
|
self.quantity = quantity
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct MaxContainer: Codable, Equatable, Sendable {
|
||||||
|
public let supply: EffectiveLength?
|
||||||
|
public let `return`: EffectiveLength?
|
||||||
|
|
||||||
|
public var total: Double? {
|
||||||
|
guard let supply else { return nil }
|
||||||
|
guard let `return` else { return nil }
|
||||||
|
return supply.totalEquivalentLength + `return`.totalEquivalentLength
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(supply: EffectiveLength? = nil, return: EffectiveLength? = nil) {
|
||||||
|
self.supply = supply
|
||||||
|
self.return = `return`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EffectiveLength {
|
||||||
|
public var totalEquivalentLength: Double {
|
||||||
|
straightLengths.reduce(into: 0.0) { $0 += Double($1) }
|
||||||
|
+ groups.totalEquivalentLength
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Array where Element == EffectiveLength.Group {
|
||||||
|
|
||||||
|
public var totalEquivalentLength: Double {
|
||||||
|
reduce(into: 0.0) {
|
||||||
|
$0 += ($1.value * Double($1.quantity))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
|
|||||||
@@ -52,18 +52,15 @@ extension EquipmentInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public struct Update: Codable, Equatable, Sendable {
|
public struct Update: Codable, Equatable, Sendable {
|
||||||
public let id: EquipmentInfo.ID
|
|
||||||
public let staticPressure: Double?
|
public let staticPressure: Double?
|
||||||
public let heatingCFM: Int?
|
public let heatingCFM: Int?
|
||||||
public let coolingCFM: Int?
|
public let coolingCFM: Int?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: EquipmentInfo.ID,
|
|
||||||
staticPressure: Double? = nil,
|
staticPressure: Double? = nil,
|
||||||
heatingCFM: Int? = nil,
|
heatingCFM: Int? = nil,
|
||||||
coolingCFM: Int? = nil
|
coolingCFM: Int? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
|
||||||
self.staticPressure = staticPressure
|
self.staticPressure = staticPressure
|
||||||
self.heatingCFM = heatingCFM
|
self.heatingCFM = heatingCFM
|
||||||
self.coolingCFM = coolingCFM
|
self.coolingCFM = coolingCFM
|
||||||
|
|||||||
@@ -64,9 +64,28 @@ extension Project {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public struct CompletedSteps: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
public let equipmentInfo: Bool
|
||||||
|
public let rooms: Bool
|
||||||
|
public let equivalentLength: Bool
|
||||||
|
public let frictionRate: Bool
|
||||||
|
|
||||||
|
public init(
|
||||||
|
equipmentInfo: Bool,
|
||||||
|
rooms: Bool,
|
||||||
|
equivalentLength: Bool,
|
||||||
|
frictionRate: Bool
|
||||||
|
) {
|
||||||
|
self.equipmentInfo = equipmentInfo
|
||||||
|
self.rooms = rooms
|
||||||
|
self.equivalentLength = equivalentLength
|
||||||
|
self.frictionRate = frictionRate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct Update: Codable, Equatable, Sendable {
|
public struct Update: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
public let id: Project.ID
|
|
||||||
public let name: String?
|
public let name: String?
|
||||||
public let streetAddress: String?
|
public let streetAddress: String?
|
||||||
public let city: String?
|
public let city: String?
|
||||||
@@ -75,7 +94,6 @@ extension Project {
|
|||||||
public let sensibleHeatRatio: Double?
|
public let sensibleHeatRatio: Double?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: Project.ID,
|
|
||||||
name: String? = nil,
|
name: String? = nil,
|
||||||
streetAddress: String? = nil,
|
streetAddress: String? = nil,
|
||||||
city: String? = nil,
|
city: String? = nil,
|
||||||
@@ -83,7 +101,6 @@ extension Project {
|
|||||||
zipCode: String? = nil,
|
zipCode: String? = nil,
|
||||||
sensibleHeatRatio: Double? = nil
|
sensibleHeatRatio: Double? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
|
||||||
self.name = name
|
self.name = name
|
||||||
self.streetAddress = streetAddress
|
self.streetAddress = streetAddress
|
||||||
self.city = city
|
self.city = city
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
|
|||||||
public let coolingTotal: Double
|
public let coolingTotal: Double
|
||||||
public let coolingSensible: Double?
|
public let coolingSensible: Double?
|
||||||
public let registerCount: Int
|
public let registerCount: Int
|
||||||
|
public let rectangularSizes: [DuctSizing.RectangularDuct]?
|
||||||
public let createdAt: Date
|
public let createdAt: Date
|
||||||
public let updatedAt: Date
|
public let updatedAt: Date
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
|
|||||||
coolingTotal: Double,
|
coolingTotal: Double,
|
||||||
coolingSensible: Double? = nil,
|
coolingSensible: Double? = nil,
|
||||||
registerCount: Int = 1,
|
registerCount: Int = 1,
|
||||||
|
rectangularSizes: [DuctSizing.RectangularDuct]? = nil,
|
||||||
createdAt: Date,
|
createdAt: Date,
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
) {
|
) {
|
||||||
@@ -30,6 +32,7 @@ public struct Room: Codable, Equatable, Identifiable, Sendable {
|
|||||||
self.coolingTotal = coolingTotal
|
self.coolingTotal = coolingTotal
|
||||||
self.coolingSensible = coolingSensible
|
self.coolingSensible = coolingSensible
|
||||||
self.registerCount = registerCount
|
self.registerCount = registerCount
|
||||||
|
self.rectangularSizes = rectangularSizes
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
}
|
}
|
||||||
@@ -63,27 +66,55 @@ extension Room {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public struct Update: Codable, Equatable, Sendable {
|
public struct Update: Codable, Equatable, Sendable {
|
||||||
public let id: Room.ID
|
|
||||||
public let name: String?
|
public let name: String?
|
||||||
public let heatingLoad: Double?
|
public let heatingLoad: Double?
|
||||||
public let coolingTotal: Double?
|
public let coolingTotal: Double?
|
||||||
public let coolingSensible: Double?
|
public let coolingSensible: Double?
|
||||||
public let registerCount: Int?
|
public let registerCount: Int?
|
||||||
|
public let rectangularSizes: [DuctSizing.RectangularDuct]?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: Room.ID,
|
|
||||||
name: String? = nil,
|
name: String? = nil,
|
||||||
heatingLoad: Double? = nil,
|
heatingLoad: Double? = nil,
|
||||||
coolingTotal: Double? = nil,
|
coolingTotal: Double? = nil,
|
||||||
coolingSensible: Double? = nil,
|
coolingSensible: Double? = nil,
|
||||||
registerCount: Int? = nil
|
registerCount: Int? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
|
||||||
self.name = name
|
self.name = name
|
||||||
self.heatingLoad = heatingLoad
|
self.heatingLoad = heatingLoad
|
||||||
self.coolingTotal = coolingTotal
|
self.coolingTotal = coolingTotal
|
||||||
self.coolingSensible = coolingSensible
|
self.coolingSensible = coolingSensible
|
||||||
self.registerCount = registerCount
|
self.registerCount = registerCount
|
||||||
|
self.rectangularSizes = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(
|
||||||
|
rectangularSizes: [DuctSizing.RectangularDuct]
|
||||||
|
) {
|
||||||
|
self.name = nil
|
||||||
|
self.heatingLoad = nil
|
||||||
|
self.coolingTotal = nil
|
||||||
|
self.coolingSensible = nil
|
||||||
|
self.registerCount = nil
|
||||||
|
self.rectangularSizes = rectangularSizes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Array where Element == Room {
|
||||||
|
|
||||||
|
public var totalHeatingLoad: Double {
|
||||||
|
reduce(into: 0) { $0 += $1.heatingLoad }
|
||||||
|
}
|
||||||
|
|
||||||
|
public var totalCoolingLoad: Double {
|
||||||
|
reduce(into: 0) { $0 += $1.coolingTotal }
|
||||||
|
}
|
||||||
|
|
||||||
|
public func totalCoolingSensible(shr: Double) -> Double {
|
||||||
|
reduce(into: 0) {
|
||||||
|
let sensible = $1.coolingSensible ?? ($1.coolingTotal * shr)
|
||||||
|
$0 += sensible
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ extension SiteRoute.Api {
|
|||||||
public enum ProjectRoute: Sendable, Equatable {
|
public enum ProjectRoute: Sendable, Equatable {
|
||||||
case create(Project.Create)
|
case create(Project.Create)
|
||||||
case delete(id: Project.ID)
|
case delete(id: Project.ID)
|
||||||
|
case detail(id: Project.ID, route: DetailRoute)
|
||||||
case get(id: Project.ID)
|
case get(id: Project.ID)
|
||||||
case index
|
case index
|
||||||
|
|
||||||
@@ -74,6 +75,31 @@ extension SiteRoute.Api {
|
|||||||
Path { rootPath }
|
Path { rootPath }
|
||||||
Method.get
|
Method.get
|
||||||
}
|
}
|
||||||
|
Route(.case(Self.detail(id:route:))) {
|
||||||
|
Path {
|
||||||
|
rootPath
|
||||||
|
Project.ID.parser()
|
||||||
|
}
|
||||||
|
DetailRoute.router
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SiteRoute.Api.ProjectRoute {
|
||||||
|
public enum DetailRoute: Equatable, Sendable {
|
||||||
|
case completedSteps
|
||||||
|
|
||||||
|
static let rootPath = "details"
|
||||||
|
|
||||||
|
static let router = OneOf {
|
||||||
|
Route(.case(Self.completedSteps)) {
|
||||||
|
Path {
|
||||||
|
rootPath
|
||||||
|
"completed"
|
||||||
|
}
|
||||||
|
Method.get
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,9 +11,15 @@ extension SiteRoute {
|
|||||||
case login(LoginRoute)
|
case login(LoginRoute)
|
||||||
case signup(SignupRoute)
|
case signup(SignupRoute)
|
||||||
case project(ProjectRoute)
|
case project(ProjectRoute)
|
||||||
case effectiveLength(EffectiveLengthRoute)
|
case user(UserRoute)
|
||||||
|
//FIX: Remove.
|
||||||
|
case test
|
||||||
|
|
||||||
public static let router = OneOf {
|
public static let router = OneOf {
|
||||||
|
Route(.case(Self.test)) {
|
||||||
|
Path { "test" }
|
||||||
|
Method.get
|
||||||
|
}
|
||||||
Route(.case(Self.login)) {
|
Route(.case(Self.login)) {
|
||||||
SiteRoute.View.LoginRoute.router
|
SiteRoute.View.LoginRoute.router
|
||||||
}
|
}
|
||||||
@@ -23,8 +29,8 @@ extension SiteRoute {
|
|||||||
Route(.case(Self.project)) {
|
Route(.case(Self.project)) {
|
||||||
SiteRoute.View.ProjectRoute.router
|
SiteRoute.View.ProjectRoute.router
|
||||||
}
|
}
|
||||||
Route(.case(Self.effectiveLength)) {
|
Route(.case(Self.user)) {
|
||||||
SiteRoute.View.EffectiveLengthRoute.router
|
SiteRoute.View.UserRoute.router
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -35,10 +41,9 @@ extension SiteRoute.View {
|
|||||||
case create(Project.Create)
|
case create(Project.Create)
|
||||||
case delete(id: Project.ID)
|
case delete(id: Project.ID)
|
||||||
case detail(Project.ID, DetailRoute)
|
case detail(Project.ID, DetailRoute)
|
||||||
case form(id: Project.ID? = nil, dismiss: Bool = false)
|
|
||||||
case index
|
case index
|
||||||
case page(PageRequest)
|
case page(PageRequest)
|
||||||
case update(Project.Update)
|
case update(Project.ID, Project.Update)
|
||||||
|
|
||||||
public static func page(page: Int, per limit: Int) -> Self {
|
public static func page(page: Int, per limit: Int) -> Self {
|
||||||
.page(.init(page: page, per: limit))
|
.page(.init(page: page, per: limit))
|
||||||
@@ -80,19 +85,6 @@ extension SiteRoute.View {
|
|||||||
}
|
}
|
||||||
DetailRoute.router
|
DetailRoute.router
|
||||||
}
|
}
|
||||||
Route(.case(Self.form)) {
|
|
||||||
Path {
|
|
||||||
rootPath
|
|
||||||
"create"
|
|
||||||
}
|
|
||||||
Method.get
|
|
||||||
Query {
|
|
||||||
Optionally {
|
|
||||||
Field("id", default: nil) { Project.ID.parser() }
|
|
||||||
}
|
|
||||||
Field("dismiss", default: false) { Bool.parser() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Route(.case(Self.index)) {
|
Route(.case(Self.index)) {
|
||||||
Path { rootPath }
|
Path { rootPath }
|
||||||
Method.get
|
Method.get
|
||||||
@@ -110,11 +102,13 @@ extension SiteRoute.View {
|
|||||||
.map(.memberwise(PageRequest.init))
|
.map(.memberwise(PageRequest.init))
|
||||||
}
|
}
|
||||||
Route(.case(Self.update)) {
|
Route(.case(Self.update)) {
|
||||||
Path { rootPath }
|
Path {
|
||||||
|
rootPath
|
||||||
|
Project.ID.parser()
|
||||||
|
}
|
||||||
Method.patch
|
Method.patch
|
||||||
Body {
|
Body {
|
||||||
FormData {
|
FormData {
|
||||||
Field("id") { Project.ID.parser() }
|
|
||||||
Optionally {
|
Optionally {
|
||||||
Field("name", .string)
|
Field("name", .string)
|
||||||
}
|
}
|
||||||
@@ -146,23 +140,30 @@ extension SiteRoute.View {
|
|||||||
extension SiteRoute.View.ProjectRoute {
|
extension SiteRoute.View.ProjectRoute {
|
||||||
|
|
||||||
public enum DetailRoute: Equatable, Sendable {
|
public enum DetailRoute: Equatable, Sendable {
|
||||||
case index(tab: Tab = .default)
|
case index
|
||||||
|
case componentLoss(ComponentLossRoute)
|
||||||
|
case ductSizing(DuctSizingRoute)
|
||||||
case equipment(EquipmentInfoRoute)
|
case equipment(EquipmentInfoRoute)
|
||||||
|
case equivalentLength(EquivalentLengthRoute)
|
||||||
case frictionRate(FrictionRateRoute)
|
case frictionRate(FrictionRateRoute)
|
||||||
case rooms(RoomRoute)
|
case rooms(RoomRoute)
|
||||||
|
|
||||||
static let router = OneOf {
|
static let router = OneOf {
|
||||||
Route(.case(Self.index)) {
|
Route(.case(Self.index)) {
|
||||||
Method.get
|
Method.get
|
||||||
Query {
|
}
|
||||||
Field("tab", default: Tab.default) {
|
Route(.case(Self.componentLoss)) {
|
||||||
Tab.parser()
|
ComponentLossRoute.router
|
||||||
}
|
}
|
||||||
}
|
Route(.case(Self.ductSizing)) {
|
||||||
|
DuctSizingRoute.router
|
||||||
}
|
}
|
||||||
Route(.case(Self.equipment)) {
|
Route(.case(Self.equipment)) {
|
||||||
EquipmentInfoRoute.router
|
EquipmentInfoRoute.router
|
||||||
}
|
}
|
||||||
|
Route(.case(Self.equivalentLength)) {
|
||||||
|
EquivalentLengthRoute.router
|
||||||
|
}
|
||||||
Route(.case(Self.frictionRate)) {
|
Route(.case(Self.frictionRate)) {
|
||||||
FrictionRateRoute.router
|
FrictionRateRoute.router
|
||||||
}
|
}
|
||||||
@@ -173,21 +174,19 @@ extension SiteRoute.View.ProjectRoute {
|
|||||||
|
|
||||||
public enum Tab: String, CaseIterable, Equatable, Sendable {
|
public enum Tab: String, CaseIterable, Equatable, Sendable {
|
||||||
case project
|
case project
|
||||||
|
case equipment
|
||||||
case rooms
|
case rooms
|
||||||
case effectiveLength
|
case equivalentLength
|
||||||
case frictionRate
|
case frictionRate
|
||||||
case ductSizing
|
case ductSizing
|
||||||
|
|
||||||
public static var `default`: Self { .rooms }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum RoomRoute: Equatable, Sendable {
|
public enum RoomRoute: Equatable, Sendable {
|
||||||
case delete(id: Room.ID)
|
case delete(id: Room.ID)
|
||||||
case form(id: Room.ID? = nil, dismiss: Bool = false)
|
|
||||||
case index
|
case index
|
||||||
case submit(Room.Create)
|
case submit(Room.Create)
|
||||||
case update(Room.Update)
|
case update(Room.ID, Room.Update)
|
||||||
case updateSensibleHeatRatio(SHRUpdate)
|
case updateSensibleHeatRatio(SHRUpdate)
|
||||||
|
|
||||||
static let rootPath = "rooms"
|
static let rootPath = "rooms"
|
||||||
@@ -200,19 +199,6 @@ extension SiteRoute.View.ProjectRoute {
|
|||||||
}
|
}
|
||||||
Method.delete
|
Method.delete
|
||||||
}
|
}
|
||||||
Route(.case(Self.form)) {
|
|
||||||
Path {
|
|
||||||
rootPath
|
|
||||||
"create"
|
|
||||||
}
|
|
||||||
Method.get
|
|
||||||
Query {
|
|
||||||
Optionally {
|
|
||||||
Field("id", default: nil) { Room.ID.parser() }
|
|
||||||
}
|
|
||||||
Field("dismiss", default: false) { Bool.parser() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Route(.case(Self.index)) {
|
Route(.case(Self.index)) {
|
||||||
Path {
|
Path {
|
||||||
rootPath
|
rootPath
|
||||||
@@ -237,11 +223,13 @@ extension SiteRoute.View.ProjectRoute {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Route(.case(Self.update)) {
|
Route(.case(Self.update)) {
|
||||||
Path { rootPath }
|
Path {
|
||||||
|
rootPath
|
||||||
|
Room.ID.parser()
|
||||||
|
}
|
||||||
Method.patch
|
Method.patch
|
||||||
Body {
|
Body {
|
||||||
FormData {
|
FormData {
|
||||||
Field("id") { Room.ID.parser() }
|
|
||||||
Optionally {
|
Optionally {
|
||||||
Field("name", .string)
|
Field("name", .string)
|
||||||
}
|
}
|
||||||
@@ -285,10 +273,61 @@ extension SiteRoute.View.ProjectRoute {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum ComponentLossRoute: Equatable, Sendable {
|
||||||
|
case index
|
||||||
|
case delete(ComponentPressureLoss.ID)
|
||||||
|
case submit(ComponentPressureLoss.Create)
|
||||||
|
case update(ComponentPressureLoss.ID, ComponentPressureLoss.Update)
|
||||||
|
|
||||||
|
static let rootPath = "component-loss"
|
||||||
|
|
||||||
|
static let router = OneOf {
|
||||||
|
Route(.case(Self.index)) {
|
||||||
|
Path { rootPath }
|
||||||
|
Method.get
|
||||||
|
}
|
||||||
|
Route(.case(Self.delete)) {
|
||||||
|
Path {
|
||||||
|
rootPath
|
||||||
|
ComponentPressureLoss.ID.parser()
|
||||||
|
}
|
||||||
|
Method.delete
|
||||||
|
}
|
||||||
|
Route(.case(Self.submit)) {
|
||||||
|
Path { rootPath }
|
||||||
|
Method.post
|
||||||
|
Body {
|
||||||
|
FormData {
|
||||||
|
Field("projectID") { Project.ID.parser() }
|
||||||
|
Field("name", .string)
|
||||||
|
Field("value") { Double.parser() }
|
||||||
|
}
|
||||||
|
.map(.memberwise(ComponentPressureLoss.Create.init))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Route(.case(Self.update)) {
|
||||||
|
Path {
|
||||||
|
rootPath
|
||||||
|
ComponentPressureLoss.ID.parser()
|
||||||
|
}
|
||||||
|
Method.patch
|
||||||
|
Body {
|
||||||
|
FormData {
|
||||||
|
Optionally {
|
||||||
|
Field("name", .string)
|
||||||
|
}
|
||||||
|
Optionally {
|
||||||
|
Field("value") { Double.parser() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map(.memberwise(ComponentPressureLoss.Update.init))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public enum FrictionRateRoute: Equatable, Sendable {
|
public enum FrictionRateRoute: Equatable, Sendable {
|
||||||
case index
|
case index
|
||||||
// TODO: Remove form or move equipment / component losses routes here.
|
|
||||||
case form(FormType, dismiss: Bool = false)
|
|
||||||
|
|
||||||
static let rootPath = "friction-rate"
|
static let rootPath = "friction-rate"
|
||||||
|
|
||||||
@@ -297,30 +336,13 @@ extension SiteRoute.View.ProjectRoute {
|
|||||||
Path { rootPath }
|
Path { rootPath }
|
||||||
Method.get
|
Method.get
|
||||||
}
|
}
|
||||||
Route(.case(Self.form)) {
|
|
||||||
Path {
|
|
||||||
rootPath
|
|
||||||
"create"
|
|
||||||
}
|
|
||||||
Method.get
|
|
||||||
Query {
|
|
||||||
Field("type") { FormType.parser() }
|
|
||||||
Field("dismiss", default: false) { Bool.parser() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum FormType: String, CaseIterable, Codable, Equatable, Sendable {
|
|
||||||
case equipmentInfo
|
|
||||||
case componentPressureLoss
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum EquipmentInfoRoute: Equatable, Sendable {
|
public enum EquipmentInfoRoute: Equatable, Sendable {
|
||||||
case index
|
case index
|
||||||
case form(dismiss: Bool)
|
|
||||||
case submit(EquipmentInfo.Create)
|
case submit(EquipmentInfo.Create)
|
||||||
case update(EquipmentInfo.Update)
|
case update(EquipmentInfo.ID, EquipmentInfo.Update)
|
||||||
|
|
||||||
static let rootPath = "equipment"
|
static let rootPath = "equipment"
|
||||||
|
|
||||||
@@ -329,16 +351,6 @@ extension SiteRoute.View.ProjectRoute {
|
|||||||
Path { rootPath }
|
Path { rootPath }
|
||||||
Method.get
|
Method.get
|
||||||
}
|
}
|
||||||
Route(.case(Self.form)) {
|
|
||||||
Path {
|
|
||||||
rootPath
|
|
||||||
"create"
|
|
||||||
}
|
|
||||||
Method.get
|
|
||||||
Query {
|
|
||||||
Field("dismiss", default: true) { Bool.parser() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Route(.case(Self.submit)) {
|
Route(.case(Self.submit)) {
|
||||||
Path { rootPath }
|
Path { rootPath }
|
||||||
Method.post
|
Method.post
|
||||||
@@ -353,11 +365,13 @@ extension SiteRoute.View.ProjectRoute {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Route(.case(Self.update)) {
|
Route(.case(Self.update)) {
|
||||||
Path { rootPath }
|
Path {
|
||||||
|
rootPath
|
||||||
|
EquipmentInfo.ID.parser()
|
||||||
|
}
|
||||||
Method.patch
|
Method.patch
|
||||||
Body {
|
Body {
|
||||||
FormData {
|
FormData {
|
||||||
Field("id") { EquipmentInfo.ID.parser() }
|
|
||||||
Optionally {
|
Optionally {
|
||||||
Field("staticPressure", default: nil) { Double.parser() }
|
Field("staticPressure", default: nil) { Double.parser() }
|
||||||
}
|
}
|
||||||
@@ -373,31 +387,28 @@ extension SiteRoute.View.ProjectRoute {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
extension SiteRoute.View {
|
public enum EquivalentLengthRoute: Equatable, Sendable {
|
||||||
public enum EffectiveLengthRoute: Equatable, Sendable {
|
case delete(id: EffectiveLength.ID)
|
||||||
case field(FieldType, style: EffectiveLength.EffectiveLengthType? = nil)
|
case field(FieldType, style: EffectiveLength.EffectiveLengthType? = nil)
|
||||||
case form(dismiss: Bool = false)
|
|
||||||
case index
|
case index
|
||||||
|
case submit(FormStep)
|
||||||
|
case update(EffectiveLength.ID, StepThree)
|
||||||
|
|
||||||
static let rootPath = "effective-lengths"
|
static let rootPath = "effective-lengths"
|
||||||
|
|
||||||
public static let router = OneOf {
|
public static let router = OneOf {
|
||||||
|
Route(.case(Self.delete(id:))) {
|
||||||
|
Path {
|
||||||
|
rootPath
|
||||||
|
EffectiveLength.ID.parser()
|
||||||
|
}
|
||||||
|
Method.delete
|
||||||
|
}
|
||||||
Route(.case(Self.index)) {
|
Route(.case(Self.index)) {
|
||||||
Path { rootPath }
|
Path { rootPath }
|
||||||
Method.get
|
Method.get
|
||||||
}
|
}
|
||||||
Route(.case(Self.form(dismiss:))) {
|
|
||||||
Path {
|
|
||||||
rootPath
|
|
||||||
"create"
|
|
||||||
}
|
|
||||||
Method.get
|
|
||||||
Query {
|
|
||||||
Field("dismiss", default: false) { Bool.parser() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Route(.case(Self.field)) {
|
Route(.case(Self.field)) {
|
||||||
Path {
|
Path {
|
||||||
rootPath
|
rootPath
|
||||||
@@ -413,20 +424,326 @@ extension SiteRoute.View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Route(.case(Self.submit)) {
|
||||||
|
Path { rootPath }
|
||||||
|
Method.post
|
||||||
|
FormStep.router
|
||||||
|
}
|
||||||
|
Route(.case(Self.update)) {
|
||||||
|
Path {
|
||||||
|
rootPath
|
||||||
|
EffectiveLength.ID.parser()
|
||||||
|
}
|
||||||
|
Method.patch
|
||||||
|
Body {
|
||||||
|
FormData {
|
||||||
|
Optionally {
|
||||||
|
Field("id", default: nil) { EffectiveLength.ID.parser() }
|
||||||
|
}
|
||||||
|
Field("name", .string)
|
||||||
|
Field("type") { EffectiveLength.EffectiveLengthType.parser() }
|
||||||
|
Many {
|
||||||
|
Field("straightLengths") {
|
||||||
|
Int.parser()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Many {
|
||||||
|
Field("group[group]") {
|
||||||
|
Int.parser()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Many {
|
||||||
|
Field("group[letter]", .string)
|
||||||
|
}
|
||||||
|
Many {
|
||||||
|
Field("group[length]") {
|
||||||
|
Int.parser()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
Many {
|
||||||
|
Field("group[quantity]") {
|
||||||
|
Int.parser()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map(.memberwise(StepThree.init))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum FormStep: Equatable, Sendable {
|
||||||
|
case one(StepOne)
|
||||||
|
case two(StepTwo)
|
||||||
|
case three(StepThree)
|
||||||
|
|
||||||
|
static let router = OneOf {
|
||||||
|
Route(.case(Self.one)) {
|
||||||
|
Path {
|
||||||
|
Key.stepOne.rawValue
|
||||||
|
}
|
||||||
|
Body {
|
||||||
|
FormData {
|
||||||
|
Optionally {
|
||||||
|
Field("id", default: nil) { EffectiveLength.ID.parser() }
|
||||||
|
}
|
||||||
|
Field("name", .string)
|
||||||
|
Field("type") { EffectiveLength.EffectiveLengthType.parser() }
|
||||||
|
}
|
||||||
|
.map(.memberwise(StepOne.init))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Route(.case(Self.two)) {
|
||||||
|
Path {
|
||||||
|
Key.stepTwo.rawValue
|
||||||
|
}
|
||||||
|
Body {
|
||||||
|
FormData {
|
||||||
|
Optionally {
|
||||||
|
Field("id", default: nil) { EffectiveLength.ID.parser() }
|
||||||
|
}
|
||||||
|
Field("name", .string)
|
||||||
|
Field("type") { EffectiveLength.EffectiveLengthType.parser() }
|
||||||
|
Many {
|
||||||
|
Field("straightLengths") {
|
||||||
|
Int.parser()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map(.memberwise(StepTwo.init))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Route(.case(Self.three)) {
|
||||||
|
Path {
|
||||||
|
Key.stepThree.rawValue
|
||||||
|
}
|
||||||
|
Body {
|
||||||
|
FormData {
|
||||||
|
Optionally {
|
||||||
|
Field("id", default: nil) { EffectiveLength.ID.parser() }
|
||||||
|
}
|
||||||
|
Field("name", .string)
|
||||||
|
Field("type") { EffectiveLength.EffectiveLengthType.parser() }
|
||||||
|
Many {
|
||||||
|
Field("straightLengths") {
|
||||||
|
Int.parser()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Many {
|
||||||
|
Field("group[group]") {
|
||||||
|
Int.parser()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Many {
|
||||||
|
Field("group[letter]", .string)
|
||||||
|
}
|
||||||
|
Many {
|
||||||
|
Field("group[length]") {
|
||||||
|
Int.parser()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
Many {
|
||||||
|
Field("group[quantity]") {
|
||||||
|
Int.parser()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map(.memberwise(StepThree.init))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum Key: String, CaseIterable, Codable, Equatable, Sendable {
|
||||||
|
case stepOne
|
||||||
|
case stepTwo
|
||||||
|
case stepThree
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct StepOne: Codable, Equatable, Sendable {
|
||||||
|
public let id: EffectiveLength.ID?
|
||||||
|
public let name: String
|
||||||
|
public let type: EffectiveLength.EffectiveLengthType
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct StepTwo: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
|
public let id: EffectiveLength.ID?
|
||||||
|
public let name: String
|
||||||
|
public let type: EffectiveLength.EffectiveLengthType
|
||||||
|
public let straightLengths: [Int]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: EffectiveLength.ID? = nil,
|
||||||
|
name: String,
|
||||||
|
type: EffectiveLength.EffectiveLengthType,
|
||||||
|
straightLengths: [Int]
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.name = name
|
||||||
|
self.type = type
|
||||||
|
self.straightLengths = straightLengths
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct StepThree: Codable, Equatable, Sendable {
|
||||||
|
public let id: EffectiveLength.ID?
|
||||||
|
public let name: String
|
||||||
|
public let type: EffectiveLength.EffectiveLengthType
|
||||||
|
public let straightLengths: [Int]
|
||||||
|
public let groupGroups: [Int]
|
||||||
|
public let groupLetters: [String]
|
||||||
|
public let groupLengths: [Int]
|
||||||
|
public let groupQuantities: [Int]
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum FieldType: String, CaseIterable, Equatable, Sendable {
|
||||||
|
case straightLength
|
||||||
|
case group
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension SiteRoute.View.EffectiveLengthRoute {
|
|
||||||
public enum FieldType: String, CaseIterable, Equatable, Sendable {
|
|
||||||
case straightLength
|
|
||||||
case group
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum FormStep: String, CaseIterable, Equatable, Sendable {
|
public enum DuctSizingRoute: Equatable, Sendable {
|
||||||
case nameAndType
|
case index
|
||||||
case straightLengths
|
case deleteRectangularSize(Room.ID, DeleteRectangularDuct)
|
||||||
case groups
|
case roomRectangularForm(Room.ID, RoomRectangularForm)
|
||||||
|
case trunk(TrunkRoute)
|
||||||
|
|
||||||
|
public static let roomPath = "room"
|
||||||
|
static let rootPath = "duct-sizing"
|
||||||
|
|
||||||
|
static let router = OneOf {
|
||||||
|
Route(.case(Self.index)) {
|
||||||
|
Path { rootPath }
|
||||||
|
Method.get
|
||||||
|
}
|
||||||
|
Route(.case(Self.deleteRectangularSize)) {
|
||||||
|
Path {
|
||||||
|
rootPath
|
||||||
|
roomPath
|
||||||
|
Room.ID.parser()
|
||||||
|
}
|
||||||
|
Method.delete
|
||||||
|
Query {
|
||||||
|
Field("rectangularSize") { DuctSizing.RectangularDuct.ID.parser() }
|
||||||
|
Field("register") { Int.parser() }
|
||||||
|
}
|
||||||
|
.map(.memberwise(DeleteRectangularDuct.init))
|
||||||
|
}
|
||||||
|
Route(.case(Self.roomRectangularForm)) {
|
||||||
|
Path {
|
||||||
|
rootPath
|
||||||
|
roomPath
|
||||||
|
Room.ID.parser()
|
||||||
|
}
|
||||||
|
Method.post
|
||||||
|
Body {
|
||||||
|
FormData {
|
||||||
|
Optionally {
|
||||||
|
Field("id") { DuctSizing.RectangularDuct.ID.parser() }
|
||||||
|
}
|
||||||
|
Field("register") { Int.parser() }
|
||||||
|
Field("height") { Int.parser() }
|
||||||
|
}
|
||||||
|
.map(.memberwise(RoomRectangularForm.init))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Route(.case(Self.trunk)) {
|
||||||
|
Path { rootPath }
|
||||||
|
TrunkRoute.router
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct DeleteRectangularDuct: Equatable, Sendable {
|
||||||
|
|
||||||
|
public let rectangularSizeID: DuctSizing.RectangularDuct.ID
|
||||||
|
public let register: Int
|
||||||
|
|
||||||
|
public init(rectangularSizeID: DuctSizing.RectangularDuct.ID, register: Int) {
|
||||||
|
self.rectangularSizeID = rectangularSizeID
|
||||||
|
self.register = register
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum TrunkRoute: Equatable, Sendable {
|
||||||
|
case delete(DuctSizing.TrunkSize.ID)
|
||||||
|
case submit(TrunkSizeForm)
|
||||||
|
case update(DuctSizing.TrunkSize.ID, TrunkSizeForm)
|
||||||
|
|
||||||
|
public static let rootPath = "trunk"
|
||||||
|
|
||||||
|
static let router = OneOf {
|
||||||
|
Route(.case(Self.delete)) {
|
||||||
|
Path {
|
||||||
|
rootPath
|
||||||
|
DuctSizing.TrunkSize.ID.parser()
|
||||||
|
}
|
||||||
|
Method.delete
|
||||||
|
}
|
||||||
|
Route(.case(Self.submit)) {
|
||||||
|
Path {
|
||||||
|
rootPath
|
||||||
|
}
|
||||||
|
Method.post
|
||||||
|
Body {
|
||||||
|
FormData {
|
||||||
|
Field("projectID") { Project.ID.parser() }
|
||||||
|
Field("type") { DuctSizing.TrunkSize.TrunkType.parser() }
|
||||||
|
Optionally {
|
||||||
|
Field("height") { Int.parser() }
|
||||||
|
|
||||||
|
}
|
||||||
|
Optionally {
|
||||||
|
Field("name", .string)
|
||||||
|
}
|
||||||
|
Many {
|
||||||
|
Field("rooms", .string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map(.memberwise(TrunkSizeForm.init))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Route(.case(Self.update)) {
|
||||||
|
Path {
|
||||||
|
rootPath
|
||||||
|
DuctSizing.TrunkSize.ID.parser()
|
||||||
|
}
|
||||||
|
Method.patch
|
||||||
|
Body {
|
||||||
|
FormData {
|
||||||
|
Field("projectID") { Project.ID.parser() }
|
||||||
|
Field("type") { DuctSizing.TrunkSize.TrunkType.parser() }
|
||||||
|
Optionally {
|
||||||
|
Field("height") { Int.parser() }
|
||||||
|
}
|
||||||
|
Optionally {
|
||||||
|
Field("name", .string)
|
||||||
|
}
|
||||||
|
Many {
|
||||||
|
Field("rooms", .string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map(.memberwise(TrunkSizeForm.init))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct RoomRectangularForm: Equatable, Sendable {
|
||||||
|
public let id: DuctSizing.RectangularDuct.ID?
|
||||||
|
public let register: Int
|
||||||
|
public let height: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct TrunkSizeForm: Equatable, Sendable {
|
||||||
|
public let projectID: Project.ID
|
||||||
|
public let type: DuctSizing.TrunkSize.TrunkType
|
||||||
|
public let height: Int?
|
||||||
|
public let name: String?
|
||||||
|
public let rooms: [String]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,6 +791,7 @@ extension SiteRoute.View {
|
|||||||
public enum SignupRoute: Equatable, Sendable {
|
public enum SignupRoute: Equatable, Sendable {
|
||||||
case index
|
case index
|
||||||
case submit(User.Create)
|
case submit(User.Create)
|
||||||
|
case submitProfile(User.Profile.Create)
|
||||||
|
|
||||||
static let rootPath = "signup"
|
static let rootPath = "signup"
|
||||||
|
|
||||||
@@ -487,7 +805,6 @@ extension SiteRoute.View {
|
|||||||
Method.post
|
Method.post
|
||||||
Body {
|
Body {
|
||||||
FormData {
|
FormData {
|
||||||
Field("username", .string)
|
|
||||||
Field("email", .string)
|
Field("email", .string)
|
||||||
Field("password", .string)
|
Field("password", .string)
|
||||||
Field("confirmPassword", .string)
|
Field("confirmPassword", .string)
|
||||||
@@ -495,6 +812,114 @@ extension SiteRoute.View {
|
|||||||
.map(.memberwise(User.Create.init))
|
.map(.memberwise(User.Create.init))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Route(.case(Self.submitProfile)) {
|
||||||
|
Path {
|
||||||
|
rootPath
|
||||||
|
"profile"
|
||||||
|
}
|
||||||
|
Method.post
|
||||||
|
Body {
|
||||||
|
FormData {
|
||||||
|
Field("userID") { User.ID.parser() }
|
||||||
|
Field("firstName", .string)
|
||||||
|
Field("lastName", .string)
|
||||||
|
Field("companyName", .string)
|
||||||
|
Field("streetAddress", .string)
|
||||||
|
Field("city", .string)
|
||||||
|
Field("state", .string)
|
||||||
|
Field("zipCode", .string)
|
||||||
|
Optionally {
|
||||||
|
Field("theme") { Theme.parser() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map(.memberwise(User.Profile.Create.init))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SiteRoute.View {
|
||||||
|
public enum UserRoute: Equatable, Sendable {
|
||||||
|
case profile(Profile)
|
||||||
|
|
||||||
|
static let router = OneOf {
|
||||||
|
Route(.case(Self.profile)) {
|
||||||
|
Profile.router
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SiteRoute.View.UserRoute {
|
||||||
|
public enum Profile: Equatable, Sendable {
|
||||||
|
case index
|
||||||
|
case submit(User.Profile.Create)
|
||||||
|
case update(User.Profile.ID, User.Profile.Update)
|
||||||
|
|
||||||
|
static let rootPath = "profile"
|
||||||
|
|
||||||
|
static let router = OneOf {
|
||||||
|
Route(.case(Self.index)) {
|
||||||
|
Path { rootPath }
|
||||||
|
Method.get
|
||||||
|
}
|
||||||
|
Route(.case(Self.submit)) {
|
||||||
|
Path { rootPath }
|
||||||
|
Method.post
|
||||||
|
Body {
|
||||||
|
FormData {
|
||||||
|
Field("userID") { User.ID.parser() }
|
||||||
|
Field("firstName", .string)
|
||||||
|
Field("lastName", .string)
|
||||||
|
Field("companyName", .string)
|
||||||
|
Field("streetAddress", .string)
|
||||||
|
Field("city", .string)
|
||||||
|
Field("state", .string)
|
||||||
|
Field("zipCode", .string)
|
||||||
|
Optionally {
|
||||||
|
Field("theme") { Theme.parser() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map(.memberwise(User.Profile.Create.init))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Route(.case(Self.update)) {
|
||||||
|
Path {
|
||||||
|
rootPath
|
||||||
|
User.Profile.ID.parser()
|
||||||
|
}
|
||||||
|
Method.patch
|
||||||
|
Body {
|
||||||
|
FormData {
|
||||||
|
Optionally {
|
||||||
|
Field("firstName", .string)
|
||||||
|
}
|
||||||
|
Optionally {
|
||||||
|
Field("lastName", .string)
|
||||||
|
}
|
||||||
|
Optionally {
|
||||||
|
Field("companyName", .string)
|
||||||
|
}
|
||||||
|
Optionally {
|
||||||
|
Field("streetAddress", .string)
|
||||||
|
}
|
||||||
|
Optionally {
|
||||||
|
Field("city", .string)
|
||||||
|
}
|
||||||
|
Optionally {
|
||||||
|
Field("state", .string)
|
||||||
|
}
|
||||||
|
Optionally {
|
||||||
|
Field("zipCode", .string)
|
||||||
|
}
|
||||||
|
Optionally {
|
||||||
|
Field("theme") { Theme.parser() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map(.memberwise(User.Profile.Update.init))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
Sources/ManualDCore/Theme.swift
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum Theme: String, CaseIterable, Codable, Equatable, Sendable {
|
||||||
|
case aqua
|
||||||
|
case cupcake
|
||||||
|
case cyberpunk
|
||||||
|
case dark
|
||||||
|
case `default`
|
||||||
|
case dracula
|
||||||
|
case light
|
||||||
|
case night
|
||||||
|
case nord
|
||||||
|
case retro
|
||||||
|
case synthwave
|
||||||
|
|
||||||
|
public static let darkThemes = [
|
||||||
|
Self.aqua,
|
||||||
|
Self.cyberpunk,
|
||||||
|
Self.dark,
|
||||||
|
Self.dracula,
|
||||||
|
Self.night,
|
||||||
|
Self.synthwave,
|
||||||
|
]
|
||||||
|
|
||||||
|
public static let lightThemes = [
|
||||||
|
Self.cupcake,
|
||||||
|
Self.light,
|
||||||
|
Self.nord,
|
||||||
|
Self.retro,
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,23 +1,21 @@
|
|||||||
import Dependencies
|
import Dependencies
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
// FIX: Remove username.
|
||||||
public struct User: Codable, Equatable, Identifiable, Sendable {
|
public struct User: Codable, Equatable, Identifiable, Sendable {
|
||||||
|
|
||||||
public let id: UUID
|
public let id: UUID
|
||||||
public let email: String
|
public let email: String
|
||||||
public let username: String
|
|
||||||
public let createdAt: Date
|
public let createdAt: Date
|
||||||
public let updatedAt: Date
|
public let updatedAt: Date
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: UUID,
|
id: UUID,
|
||||||
email: String,
|
email: String,
|
||||||
username: String,
|
|
||||||
createdAt: Date,
|
createdAt: Date,
|
||||||
updatedAt: Date
|
updatedAt: Date
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.username = username
|
|
||||||
self.email = email
|
self.email = email
|
||||||
self.createdAt = createdAt
|
self.createdAt = createdAt
|
||||||
self.updatedAt = updatedAt
|
self.updatedAt = updatedAt
|
||||||
@@ -27,18 +25,15 @@ public struct User: Codable, Equatable, Identifiable, Sendable {
|
|||||||
extension User {
|
extension User {
|
||||||
public struct Create: Codable, Equatable, Sendable {
|
public struct Create: Codable, Equatable, Sendable {
|
||||||
|
|
||||||
public let username: String
|
|
||||||
public let email: String
|
public let email: String
|
||||||
public let password: String
|
public let password: String
|
||||||
public let confirmPassword: String
|
public let confirmPassword: String
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
username: String,
|
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
password: String,
|
||||||
confirmPassword: String
|
confirmPassword: String
|
||||||
) {
|
) {
|
||||||
self.username = username
|
|
||||||
self.email = email
|
self.email = email
|
||||||
self.password = password
|
self.password = password
|
||||||
self.confirmPassword = confirmPassword
|
self.confirmPassword = confirmPassword
|
||||||
|
|||||||
115
Sources/ManualDCore/UserProfile.swift
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension User {
|
||||||
|
public struct Profile: Codable, Equatable, Identifiable, Sendable {
|
||||||
|
|
||||||
|
public let id: UUID
|
||||||
|
public let userID: User.ID
|
||||||
|
public let firstName: String
|
||||||
|
public let lastName: String
|
||||||
|
public let companyName: String
|
||||||
|
public let streetAddress: String
|
||||||
|
public let city: String
|
||||||
|
public let state: String
|
||||||
|
public let zipCode: String
|
||||||
|
public let theme: Theme?
|
||||||
|
public let createdAt: Date
|
||||||
|
public let updatedAt: Date
|
||||||
|
|
||||||
|
public init(
|
||||||
|
id: UUID,
|
||||||
|
userID: User.ID,
|
||||||
|
firstName: String,
|
||||||
|
lastName: String,
|
||||||
|
companyName: String,
|
||||||
|
streetAddress: String,
|
||||||
|
city: String,
|
||||||
|
state: String,
|
||||||
|
zipCode: String,
|
||||||
|
theme: Theme? = nil,
|
||||||
|
createdAt: Date,
|
||||||
|
updatedAt: Date
|
||||||
|
) {
|
||||||
|
self.id = id
|
||||||
|
self.userID = userID
|
||||||
|
self.firstName = firstName
|
||||||
|
self.lastName = lastName
|
||||||
|
self.companyName = companyName
|
||||||
|
self.streetAddress = streetAddress
|
||||||
|
self.city = city
|
||||||
|
self.state = state
|
||||||
|
self.zipCode = zipCode
|
||||||
|
self.theme = theme
|
||||||
|
self.createdAt = createdAt
|
||||||
|
self.updatedAt = updatedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension User.Profile {
|
||||||
|
|
||||||
|
public struct Create: Codable, Equatable, Sendable {
|
||||||
|
public let userID: User.ID
|
||||||
|
public let firstName: String
|
||||||
|
public let lastName: String
|
||||||
|
public let companyName: String
|
||||||
|
public let streetAddress: String
|
||||||
|
public let city: String
|
||||||
|
public let state: String
|
||||||
|
public let zipCode: String
|
||||||
|
public let theme: Theme?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
userID: User.ID,
|
||||||
|
firstName: String,
|
||||||
|
lastName: String,
|
||||||
|
companyName: String,
|
||||||
|
streetAddress: String,
|
||||||
|
city: String,
|
||||||
|
state: String,
|
||||||
|
zipCode: String,
|
||||||
|
theme: Theme? = nil
|
||||||
|
) {
|
||||||
|
self.userID = userID
|
||||||
|
self.firstName = firstName
|
||||||
|
self.lastName = lastName
|
||||||
|
self.companyName = companyName
|
||||||
|
self.streetAddress = streetAddress
|
||||||
|
self.city = city
|
||||||
|
self.state = state
|
||||||
|
self.zipCode = zipCode
|
||||||
|
self.theme = theme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Update: Codable, Equatable, Sendable {
|
||||||
|
public let firstName: String?
|
||||||
|
public let lastName: String?
|
||||||
|
public let companyName: String?
|
||||||
|
public let streetAddress: String?
|
||||||
|
public let city: String?
|
||||||
|
public let state: String?
|
||||||
|
public let zipCode: String?
|
||||||
|
public let theme: Theme?
|
||||||
|
|
||||||
|
public init(
|
||||||
|
firstName: String? = nil,
|
||||||
|
lastName: String? = nil,
|
||||||
|
companyName: String? = nil,
|
||||||
|
streetAddress: String? = nil,
|
||||||
|
city: String? = nil,
|
||||||
|
state: String? = nil,
|
||||||
|
zipCode: String? = nil,
|
||||||
|
theme: Theme? = nil
|
||||||
|
) {
|
||||||
|
self.firstName = firstName
|
||||||
|
self.lastName = lastName
|
||||||
|
self.companyName = companyName
|
||||||
|
self.streetAddress = streetAddress
|
||||||
|
self.city = city
|
||||||
|
self.state = state
|
||||||
|
self.zipCode = zipCode
|
||||||
|
self.theme = theme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
Sources/Styleguide/Alert.swift
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import Elementary
|
||||||
|
|
||||||
|
public struct Alert<Content: HTML>: HTML {
|
||||||
|
|
||||||
|
let inner: Content
|
||||||
|
|
||||||
|
public init(@HTMLBuilder content: () -> Content) {
|
||||||
|
self.inner = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some HTML<HTMLTag.div> {
|
||||||
|
div(.class("flex space-x-2")) {
|
||||||
|
SVG(.triangleAlert)
|
||||||
|
inner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Alert: Sendable where Content: Sendable {}
|
||||||
|
|
||||||
|
extension Alert where Content == p<HTMLText> {
|
||||||
|
|
||||||
|
public init(_ description: String) {
|
||||||
|
self.init {
|
||||||
|
p { description }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
28
Sources/Styleguide/Badge.swift
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import Elementary
|
||||||
|
|
||||||
|
public struct Badge<Inner: HTML>: HTML, Sendable where Inner: Sendable {
|
||||||
|
|
||||||
|
let inner: Inner
|
||||||
|
|
||||||
|
public init(
|
||||||
|
@HTMLBuilder inner: () -> Inner
|
||||||
|
) {
|
||||||
|
self.inner = inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some HTML<HTMLTag.div> {
|
||||||
|
div(.class("badge badge-lg badge-outline")) {
|
||||||
|
inner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Badge where Inner == Number {
|
||||||
|
public init(number: Int) {
|
||||||
|
self.inner = Number(number)
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(number: Double, digits: Int = 2) {
|
||||||
|
self.inner = Number(number, digits: digits)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,7 +65,7 @@ public struct EditButton: HTML, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public var body: some HTML<HTMLTag.button> {
|
public var body: some HTML<HTMLTag.button> {
|
||||||
button(.class("btn btn-success dark:text-white"), .type(type)) {
|
button(.class("btn"), .type(type)) {
|
||||||
div(.class("flex")) {
|
div(.class("flex")) {
|
||||||
if let title {
|
if let title {
|
||||||
span(.class("pe-2")) { title }
|
span(.class("pe-2")) { title }
|
||||||
@@ -83,7 +83,7 @@ public struct PlusButton: HTML, Sendable {
|
|||||||
public var body: some HTML<HTMLTag.button> {
|
public var body: some HTML<HTMLTag.button> {
|
||||||
button(
|
button(
|
||||||
.type(.button),
|
.type(.button),
|
||||||
.class("btn btn-primary")
|
.class("btn")
|
||||||
) { SVG(.circlePlus) }
|
) { SVG(.circlePlus) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ public struct TrashButton: HTML, Sendable {
|
|||||||
public var body: some HTML<HTMLTag.button> {
|
public var body: some HTML<HTMLTag.button> {
|
||||||
button(
|
button(
|
||||||
.type(.button),
|
.type(.button),
|
||||||
.class("btn btn-error dark:text-white")
|
.class("btn btn-error")
|
||||||
) {
|
) {
|
||||||
SVG(.trash)
|
SVG(.trash)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Elementary
|
import Elementary
|
||||||
|
import Foundation
|
||||||
import ManualDCore
|
import ManualDCore
|
||||||
|
|
||||||
extension HTMLAttribute where Tag: HTMLTrait.Attributes.href {
|
extension HTMLAttribute where Tag: HTMLTrait.Attributes.href {
|
||||||
@@ -28,6 +29,10 @@ extension HTMLAttribute where Tag == HTMLTag.input {
|
|||||||
public static func value(_ double: Double?) -> Self {
|
public static func value(_ double: Double?) -> Self {
|
||||||
value(double == nil ? "" : "\(double!)")
|
value(double == nil ? "" : "\(double!)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static func value(_ uuid: UUID?) -> Self {
|
||||||
|
value(uuid?.uuidString ?? "")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension HTMLAttribute where Tag == HTMLTag.button {
|
extension HTMLAttribute where Tag == HTMLTag.button {
|
||||||
@@ -35,3 +40,17 @@ extension HTMLAttribute where Tag == HTMLTag.button {
|
|||||||
.on(.click, "\(id).showModal()")
|
.on(.click, "\(id).showModal()")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension HTML where Tag: HTMLTrait.Attributes.Global {
|
||||||
|
public func badge() -> _AttributedElement<Self> {
|
||||||
|
attributes(.class("badge badge-lg badge-outline"))
|
||||||
|
}
|
||||||
|
|
||||||
|
public func hidden(when shouldHide: Bool) -> _AttributedElement<Self> {
|
||||||
|
attributes(.class("hidden"), when: shouldHide)
|
||||||
|
}
|
||||||
|
|
||||||
|
public func bold(when shouldBeBold: Bool = true) -> _AttributedElement<Self> {
|
||||||
|
attributes(.class("font-bold"), when: shouldBeBold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Elementary
|
import Elementary
|
||||||
|
|
||||||
|
// TODO: Remove, using svg's.
|
||||||
public struct Icon: HTML, Sendable {
|
public struct Icon: HTML, Sendable {
|
||||||
|
|
||||||
let icon: String
|
let icon: String
|
||||||
|
|||||||
@@ -1,5 +1,26 @@
|
|||||||
import Elementary
|
import Elementary
|
||||||
|
|
||||||
|
public struct LabeledInput: HTML, Sendable {
|
||||||
|
|
||||||
|
let labelText: String
|
||||||
|
let inputAttributes: [HTMLAttribute<HTMLTag.input>]
|
||||||
|
|
||||||
|
public init(
|
||||||
|
_ label: String,
|
||||||
|
_ attributes: HTMLAttribute<HTMLTag.input>...
|
||||||
|
) {
|
||||||
|
self.labelText = label
|
||||||
|
self.inputAttributes = attributes
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some HTML<HTMLTag.label> {
|
||||||
|
label(.class("input w-full")) {
|
||||||
|
span(.class("label")) { labelText }
|
||||||
|
input(attributes: inputAttributes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public struct Input: HTML, Sendable {
|
public struct Input: HTML, Sendable {
|
||||||
|
|
||||||
let id: String?
|
let id: String?
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ public struct Label: HTML, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public var body: some HTML<HTMLTag.span> {
|
public var body: some HTML<HTMLTag.span> {
|
||||||
span(.class("text-xl text-gray-400 font-bold")) {
|
span(.class("text-lg label font-bold")) {
|
||||||
title
|
title
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
72
Sources/Styleguide/LabeledContent.swift
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import Elementary
|
||||||
|
|
||||||
|
public struct LabeledContent<Label: HTML, Content: HTML>: HTML {
|
||||||
|
let label: @Sendable () -> Label
|
||||||
|
let content: @Sendable () -> Content
|
||||||
|
let position: LabelPosition
|
||||||
|
|
||||||
|
public init(
|
||||||
|
position: LabelPosition = .default,
|
||||||
|
@HTMLBuilder label: @escaping @Sendable () -> Label,
|
||||||
|
@HTMLBuilder content: @escaping @Sendable () -> Content
|
||||||
|
) {
|
||||||
|
self.position = position
|
||||||
|
self.label = label
|
||||||
|
self.content = content
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some HTML<HTMLTag.div> {
|
||||||
|
div {
|
||||||
|
switch position {
|
||||||
|
case .leading:
|
||||||
|
label()
|
||||||
|
content()
|
||||||
|
case .trailing:
|
||||||
|
content()
|
||||||
|
label()
|
||||||
|
case .top:
|
||||||
|
label()
|
||||||
|
content()
|
||||||
|
case .bottom:
|
||||||
|
content()
|
||||||
|
label()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.attributes(.class("flex space-x-4"), when: position.isHorizontal)
|
||||||
|
.attributes(.class("space-y-4"), when: position.isVertical)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Merge / use TooltipPosition
|
||||||
|
public enum LabelPosition: String, CaseIterable, Equatable, Sendable {
|
||||||
|
case leading
|
||||||
|
case trailing
|
||||||
|
case top
|
||||||
|
case bottom
|
||||||
|
|
||||||
|
var isHorizontal: Bool {
|
||||||
|
self == .leading || self == .trailing
|
||||||
|
}
|
||||||
|
|
||||||
|
var isVertical: Bool {
|
||||||
|
self == .top || self == .bottom
|
||||||
|
}
|
||||||
|
|
||||||
|
public static let `default` = Self.leading
|
||||||
|
}
|
||||||
|
|
||||||
|
extension LabeledContent: Sendable where Label: Sendable, Content: Sendable {}
|
||||||
|
|
||||||
|
extension LabeledContent where Label == Styleguide.Label {
|
||||||
|
|
||||||
|
public init(
|
||||||
|
_ label: String, position: LabelPosition = .default,
|
||||||
|
@HTMLBuilder content: @escaping @Sendable () -> Content
|
||||||
|
) {
|
||||||
|
self.init(
|
||||||
|
position: position,
|
||||||
|
label: { Label(label) },
|
||||||
|
content: content
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ public struct ModalForm<T: HTML>: HTML, Sendable where T: Sendable {
|
|||||||
self.inner = inner()
|
self.inner = inner()
|
||||||
}
|
}
|
||||||
|
|
||||||
public var body: some HTML {
|
public var body: some HTML<HTMLTag.dialog> {
|
||||||
dialog(.id(id), .class("modal")) {
|
dialog(.id(id), .class("modal")) {
|
||||||
div(.class("modal-box")) {
|
div(.class("modal-box")) {
|
||||||
if closeButton {
|
if closeButton {
|
||||||
|
|||||||
40
Sources/Styleguide/PageTitle.swift
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import Elementary
|
||||||
|
|
||||||
|
public struct PageTitleRow<Content: HTML>: HTML, Sendable where Content: Sendable {
|
||||||
|
|
||||||
|
let inner: Content
|
||||||
|
|
||||||
|
public init(@HTMLBuilder content: () -> Content) {
|
||||||
|
self.inner = content()
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some HTML<HTMLTag.div> {
|
||||||
|
div(
|
||||||
|
.class(
|
||||||
|
"""
|
||||||
|
flex justify-between bg-secondary border-2 border-primary rounded-sm shadow-sm
|
||||||
|
p-6 w-full
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
inner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct PageTitle: HTML, Sendable {
|
||||||
|
|
||||||
|
let title: String
|
||||||
|
|
||||||
|
public init(_ title: String) {
|
||||||
|
self.title = title
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(_ title: () -> String) {
|
||||||
|
self.title = title()
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some HTML<HTMLTag.h1> {
|
||||||
|
h1(.class("text-3xl font-bold")) { title }
|
||||||
|
}
|
||||||
|
}
|
||||||
89
Sources/Styleguide/ResultView.swift
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import Elementary
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct ResultView<
|
||||||
|
V: Sendable,
|
||||||
|
E: Error,
|
||||||
|
ValueView: HTML,
|
||||||
|
ErrorView: HTML
|
||||||
|
>: HTML {
|
||||||
|
|
||||||
|
let onSuccess: @Sendable (V) -> ValueView
|
||||||
|
let onError: @Sendable (E) -> ErrorView
|
||||||
|
let result: Result<V, E>
|
||||||
|
|
||||||
|
public init(
|
||||||
|
result: Result<V, E>,
|
||||||
|
@HTMLBuilder onSuccess: @escaping @Sendable (V) -> ValueView,
|
||||||
|
@HTMLBuilder onError: @escaping @Sendable (E) -> ErrorView
|
||||||
|
) {
|
||||||
|
self.result = result
|
||||||
|
self.onError = onError
|
||||||
|
self.onSuccess = onSuccess
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some HTML {
|
||||||
|
switch result {
|
||||||
|
case .success(let value):
|
||||||
|
onSuccess(value)
|
||||||
|
case .failure(let error):
|
||||||
|
onError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ResultView {
|
||||||
|
|
||||||
|
public init(
|
||||||
|
result: Result<V, E>,
|
||||||
|
@HTMLBuilder onSuccess: @escaping @Sendable (V) -> ValueView
|
||||||
|
) where ErrorView == Styleguide.ErrorView<E> {
|
||||||
|
self.init(result: result, onSuccess: onSuccess) { error in
|
||||||
|
Styleguide.ErrorView(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(
|
||||||
|
catching: @escaping @Sendable () async throws(E) -> V,
|
||||||
|
@HTMLBuilder onSuccess: @escaping @Sendable (V) -> ValueView
|
||||||
|
) async where ErrorView == Styleguide.ErrorView<E> {
|
||||||
|
await self.init(
|
||||||
|
result: .init(catching: catching),
|
||||||
|
onSuccess: onSuccess
|
||||||
|
) { error in
|
||||||
|
Styleguide.ErrorView(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(
|
||||||
|
catching: @escaping @Sendable () async throws(E) -> V,
|
||||||
|
) async where ErrorView == Styleguide.ErrorView<E>, V == Void, ValueView == EmptyHTML {
|
||||||
|
await self.init(
|
||||||
|
result: .init(catching: catching),
|
||||||
|
onSuccess: { EmptyHTML() }
|
||||||
|
) { error in
|
||||||
|
Styleguide.ErrorView(error: error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ResultView: Sendable where Error: Sendable, ValueView: Sendable, ErrorView: Sendable {}
|
||||||
|
|
||||||
|
public struct ErrorView<E: Error>: HTML, Sendable where Error: Sendable {
|
||||||
|
|
||||||
|
let error: E
|
||||||
|
|
||||||
|
public init(error: E) {
|
||||||
|
self.error = error
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some HTML<HTMLTag.div> {
|
||||||
|
div {
|
||||||
|
h1(.class("text-2xl font-bold text-error")) { "Oops: Error" }
|
||||||
|
p {
|
||||||
|
"\(error)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -15,24 +15,66 @@ public struct SVG: HTML, Sendable {
|
|||||||
|
|
||||||
extension SVG {
|
extension SVG {
|
||||||
public enum Key: Sendable {
|
public enum Key: Sendable {
|
||||||
|
case badgeCheck
|
||||||
|
case ban
|
||||||
|
case chevronDown
|
||||||
|
case chevronRight
|
||||||
|
case chevronsLeft
|
||||||
case circlePlus
|
case circlePlus
|
||||||
|
case circleUser
|
||||||
case close
|
case close
|
||||||
|
case doorClosed
|
||||||
case email
|
case email
|
||||||
|
case fan
|
||||||
case key
|
case key
|
||||||
|
case mapPin
|
||||||
|
case rulerDimensionLine
|
||||||
|
case sidebarToggle
|
||||||
|
case squareFunction
|
||||||
case squarePen
|
case squarePen
|
||||||
case trash
|
case trash
|
||||||
|
case triangleAlert
|
||||||
case user
|
case user
|
||||||
|
case wind
|
||||||
|
|
||||||
var svg: String {
|
var svg: String {
|
||||||
switch self {
|
switch self {
|
||||||
|
case .badgeCheck:
|
||||||
|
return """
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-badge-check-icon lucide-badge-check"><path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z"/><path d="m9 12 2 2 4-4"/></svg>
|
||||||
|
"""
|
||||||
|
case .ban:
|
||||||
|
return """
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ban-icon lucide-ban"><path d="M4.929 4.929 19.07 19.071"/><circle cx="12" cy="12" r="10"/></svg>
|
||||||
|
"""
|
||||||
|
case .chevronDown:
|
||||||
|
return """
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down-icon lucide-chevron-down"><path d="m6 9 6 6 6-6"/></svg>
|
||||||
|
"""
|
||||||
|
case .chevronRight:
|
||||||
|
return """
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right-icon lucide-chevron-right"><path d="m9 18 6-6-6-6"/></svg>
|
||||||
|
"""
|
||||||
|
case .chevronsLeft:
|
||||||
|
return """
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-left-icon lucide-chevrons-left"><path d="m11 17-5-5 5-5"/><path d="m18 17-5-5 5-5"/></svg>
|
||||||
|
"""
|
||||||
case .circlePlus:
|
case .circlePlus:
|
||||||
return """
|
return """
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-plus-icon lucide-circle-plus"><circle cx="12" cy="12" r="10"/><path d="M8 12h8"/><path d="M12 8v8"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-plus-icon lucide-circle-plus"><circle cx="12" cy="12" r="10"/><path d="M8 12h8"/><path d="M12 8v8"/></svg>
|
||||||
"""
|
"""
|
||||||
|
case .circleUser:
|
||||||
|
return """
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-user-icon lucide-circle-user"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 20.662V19a2 2 0 0 1 2-2h6a2 2 0 0 1 2 2v1.662"/></svg>
|
||||||
|
"""
|
||||||
case .close:
|
case .close:
|
||||||
return """
|
return """
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||||
"""
|
"""
|
||||||
|
case .doorClosed:
|
||||||
|
return """
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-door-closed-icon lucide-door-closed"><path d="M10 12h.01"/><path d="M18 20V6a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v14"/><path d="M2 20h20"/></svg>
|
||||||
|
"""
|
||||||
case .email:
|
case .email:
|
||||||
return """
|
return """
|
||||||
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
@@ -48,6 +90,10 @@ extension SVG {
|
|||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
"""
|
"""
|
||||||
|
case .fan:
|
||||||
|
return """
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-fan-icon lucide-fan"><path d="M10.827 16.379a6.082 6.082 0 0 1-8.618-7.002l5.412 1.45a6.082 6.082 0 0 1 7.002-8.618l-1.45 5.412a6.082 6.082 0 0 1 8.618 7.002l-5.412-1.45a6.082 6.082 0 0 1-7.002 8.618l1.45-5.412Z"/><path d="M12 12v.01"/></svg>
|
||||||
|
"""
|
||||||
case .key:
|
case .key:
|
||||||
return """
|
return """
|
||||||
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
@@ -65,6 +111,22 @@ extension SVG {
|
|||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
"""
|
"""
|
||||||
|
case .mapPin:
|
||||||
|
return """
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin-icon lucide-map-pin"><path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/></svg>
|
||||||
|
"""
|
||||||
|
case .rulerDimensionLine:
|
||||||
|
return """
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ruler-dimension-line-icon lucide-ruler-dimension-line"><path d="M10 15v-3"/><path d="M14 15v-3"/><path d="M18 15v-3"/><path d="M2 8V4"/><path d="M22 6H2"/><path d="M22 8V4"/><path d="M6 15v-3"/><rect x="2" y="12" width="20" height="8" rx="2"/></svg>
|
||||||
|
"""
|
||||||
|
case .sidebarToggle:
|
||||||
|
return """
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-linejoin="round" stroke-linecap="round" stroke-width="2" fill="none" stroke="currentColor" class="my-1.5 inline-block"><path d="M4 4m0 2a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z"></path><path d="M9 4v16"></path><path d="M14 10l2 2l-2 2"></path></svg>
|
||||||
|
"""
|
||||||
|
case .squareFunction:
|
||||||
|
return """
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-function-icon lucide-square-function"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><path d="M9 17c2 0 2.8-1 2.8-2.8V10c0-2 1-3.3 3.2-3"/><path d="M9 11.2h5.7"/></svg>
|
||||||
|
"""
|
||||||
case .squarePen:
|
case .squarePen:
|
||||||
return """
|
return """
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-pen-icon lucide-square-pen"><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-square-pen-icon lucide-square-pen"><path d="M12 3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.375 2.625a1 1 0 0 1 3 3l-9.013 9.014a2 2 0 0 1-.853.505l-2.873.84a.5.5 0 0 1-.62-.62l.84-2.873a2 2 0 0 1 .506-.852z"/></svg>
|
||||||
@@ -74,6 +136,10 @@ extension SVG {
|
|||||||
return """
|
return """
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash2-icon lucide-trash-2"><path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-trash2-icon lucide-trash-2"><path d="M10 11v6"/><path d="M14 11v6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||||
"""
|
"""
|
||||||
|
case .triangleAlert:
|
||||||
|
return """
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-triangle-alert-icon lucide-triangle-alert"><path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
|
||||||
|
"""
|
||||||
case .user:
|
case .user:
|
||||||
return """
|
return """
|
||||||
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
<svg class="h-[1em] opacity-50" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
@@ -89,6 +155,10 @@ extension SVG {
|
|||||||
</g>
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
"""
|
"""
|
||||||
|
case .wind:
|
||||||
|
return """
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-wind-icon lucide-wind"><path d="M12.8 19.6A2 2 0 1 0 14 16H2"/><path d="M17.5 8a2.5 2.5 0 1 1 2 4H2"/><path d="M9.8 4.4A2 2 0 1 1 11 8H2"/></svg>
|
||||||
|
"""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
51
Sources/Styleguide/Tooltip.swift
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import Elementary
|
||||||
|
|
||||||
|
extension HTML {
|
||||||
|
|
||||||
|
public func tooltip(
|
||||||
|
_ tip: String,
|
||||||
|
position: TooltipPosition = .default
|
||||||
|
) -> Tooltip<Self> {
|
||||||
|
Tooltip(tip, position: position) {
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct Tooltip<Inner: HTML>: HTML {
|
||||||
|
|
||||||
|
let tooltip: String
|
||||||
|
let position: TooltipPosition
|
||||||
|
let inner: Inner
|
||||||
|
|
||||||
|
public init(
|
||||||
|
_ tooltip: String,
|
||||||
|
position: TooltipPosition = .default,
|
||||||
|
@HTMLBuilder inner: () -> Inner
|
||||||
|
) {
|
||||||
|
self.tooltip = tooltip
|
||||||
|
self.position = position
|
||||||
|
self.inner = inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some HTML<HTMLTag.div> {
|
||||||
|
div(
|
||||||
|
.class("tooltip tooltip-\(position.rawValue)"),
|
||||||
|
.data("tip", value: tooltip)
|
||||||
|
) {
|
||||||
|
inner
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Tooltip: Sendable where Inner: Sendable {}
|
||||||
|
|
||||||
|
public enum TooltipPosition: String, CaseIterable, Sendable {
|
||||||
|
|
||||||
|
public static let `default` = Self.left
|
||||||
|
|
||||||
|
case bottom
|
||||||
|
case left
|
||||||
|
case right
|
||||||
|
case top
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import DatabaseClient
|
import DatabaseClient
|
||||||
|
import Dependencies
|
||||||
import Fluent
|
import Fluent
|
||||||
|
import ManualDClient
|
||||||
import ManualDCore
|
import ManualDCore
|
||||||
import Vapor
|
import Vapor
|
||||||
|
|
||||||
@@ -22,6 +24,43 @@ extension DatabaseClient.Projects {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension DatabaseClient {
|
||||||
|
|
||||||
|
func calculateDuctSizes(
|
||||||
|
projectID: Project.ID
|
||||||
|
) async throws -> (rooms: [DuctSizing.RoomContainer], trunks: [DuctSizing.TrunkContainer]) {
|
||||||
|
@Dependency(\.manualD) var manualD
|
||||||
|
|
||||||
|
return try await manualD.calculate(
|
||||||
|
rooms: rooms.fetch(projectID),
|
||||||
|
trunks: trunkSizes.fetch(projectID),
|
||||||
|
designFrictionRateResult: designFrictionRate(projectID: projectID),
|
||||||
|
projectSHR: projects.getSensibleHeatRatio(projectID)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func designFrictionRate(
|
||||||
|
projectID: Project.ID
|
||||||
|
) async throws -> (EquipmentInfo, EffectiveLength.MaxContainer, Double)? {
|
||||||
|
guard let equipmentInfo = try await equipment.fetch(projectID) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let equivalentLengths = try await effectiveLength.fetchMax(projectID)
|
||||||
|
guard let tel = equivalentLengths.total else { return nil }
|
||||||
|
|
||||||
|
let componentLosses = try await componentLoss.fetch(projectID)
|
||||||
|
guard componentLosses.count > 0 else { return nil }
|
||||||
|
|
||||||
|
let availableStaticPressure =
|
||||||
|
equipmentInfo.staticPressure - componentLosses.total
|
||||||
|
|
||||||
|
let designFrictionRate = (availableStaticPressure * 100) / tel
|
||||||
|
|
||||||
|
return (equipmentInfo, equivalentLengths, designFrictionRate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension DatabaseClient.ComponentLoss {
|
extension DatabaseClient.ComponentLoss {
|
||||||
|
|
||||||
func createDefaults(projectID: Project.ID) async throws {
|
func createDefaults(projectID: Project.ID) async throws {
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import DatabaseClient
|
||||||
|
import ManualDCore
|
||||||
|
|
||||||
|
extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree {
|
||||||
|
|
||||||
|
func validate() throws(ValidationError) {
|
||||||
|
guard groupGroups.count == groupLengths.count,
|
||||||
|
groupGroups.count == groupLetters.count,
|
||||||
|
groupGroups.count == groupQuantities.count
|
||||||
|
else {
|
||||||
|
throw ValidationError("Equivalent length form group counts are not equal.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var groups: [EffectiveLength.Group] {
|
||||||
|
var groups = [EffectiveLength.Group]()
|
||||||
|
for (n, group) in groupGroups.enumerated() {
|
||||||
|
groups.append(
|
||||||
|
.init(
|
||||||
|
group: group,
|
||||||
|
letter: groupLetters[n],
|
||||||
|
value: Double(groupLengths[n]),
|
||||||
|
quantity: groupQuantities[n]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EffectiveLength.Create {
|
||||||
|
|
||||||
|
init(
|
||||||
|
form: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree,
|
||||||
|
projectID: Project.ID
|
||||||
|
) {
|
||||||
|
self.init(
|
||||||
|
projectID: projectID,
|
||||||
|
name: form.name,
|
||||||
|
type: form.type,
|
||||||
|
straightLengths: form.straightLengths,
|
||||||
|
groups: form.groups
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension EffectiveLength.Update {
|
||||||
|
init(
|
||||||
|
form: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepThree,
|
||||||
|
projectID: Project.ID
|
||||||
|
) throws {
|
||||||
|
self.init(
|
||||||
|
name: form.name,
|
||||||
|
type: form.type,
|
||||||
|
straightLengths: form.straightLengths,
|
||||||
|
groups: form.groups
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import Logging
|
||||||
|
import ManualDClient
|
||||||
|
import ManualDCore
|
||||||
|
|
||||||
|
extension ManualDClient {
|
||||||
|
|
||||||
|
func calculate(
|
||||||
|
rooms: [Room],
|
||||||
|
trunks: [DuctSizing.TrunkSize],
|
||||||
|
designFrictionRateResult: (EquipmentInfo, EffectiveLength.MaxContainer, Double)?,
|
||||||
|
projectSHR: Double?,
|
||||||
|
logger: Logger? = nil
|
||||||
|
) async throws -> (rooms: [DuctSizing.RoomContainer], trunks: [DuctSizing.TrunkContainer]) {
|
||||||
|
guard let designFrictionRateResult else { return ([], []) }
|
||||||
|
let equipmentInfo = designFrictionRateResult.0
|
||||||
|
let effectiveLengths = designFrictionRateResult.1
|
||||||
|
let designFrictionRate = designFrictionRateResult.2
|
||||||
|
|
||||||
|
guard let maxSupply = effectiveLengths.supply else { return ([], []) }
|
||||||
|
guard let maxReturn = effectiveLengths.return else { return ([], []) }
|
||||||
|
|
||||||
|
let ductRooms = try await self.calculateSizes(
|
||||||
|
rooms: rooms,
|
||||||
|
trunks: trunks,
|
||||||
|
equipmentInfo: equipmentInfo,
|
||||||
|
maxSupplyLength: maxSupply,
|
||||||
|
maxReturnLength: maxReturn,
|
||||||
|
designFrictionRate: designFrictionRate,
|
||||||
|
projectSHR: projectSHR ?? 1.0,
|
||||||
|
logger: logger
|
||||||
|
)
|
||||||
|
|
||||||
|
// logger?.debug("Rooms: \(ductRooms)")
|
||||||
|
|
||||||
|
return ductRooms
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
25
Sources/ViewController/Extensions/String+extensions.swift
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension String {
|
||||||
|
|
||||||
|
func appendingPath(_ string: String) -> Self {
|
||||||
|
guard string.starts(with: "/") else {
|
||||||
|
return self.appending("/\(string)")
|
||||||
|
}
|
||||||
|
return self.appending(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendingPath(_ id: UUID?) -> Self {
|
||||||
|
guard let id else { return self }
|
||||||
|
return appendingPath(id.uuidString)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendingPath(_ id: UUID) -> Self {
|
||||||
|
return appendingPath(id.uuidString)
|
||||||
|
}
|
||||||
|
|
||||||
|
var idString: Self {
|
||||||
|
replacing("-", with: "")
|
||||||
|
.replacing(" ", with: "")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import Foundation
|
||||||
|
import Logging
|
||||||
|
import ManualDCore
|
||||||
|
|
||||||
|
extension SiteRoute.View.ProjectRoute.DuctSizingRoute.TrunkSizeForm {
|
||||||
|
|
||||||
|
func toCreate(logger: Logger? = nil) throws -> DuctSizing.TrunkSize.Create {
|
||||||
|
try .init(
|
||||||
|
projectID: projectID,
|
||||||
|
type: type,
|
||||||
|
rooms: makeRooms(logger: logger),
|
||||||
|
height: height,
|
||||||
|
name: name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toUpdate(logger: Logger? = nil) throws -> DuctSizing.TrunkSize.Update {
|
||||||
|
try .init(
|
||||||
|
type: type,
|
||||||
|
rooms: makeRooms(logger: logger),
|
||||||
|
height: height,
|
||||||
|
name: name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeRooms(logger: Logger?) throws -> [Room.ID: [Int]] {
|
||||||
|
var retval = [Room.ID: [Int]]()
|
||||||
|
for room in rooms {
|
||||||
|
let split = room.split(separator: "_")
|
||||||
|
guard let idString = split.first,
|
||||||
|
let id = UUID(uuidString: String(idString))
|
||||||
|
else {
|
||||||
|
logger?.error("Could not parse id from: \(room)")
|
||||||
|
throw RoomError()
|
||||||
|
}
|
||||||
|
guard let registerString = split.last,
|
||||||
|
let register = Int(registerString)
|
||||||
|
else {
|
||||||
|
logger?.error("Could not register number from: \(room)")
|
||||||
|
throw RoomError()
|
||||||
|
}
|
||||||
|
if var currRegisters = retval[id] {
|
||||||
|
currRegisters.append(register)
|
||||||
|
retval[id] = currRegisters
|
||||||
|
} else {
|
||||||
|
retval[id] = [register]
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
return retval
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RoomError: Error {}
|
||||||
7
Sources/ViewController/Extensions/UUID+idString.swift
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension UUID {
|
||||||
|
var idString: String {
|
||||||
|
uuidString.idString
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -55,7 +55,7 @@ extension ViewController: DependencyKey {
|
|||||||
// FIX: Fix.
|
// FIX: Fix.
|
||||||
public static let liveValue = Self(
|
public static let liveValue = Self(
|
||||||
view: { request in
|
view: { request in
|
||||||
try await request.render()
|
await request.render()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -73,6 +73,7 @@ extension ViewController.Request {
|
|||||||
return user
|
return user
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
func createAndAuthenticate(
|
func createAndAuthenticate(
|
||||||
_ signup: User.Create
|
_ signup: User.Create
|
||||||
) async throws -> User {
|
) async throws -> User {
|
||||||
|
|||||||
@@ -3,65 +3,105 @@ import Dependencies
|
|||||||
import Elementary
|
import Elementary
|
||||||
import Foundation
|
import Foundation
|
||||||
import ManualDCore
|
import ManualDCore
|
||||||
|
import Styleguide
|
||||||
|
|
||||||
extension ViewController.Request {
|
extension ViewController.Request {
|
||||||
|
|
||||||
func render() async throws -> AnySendableHTML {
|
func render() async -> AnySendableHTML {
|
||||||
|
|
||||||
@Dependency(\.database) var database
|
@Dependency(\.database) var database
|
||||||
|
|
||||||
switch route {
|
switch route {
|
||||||
|
case .test:
|
||||||
|
let projectID = UUID(uuidString: "A9C20153-E2E5-4C65-B33F-4D8A29C63A7A")!
|
||||||
|
return await view {
|
||||||
|
await ResultView {
|
||||||
|
return (
|
||||||
|
try await database.projects.getCompletedSteps(projectID),
|
||||||
|
try await database.calculateDuctSizes(projectID: projectID)
|
||||||
|
)
|
||||||
|
} onSuccess: { (_, result) in
|
||||||
|
TestPage(trunks: result.trunks, rooms: result.rooms)
|
||||||
|
}
|
||||||
|
}
|
||||||
case .login(let route):
|
case .login(let route):
|
||||||
switch route {
|
switch route {
|
||||||
case .index(let next):
|
case .index(let next):
|
||||||
return view {
|
return await view {
|
||||||
LoginForm(next: next)
|
LoginForm(next: next)
|
||||||
}
|
}
|
||||||
case .submit(let login):
|
case .submit(let login):
|
||||||
let _ = try await authenticate(login)
|
// let _ = try await authenticate(login)
|
||||||
return view {
|
return await view {
|
||||||
LoggedIn(next: login.next)
|
await ResultView {
|
||||||
|
try await authenticate(login)
|
||||||
|
} onSuccess: { _ in
|
||||||
|
LoggedIn(next: login.next)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case .signup(let route):
|
case .signup(let route):
|
||||||
switch route {
|
switch route {
|
||||||
case .index:
|
case .index:
|
||||||
return view {
|
return await view {
|
||||||
LoginForm(style: .signup)
|
LoginForm(style: .signup)
|
||||||
}
|
}
|
||||||
case .submit(let request):
|
case .submit(let request):
|
||||||
// Create a new user and log them in.
|
// Create a new user and log them in.
|
||||||
let user = try await createAndAuthenticate(request)
|
return await view {
|
||||||
let projects = try await database.projects.fetch(user.id, .init(page: 1, per: 25))
|
await ResultView {
|
||||||
return view {
|
try await createAndAuthenticate(request)
|
||||||
ProjectsTable(userID: user.id, projects: projects)
|
} onSuccess: { user in
|
||||||
|
MainPage {
|
||||||
|
UserProfileForm(userID: user.id, profile: nil, dismiss: false, signup: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case .submitProfile(let profile):
|
||||||
|
return await view {
|
||||||
|
await ResultView {
|
||||||
|
_ = try await database.userProfile.create(profile)
|
||||||
|
let userID = profile.userID
|
||||||
|
// let user = try currentUser()
|
||||||
|
return (
|
||||||
|
userID,
|
||||||
|
try await database.projects.fetch(userID, .init(page: 1, per: 25))
|
||||||
|
)
|
||||||
|
} onSuccess: { (userID, projects) in
|
||||||
|
ProjectsTable(userID: userID, projects: projects)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case .project(let route):
|
case .project(let route):
|
||||||
return try await route.renderView(on: self)
|
return await route.renderView(on: self)
|
||||||
case .effectiveLength(let route):
|
|
||||||
return try await route.renderView(isHtmxRequest: isHtmxRequest)
|
case .user(let route):
|
||||||
// case .user(let route):
|
return await route.renderView(on: self)
|
||||||
// return try await route.renderView(isHtmxRequest: isHtmxRequest)
|
|
||||||
default:
|
|
||||||
// FIX: FIX
|
|
||||||
return _render(isHtmxRequest: false) {
|
|
||||||
div { "Fix me!" }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func view<C: HTML>(
|
func view<C: HTML>(
|
||||||
@HTMLBuilder inner: () -> C
|
@HTMLBuilder inner: () async -> C
|
||||||
) -> AnySendableHTML where C: Sendable {
|
) async -> AnySendableHTML where C: Sendable {
|
||||||
_render(isHtmxRequest: isHtmxRequest, showSidebar: showSidebar) {
|
let inner = await inner()
|
||||||
inner()
|
let theme = await self.theme
|
||||||
|
|
||||||
|
return MainPage(displayFooter: displayFooter, theme: theme) {
|
||||||
|
inner
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var showSidebar: Bool {
|
var theme: Theme? {
|
||||||
|
get async {
|
||||||
|
@Dependency(\.database) var database
|
||||||
|
guard let user = try? currentUser() else { return nil }
|
||||||
|
return try? await database.userProfile.fetch(user.id)?.theme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayFooter: Bool {
|
||||||
switch route {
|
switch route {
|
||||||
case .login, .signup, .project(.page):
|
case .login, .signup:
|
||||||
return false
|
return false
|
||||||
default:
|
default:
|
||||||
return true
|
return true
|
||||||
@@ -71,83 +111,155 @@ extension ViewController.Request {
|
|||||||
|
|
||||||
extension SiteRoute.View.ProjectRoute {
|
extension SiteRoute.View.ProjectRoute {
|
||||||
|
|
||||||
func renderView(on request: ViewController.Request) async throws -> AnySendableHTML {
|
func renderView(on request: ViewController.Request) async -> AnySendableHTML {
|
||||||
@Dependency(\.database) var database
|
@Dependency(\.database) var database
|
||||||
let user = try request.currentUser()
|
|
||||||
|
|
||||||
switch self {
|
switch self {
|
||||||
case .index:
|
case .index:
|
||||||
let projects = try await database.projects.fetchPage(userID: user.id)
|
return await request.view {
|
||||||
return request.view {
|
await ResultView {
|
||||||
ProjectsTable(userID: user.id, projects: projects)
|
let user = try request.currentUser()
|
||||||
|
return try await (
|
||||||
|
user.id,
|
||||||
|
database.projects.fetchPage(userID: user.id)
|
||||||
|
)
|
||||||
|
|
||||||
|
} onSuccess: { (userID, projects) in
|
||||||
|
ProjectsTable(userID: userID, projects: projects)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case .page(let page):
|
case .page(let page):
|
||||||
let projects = try await database.projects.fetch(user.id, page)
|
return await ResultView {
|
||||||
return ProjectsTable(userID: user.id, projects: projects)
|
let user = try request.currentUser()
|
||||||
|
return try await (
|
||||||
case .form(let id, let dismiss):
|
user.id,
|
||||||
request.logger.debug("Project form: \(id != nil ? "Fetching project for: \(id!)" : "N/A")")
|
database.projects.fetch(user.id, page)
|
||||||
var project: Project? = nil
|
)
|
||||||
if let id, dismiss == false {
|
} onSuccess: { (userID, projects) in
|
||||||
project = try await database.projects.get(id)
|
ProjectsTable(userID: userID, projects: projects)
|
||||||
}
|
}
|
||||||
request.logger.debug(
|
|
||||||
project == nil ? "No project found" : "Showing form for existing project"
|
|
||||||
)
|
|
||||||
return ProjectForm(dismiss: dismiss, project: project)
|
|
||||||
|
|
||||||
case .create(let form):
|
case .create(let form):
|
||||||
let project = try await database.projects.create(user.id, form)
|
return await request.view {
|
||||||
try await database.componentLoss.createDefaults(projectID: project.id)
|
await ResultView {
|
||||||
return ProjectView(projectID: project.id, activeTab: .rooms)
|
let user = try request.currentUser()
|
||||||
|
let project = try await database.projects.create(user.id, form)
|
||||||
|
try await database.componentLoss.createDefaults(projectID: project.id)
|
||||||
|
let rooms = try await database.rooms.fetch(project.id)
|
||||||
|
let shr = try await database.projects.getSensibleHeatRatio(project.id)
|
||||||
|
let completedSteps = try await database.projects.getCompletedSteps(project.id)
|
||||||
|
return (project.id, rooms, shr, completedSteps)
|
||||||
|
} onSuccess: { (projectID, rooms, shr, completedSteps) in
|
||||||
|
ProjectView(
|
||||||
|
projectID: projectID,
|
||||||
|
activeTab: .rooms,
|
||||||
|
completedSteps: completedSteps
|
||||||
|
) {
|
||||||
|
RoomsView(rooms: rooms, sensibleHeatRatio: shr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case .delete(let id):
|
case .delete(let id):
|
||||||
try await database.projects.delete(id)
|
return await ResultView {
|
||||||
return EmptyHTML()
|
try await database.projects.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
case .update(let form):
|
case .update(let id, let form):
|
||||||
let project = try await database.projects.update(form)
|
return await projectView(on: request, projectID: id) {
|
||||||
return ProjectView(projectID: project.id, activeTab: .project)
|
_ = try await database.projects.update(id, form)
|
||||||
|
}
|
||||||
|
|
||||||
case .detail(let projectID, let route):
|
case .detail(let projectID, let route):
|
||||||
switch route {
|
switch route {
|
||||||
case .index(let tab):
|
case .index:
|
||||||
return request.view {
|
return await projectView(on: request, projectID: projectID)
|
||||||
ProjectView(projectID: projectID, activeTab: tab)
|
case .componentLoss(let route):
|
||||||
}
|
return await route.renderView(on: request, projectID: projectID)
|
||||||
|
case .ductSizing(let route):
|
||||||
|
return await route.renderView(on: request, projectID: projectID)
|
||||||
case .equipment(let route):
|
case .equipment(let route):
|
||||||
return try await route.renderView(on: request, projectID: projectID)
|
return await route.renderView(on: request, projectID: projectID)
|
||||||
|
case .equivalentLength(let route):
|
||||||
|
return await route.renderView(on: request, projectID: projectID)
|
||||||
case .frictionRate(let route):
|
case .frictionRate(let route):
|
||||||
return try await route.renderView(on: request, projectID: projectID)
|
return await route.renderView(on: request, projectID: projectID)
|
||||||
case .rooms(let route):
|
case .rooms(let route):
|
||||||
return try await route.renderView(on: request, projectID: projectID)
|
return await route.renderView(on: request, projectID: projectID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func projectView(
|
||||||
|
on request: ViewController.Request,
|
||||||
|
projectID: Project.ID,
|
||||||
|
catching: @escaping @Sendable () async throws -> Void = {}
|
||||||
|
) async -> AnySendableHTML {
|
||||||
|
@Dependency(\.database) var database
|
||||||
|
|
||||||
|
return await request.view {
|
||||||
|
await ResultView {
|
||||||
|
try await catching()
|
||||||
|
guard let project = try await database.projects.get(projectID) else {
|
||||||
|
throw NotFoundError()
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
try await database.projects.getCompletedSteps(project.id),
|
||||||
|
project
|
||||||
|
)
|
||||||
|
} onSuccess: { (steps, project) in
|
||||||
|
ProjectView(projectID: projectID, activeTab: .project, completedSteps: steps) {
|
||||||
|
ProjectDetail(project: project)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SiteRoute.View.ProjectRoute.EquipmentInfoRoute {
|
extension SiteRoute.View.ProjectRoute.EquipmentInfoRoute {
|
||||||
func renderView(
|
func renderView(
|
||||||
on request: ViewController.Request,
|
on request: ViewController.Request,
|
||||||
projectID: Project.ID
|
projectID: Project.ID
|
||||||
) async throws -> AnySendableHTML {
|
) async -> AnySendableHTML {
|
||||||
@Dependency(\.database) var database
|
@Dependency(\.database) var database
|
||||||
|
|
||||||
switch self {
|
switch self {
|
||||||
case .index:
|
case .index:
|
||||||
let equipment = try await database.equipment.fetch(projectID)
|
return await equipmentView(on: request, projectID: projectID)
|
||||||
return EquipmentInfoView(equipmentInfo: equipment, projectID: projectID)
|
|
||||||
case .form(let dismiss):
|
|
||||||
let equipment = try await database.equipment.fetch(projectID)
|
|
||||||
return EquipmentInfoForm(dismiss: dismiss, projectID: projectID, equipmentInfo: equipment)
|
|
||||||
case .submit(let form):
|
case .submit(let form):
|
||||||
let equipment = try await database.equipment.create(form)
|
return await equipmentView(on: request, projectID: projectID) {
|
||||||
return EquipmentInfoView(equipmentInfo: equipment, projectID: projectID)
|
_ = try await database.equipment.create(form)
|
||||||
case .update(let updates):
|
}
|
||||||
let equipment = try await database.equipment.update(updates)
|
|
||||||
return EquipmentInfoView(equipmentInfo: equipment, projectID: projectID)
|
case .update(let id, let updates):
|
||||||
|
return await equipmentView(on: request, projectID: projectID) {
|
||||||
|
_ = try await database.equipment.update(id, updates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func equipmentView(
|
||||||
|
on request: ViewController.Request,
|
||||||
|
projectID: Project.ID,
|
||||||
|
catching: @escaping @Sendable () async throws -> Void = {}
|
||||||
|
) async -> AnySendableHTML {
|
||||||
|
@Dependency(\.database) var database
|
||||||
|
|
||||||
|
return await request.view {
|
||||||
|
await ResultView {
|
||||||
|
try await catching()
|
||||||
|
return (
|
||||||
|
try await database.projects.getCompletedSteps(projectID),
|
||||||
|
try await database.equipment.fetch(projectID)
|
||||||
|
)
|
||||||
|
} onSuccess: { (steps, equipment) in
|
||||||
|
ProjectView(projectID: projectID, activeTab: .equipment, completedSteps: steps) {
|
||||||
|
EquipmentInfoView(equipmentInfo: equipment, projectID: projectID)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,98 +268,203 @@ extension SiteRoute.View.ProjectRoute.RoomRoute {
|
|||||||
func renderView(
|
func renderView(
|
||||||
on request: ViewController.Request,
|
on request: ViewController.Request,
|
||||||
projectID: Project.ID
|
projectID: Project.ID
|
||||||
) async throws -> AnySendableHTML {
|
) async -> AnySendableHTML {
|
||||||
@Dependency(\.database) var database
|
@Dependency(\.database) var database
|
||||||
|
|
||||||
switch self {
|
switch self {
|
||||||
|
|
||||||
case .delete(let id):
|
case .delete(let id):
|
||||||
try await database.rooms.delete(id)
|
return await ResultView {
|
||||||
return EmptyHTML()
|
try await database.rooms.delete(id)
|
||||||
|
|
||||||
case .form(let id, let dismiss):
|
|
||||||
var room: Room? = nil
|
|
||||||
if let id, dismiss == false {
|
|
||||||
room = try await database.rooms.get(id)
|
|
||||||
}
|
}
|
||||||
return RoomForm(dismiss: dismiss, projectID: projectID, room: room)
|
|
||||||
|
|
||||||
case .index:
|
case .index:
|
||||||
return request.view {
|
return await roomsView(on: request, projectID: projectID)
|
||||||
ProjectView(projectID: projectID, activeTab: .rooms)
|
|
||||||
}
|
|
||||||
|
|
||||||
case .submit(let form):
|
case .submit(let form):
|
||||||
request.logger.debug("New room form submitted.")
|
return await roomsView(on: request, projectID: projectID) {
|
||||||
let _ = try await database.rooms.create(form)
|
_ = try await database.rooms.create(form)
|
||||||
return request.view {
|
|
||||||
ProjectView(projectID: projectID, activeTab: .rooms)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case .update(let form):
|
case .update(let id, let form):
|
||||||
_ = try await database.rooms.update(form)
|
return await roomsView(on: request, projectID: projectID) {
|
||||||
return ProjectView(projectID: projectID, activeTab: .rooms)
|
_ = try await database.rooms.update(id, form)
|
||||||
|
}
|
||||||
|
|
||||||
case .updateSensibleHeatRatio(let form):
|
case .updateSensibleHeatRatio(let form):
|
||||||
let _ = try await database.projects.update(
|
return await roomsView(on: request, projectID: projectID) {
|
||||||
.init(id: form.projectID, sensibleHeatRatio: form.sensibleHeatRatio)
|
_ = try await database.projects.update(
|
||||||
)
|
form.projectID,
|
||||||
return request.view {
|
.init(sensibleHeatRatio: form.sensibleHeatRatio)
|
||||||
ProjectView(projectID: projectID, activeTab: .rooms)
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func roomsView(
|
||||||
|
on request: ViewController.Request,
|
||||||
|
projectID: Project.ID,
|
||||||
|
catching: @escaping @Sendable () async throws -> Void = {}
|
||||||
|
) async -> AnySendableHTML {
|
||||||
|
@Dependency(\.database) var database
|
||||||
|
|
||||||
|
return await request.view {
|
||||||
|
await ResultView {
|
||||||
|
try await catching()
|
||||||
|
return (
|
||||||
|
try await database.projects.getCompletedSteps(projectID),
|
||||||
|
try await database.rooms.fetch(projectID),
|
||||||
|
try await database.projects.getSensibleHeatRatio(projectID)
|
||||||
|
)
|
||||||
|
} onSuccess: { (steps, rooms, shr) in
|
||||||
|
ProjectView(projectID: projectID, activeTab: .rooms, completedSteps: steps) {
|
||||||
|
RoomsView(rooms: rooms, sensibleHeatRatio: shr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SiteRoute.View.ProjectRoute.FrictionRateRoute {
|
extension SiteRoute.View.ProjectRoute.FrictionRateRoute {
|
||||||
func renderView(on request: ViewController.Request, projectID: Project.ID) async throws
|
func renderView(
|
||||||
-> AnySendableHTML
|
on request: ViewController.Request,
|
||||||
{
|
projectID: Project.ID
|
||||||
|
) async -> AnySendableHTML {
|
||||||
|
@Dependency(\.database) var database
|
||||||
|
@Dependency(\.manualD) var manualD
|
||||||
|
|
||||||
|
switch self {
|
||||||
|
case .index:
|
||||||
|
return await view(on: request, projectID: projectID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func view(
|
||||||
|
on request: ViewController.Request,
|
||||||
|
projectID: Project.ID,
|
||||||
|
catching: @escaping @Sendable () async throws -> Void = {}
|
||||||
|
) async -> AnySendableHTML {
|
||||||
|
|
||||||
|
@Dependency(\.database) var database
|
||||||
|
@Dependency(\.manualD) var manualD
|
||||||
|
|
||||||
|
return await request.view {
|
||||||
|
await ResultView {
|
||||||
|
let equipment = try await database.equipment.fetch(projectID)
|
||||||
|
let componentLosses = try await database.componentLoss.fetch(projectID)
|
||||||
|
let lengths = try await database.effectiveLength.fetchMax(projectID)
|
||||||
|
|
||||||
|
return (
|
||||||
|
try await database.projects.getCompletedSteps(projectID),
|
||||||
|
componentLosses,
|
||||||
|
lengths,
|
||||||
|
try await manualD.frictionRate(
|
||||||
|
equipmentInfo: equipment,
|
||||||
|
componentLosses: componentLosses,
|
||||||
|
effectiveLength: lengths
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} onSuccess: { (steps, losses, lengths, frictionRate) in
|
||||||
|
ProjectView(projectID: projectID, activeTab: .frictionRate, completedSteps: steps) {
|
||||||
|
FrictionRateView(
|
||||||
|
componentLosses: losses,
|
||||||
|
equivalentLengths: lengths,
|
||||||
|
frictionRateResponse: frictionRate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension SiteRoute.View.ProjectRoute.ComponentLossRoute {
|
||||||
|
|
||||||
|
func renderView(
|
||||||
|
on request: ViewController.Request,
|
||||||
|
projectID: Project.ID
|
||||||
|
) async -> AnySendableHTML {
|
||||||
@Dependency(\.database) var database
|
@Dependency(\.database) var database
|
||||||
|
|
||||||
switch self {
|
switch self {
|
||||||
case .index:
|
case .index:
|
||||||
let equipment = try await database.equipment.fetch(projectID)
|
return EmptyHTML()
|
||||||
let componentLosses = try await database.componentLoss.fetch(projectID)
|
case .delete(let id):
|
||||||
|
return await view(on: request, projectID: projectID) {
|
||||||
return request.view {
|
_ = try await database.componentLoss.delete(id)
|
||||||
ProjectView(projectID: projectID, activeTab: .frictionRate)
|
}
|
||||||
|
case .submit(let form):
|
||||||
|
return await view(on: request, projectID: projectID) {
|
||||||
|
_ = try await database.componentLoss.create(form)
|
||||||
}
|
}
|
||||||
|
|
||||||
case .form(let type, let dismiss):
|
case .update(let id, let form):
|
||||||
// FIX: Forms need to reference existing items.
|
return await view(on: request, projectID: projectID) {
|
||||||
switch type {
|
_ = try await database.componentLoss.update(id, form)
|
||||||
case .equipmentInfo:
|
|
||||||
return div { "REMOVE ME!" }
|
|
||||||
// return EquipmentForm(dismiss: dismiss, projectID: projectID)
|
|
||||||
case .componentPressureLoss:
|
|
||||||
return ComponentLossForm(dismiss: dismiss, projectID: projectID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
extension SiteRoute.View.ProjectRoute.FrictionRateRoute.FormType {
|
func view(
|
||||||
var id: String {
|
on request: ViewController.Request,
|
||||||
switch self {
|
projectID: Project.ID,
|
||||||
case .equipmentInfo:
|
catching: @escaping @Sendable () async throws -> Void = {}
|
||||||
return "equipmentForm"
|
) async -> AnySendableHTML {
|
||||||
case .componentPressureLoss:
|
|
||||||
return "componentLossForm"
|
@Dependency(\.database) var database
|
||||||
|
@Dependency(\.manualD) var manualD
|
||||||
|
|
||||||
|
return await request.view {
|
||||||
|
await ResultView {
|
||||||
|
try await catching()
|
||||||
|
|
||||||
|
let equipment = try await database.equipment.fetch(projectID)
|
||||||
|
let componentLosses = try await database.componentLoss.fetch(projectID)
|
||||||
|
let lengths = try await database.effectiveLength.fetchMax(projectID)
|
||||||
|
|
||||||
|
return (
|
||||||
|
try await database.projects.getCompletedSteps(projectID),
|
||||||
|
componentLosses,
|
||||||
|
lengths,
|
||||||
|
try await manualD.frictionRate(
|
||||||
|
equipmentInfo: equipment,
|
||||||
|
componentLosses: componentLosses,
|
||||||
|
effectiveLength: lengths
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} onSuccess: { (steps, losses, lengths, frictionRate) in
|
||||||
|
ProjectView(projectID: projectID, activeTab: .frictionRate, completedSteps: steps) {
|
||||||
|
FrictionRateView(
|
||||||
|
componentLosses: losses,
|
||||||
|
equivalentLengths: lengths,
|
||||||
|
frictionRateResponse: frictionRate
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
extension SiteRoute.View.EffectiveLengthRoute {
|
extension SiteRoute.View.ProjectRoute.EquivalentLengthRoute {
|
||||||
|
|
||||||
|
func renderView(
|
||||||
|
on request: ViewController.Request,
|
||||||
|
projectID: Project.ID
|
||||||
|
) async -> AnySendableHTML {
|
||||||
|
@Dependency(\.database) var database
|
||||||
|
|
||||||
func renderView(isHtmxRequest: Bool) async throws -> AnySendableHTML {
|
|
||||||
switch self {
|
switch self {
|
||||||
|
|
||||||
|
case .delete(let id):
|
||||||
|
return await ResultView {
|
||||||
|
try await database.effectiveLength.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
case .index:
|
case .index:
|
||||||
return _render(isHtmxRequest: isHtmxRequest, active: .effectiveLength) {
|
return await self.view(on: request, projectID: projectID)
|
||||||
EffectiveLengthsView(effectiveLengths: EffectiveLength.mocks)
|
|
||||||
}
|
|
||||||
case .form(let dismiss):
|
|
||||||
return EffectiveLengthForm(dismiss: dismiss)
|
|
||||||
|
|
||||||
case .field(let type, let style):
|
case .field(let type, let style):
|
||||||
switch type {
|
switch type {
|
||||||
@@ -257,32 +474,206 @@ extension SiteRoute.View.EffectiveLengthRoute {
|
|||||||
// FIX:
|
// FIX:
|
||||||
return GroupField(style: style ?? .supply)
|
return GroupField(style: style ?? .supply)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case .update(let id, let form):
|
||||||
|
return await view(on: request, projectID: projectID) {
|
||||||
|
_ = try await database.effectiveLength.update(id, .init(form: form, projectID: projectID))
|
||||||
|
}
|
||||||
|
|
||||||
|
case .submit(let step):
|
||||||
|
switch step {
|
||||||
|
case .one(let stepOne):
|
||||||
|
return await ResultView {
|
||||||
|
var effectiveLength: EffectiveLength? = nil
|
||||||
|
if let id = stepOne.id {
|
||||||
|
effectiveLength = try await database.effectiveLength.get(id)
|
||||||
|
}
|
||||||
|
return effectiveLength
|
||||||
|
} onSuccess: { effectiveLength in
|
||||||
|
EffectiveLengthForm.StepTwo(
|
||||||
|
projectID: projectID,
|
||||||
|
stepOne: stepOne,
|
||||||
|
effectiveLength: effectiveLength
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case .two(let stepTwo):
|
||||||
|
return await ResultView {
|
||||||
|
request.logger.debug("ViewController: Got step two...")
|
||||||
|
var effectiveLength: EffectiveLength? = nil
|
||||||
|
if let id = stepTwo.id {
|
||||||
|
effectiveLength = try await database.effectiveLength.get(id)
|
||||||
|
}
|
||||||
|
return effectiveLength
|
||||||
|
} onSuccess: { effectiveLength in
|
||||||
|
return EffectiveLengthForm.StepThree(
|
||||||
|
projectID: projectID, effectiveLength: effectiveLength, stepTwo: stepTwo
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case .three(let stepThree):
|
||||||
|
return await view(on: request, projectID: projectID) {
|
||||||
|
_ = try await database.effectiveLength.create(
|
||||||
|
.init(form: stepThree, projectID: projectID)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func view(
|
||||||
|
on request: ViewController.Request,
|
||||||
|
projectID: Project.ID,
|
||||||
|
catching: @escaping @Sendable () async throws -> Void = {}
|
||||||
|
) async -> AnySendableHTML {
|
||||||
|
@Dependency(\.database) var database
|
||||||
|
return await request.view {
|
||||||
|
await ResultView {
|
||||||
|
try await catching()
|
||||||
|
return (
|
||||||
|
try await database.projects.getCompletedSteps(projectID),
|
||||||
|
try await database.effectiveLength.fetch(projectID)
|
||||||
|
)
|
||||||
|
} onSuccess: { (steps, equivalentLengths) in
|
||||||
|
ProjectView(projectID: projectID, activeTab: .equivalentLength, completedSteps: steps) {
|
||||||
|
EffectiveLengthsView(effectiveLengths: equivalentLengths)
|
||||||
|
.environment(ProjectViewValue.$projectID, projectID)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func _render<C: HTML>(
|
extension SiteRoute.View.ProjectRoute.DuctSizingRoute {
|
||||||
isHtmxRequest: Bool,
|
|
||||||
active activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab = .rooms,
|
func renderView(
|
||||||
showSidebar: Bool = true,
|
on request: ViewController.Request,
|
||||||
@HTMLBuilder inner: () async throws -> C
|
projectID: Project.ID
|
||||||
) async throws -> AnySendableHTML where C: Sendable {
|
) async -> AnySendableHTML {
|
||||||
let inner = try await inner()
|
@Dependency(\.database) var database
|
||||||
if isHtmxRequest {
|
@Dependency(\.manualD) var manualD
|
||||||
return inner
|
|
||||||
|
switch self {
|
||||||
|
case .index:
|
||||||
|
return await view(on: request, projectID: projectID)
|
||||||
|
|
||||||
|
case .deleteRectangularSize(let roomID, let request):
|
||||||
|
return await ResultView {
|
||||||
|
let room = try await database.rooms.deleteRectangularSize(roomID, request.rectangularSizeID)
|
||||||
|
return try await database.calculateDuctSizes(projectID: projectID)
|
||||||
|
.rooms
|
||||||
|
.filter({ $0.roomID == room.id && $0.roomRegister == request.register })
|
||||||
|
.first!
|
||||||
|
} onSuccess: { room in
|
||||||
|
DuctSizingView.RoomRow(room: room)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .roomRectangularForm(let roomID, let form):
|
||||||
|
return await ResultView {
|
||||||
|
let room = try await database.rooms.updateRectangularSize(
|
||||||
|
roomID,
|
||||||
|
.init(id: form.id ?? .init(), register: form.register, height: form.height)
|
||||||
|
)
|
||||||
|
return try await database.calculateDuctSizes(projectID: projectID)
|
||||||
|
.rooms
|
||||||
|
.filter({ $0.roomID == room.id && $0.roomRegister == form.register })
|
||||||
|
.first!
|
||||||
|
} onSuccess: { room in
|
||||||
|
DuctSizingView.RoomRow(room: room)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .trunk(let route):
|
||||||
|
switch route {
|
||||||
|
case .delete(let id):
|
||||||
|
return await ResultView {
|
||||||
|
try await database.trunkSizes.delete(id)
|
||||||
|
}
|
||||||
|
case .submit(let form):
|
||||||
|
return await view(on: request, projectID: projectID) {
|
||||||
|
_ = try await database.trunkSizes.create(
|
||||||
|
form.toCreate(logger: request.logger)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
case .update(let id, let form):
|
||||||
|
return await view(on: request, projectID: projectID) {
|
||||||
|
_ = try await database.trunkSizes.update(id, form.toUpdate())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func view(
|
||||||
|
on request: ViewController.Request,
|
||||||
|
projectID: Project.ID,
|
||||||
|
catching: @escaping @Sendable () async throws -> Void = {}
|
||||||
|
) async -> AnySendableHTML {
|
||||||
|
@Dependency(\.database) var database
|
||||||
|
|
||||||
|
return await request.view {
|
||||||
|
await ResultView {
|
||||||
|
try await catching()
|
||||||
|
return (
|
||||||
|
try await database.projects.getCompletedSteps(projectID),
|
||||||
|
try await database.calculateDuctSizes(projectID: projectID)
|
||||||
|
)
|
||||||
|
} onSuccess: { (steps, ducts) in
|
||||||
|
ProjectView(projectID: projectID, activeTab: .ductSizing, completedSteps: steps) {
|
||||||
|
DuctSizingView(rooms: ducts.rooms, trunks: ducts.trunks)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return MainPage { inner }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func _render<C: HTML>(
|
extension SiteRoute.View.UserRoute {
|
||||||
isHtmxRequest: Bool,
|
|
||||||
active activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab = .rooms,
|
func renderView(on request: ViewController.Request) async -> AnySendableHTML {
|
||||||
showSidebar: Bool = true,
|
switch self {
|
||||||
@HTMLBuilder inner: () -> C
|
case .profile(let route):
|
||||||
) -> AnySendableHTML where C: Sendable {
|
return await route.renderView(on: request)
|
||||||
let inner = inner()
|
}
|
||||||
if isHtmxRequest {
|
}
|
||||||
return inner
|
}
|
||||||
|
|
||||||
|
extension SiteRoute.View.UserRoute.Profile {
|
||||||
|
|
||||||
|
func renderView(
|
||||||
|
on request: ViewController.Request
|
||||||
|
) async -> AnySendableHTML {
|
||||||
|
@Dependency(\.database) var database
|
||||||
|
|
||||||
|
switch self {
|
||||||
|
case .index:
|
||||||
|
return await view(on: request)
|
||||||
|
case .submit(let form):
|
||||||
|
return await view(on: request) {
|
||||||
|
_ = try await database.userProfile.create(form)
|
||||||
|
}
|
||||||
|
case .update(let id, let updates):
|
||||||
|
return await view(on: request) {
|
||||||
|
_ = try await database.userProfile.update(id, updates)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func view(
|
||||||
|
on request: ViewController.Request,
|
||||||
|
catching: @escaping @Sendable () async throws -> Void = {}
|
||||||
|
) async -> AnySendableHTML {
|
||||||
|
@Dependency(\.database) var database
|
||||||
|
|
||||||
|
return await request.view {
|
||||||
|
await ResultView {
|
||||||
|
try await catching()
|
||||||
|
let user = try request.currentUser()
|
||||||
|
return (
|
||||||
|
user,
|
||||||
|
try await database.userProfile.fetch(user.id)
|
||||||
|
)
|
||||||
|
} onSuccess: { (user, profile) in
|
||||||
|
UserView(user: user, profile: profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return MainPage { inner }
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,39 +3,69 @@ import ElementaryHTMX
|
|||||||
import ManualDCore
|
import ManualDCore
|
||||||
import Styleguide
|
import Styleguide
|
||||||
|
|
||||||
|
// FIX: The value field is sometimes wonky as far as what values it accepts.
|
||||||
|
|
||||||
struct ComponentLossForm: HTML, Sendable {
|
struct ComponentLossForm: HTML, Sendable {
|
||||||
|
|
||||||
|
static func id(_ componentLoss: ComponentPressureLoss? = nil) -> String {
|
||||||
|
let base = "componentLossForm"
|
||||||
|
guard let componentLoss else { return base }
|
||||||
|
return "\(base)_\(componentLoss.id.idString)"
|
||||||
|
}
|
||||||
|
|
||||||
let dismiss: Bool
|
let dismiss: Bool
|
||||||
let projectID: Project.ID
|
let projectID: Project.ID
|
||||||
|
let componentLoss: ComponentPressureLoss?
|
||||||
|
|
||||||
|
var route: String {
|
||||||
|
SiteRoute.View.router.path(
|
||||||
|
for: .project(.detail(projectID, .componentLoss(.index)))
|
||||||
|
)
|
||||||
|
.appendingPath(componentLoss?.id)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some HTML {
|
var body: some HTML {
|
||||||
ModalForm(id: "componentLossForm", dismiss: dismiss) {
|
ModalForm(id: Self.id(componentLoss), dismiss: dismiss) {
|
||||||
h1(.class("text-2xl font-bold")) { "Component Loss" }
|
h1(.class("text-2xl font-bold")) { "Component Loss" }
|
||||||
form(.class("space-y-4 p-4")) {
|
form(
|
||||||
div {
|
.class("space-y-4 p-4"),
|
||||||
label(.for("name")) { "Name" }
|
componentLoss == nil
|
||||||
Input(id: "name", placeholder: "Name")
|
? .hx.post(route)
|
||||||
.attributes(.type(.text), .required, .autofocus)
|
: .hx.patch(route),
|
||||||
}
|
.hx.target("body"),
|
||||||
div {
|
.hx.swap(.outerHTML)
|
||||||
label(.for("value")) { "Value" }
|
) {
|
||||||
Input(id: "name", placeholder: "Pressure loss")
|
|
||||||
.attributes(.type(.number), .min("0"), .max("1"), .step("0.1"), .required)
|
if let componentLoss {
|
||||||
}
|
input(.class("hidden"), .name("id"), .value("\(componentLoss.id)"))
|
||||||
Row {
|
|
||||||
div {}
|
|
||||||
div {
|
|
||||||
CancelButton()
|
|
||||||
.attributes(
|
|
||||||
.hx.get(
|
|
||||||
route: .project(
|
|
||||||
.detail(projectID, .frictionRate(.form(.componentPressureLoss, dismiss: true))))
|
|
||||||
),
|
|
||||||
.hx.target("#componentLossForm"),
|
|
||||||
.hx.swap(.outerHTML)
|
|
||||||
)
|
|
||||||
SubmitButton()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input(.class("hidden"), .name("projectID"), .value("\(projectID)"))
|
||||||
|
|
||||||
|
LabeledInput(
|
||||||
|
"Name",
|
||||||
|
.name("name"),
|
||||||
|
.type(.text),
|
||||||
|
.value(componentLoss?.name),
|
||||||
|
.placeholder("Name"),
|
||||||
|
.required,
|
||||||
|
.autofocus
|
||||||
|
)
|
||||||
|
|
||||||
|
LabeledInput(
|
||||||
|
"Value",
|
||||||
|
.name("value"),
|
||||||
|
.type(.number),
|
||||||
|
.value(componentLoss?.value),
|
||||||
|
.placeholder("0.2"),
|
||||||
|
.min("0.03"),
|
||||||
|
.max("1.0"),
|
||||||
|
.step("0.01"),
|
||||||
|
.required
|
||||||
|
)
|
||||||
|
|
||||||
|
SubmitButton()
|
||||||
|
.attributes(.class("btn-block"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,53 +3,88 @@ import ElementaryHTMX
|
|||||||
import ManualDCore
|
import ManualDCore
|
||||||
import Styleguide
|
import Styleguide
|
||||||
|
|
||||||
// TODO: Load component losses when view appears??
|
|
||||||
|
|
||||||
struct ComponentPressureLossesView: HTML, Sendable {
|
struct ComponentPressureLossesView: HTML, Sendable {
|
||||||
|
|
||||||
let componentPressureLosses: [ComponentPressureLoss]
|
let componentPressureLosses: [ComponentPressureLoss]
|
||||||
let projectID: Project.ID
|
let projectID: Project.ID
|
||||||
|
|
||||||
private var total: Double {
|
private var total: Double {
|
||||||
componentPressureLosses.reduce(into: 0) { $0 += $1.value }
|
componentPressureLosses.total
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sortedLosses: [ComponentPressureLoss] {
|
||||||
|
componentPressureLosses.sorted {
|
||||||
|
$0.value > $1.value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some HTML {
|
var body: some HTML {
|
||||||
div(
|
div(.class("space-y-4")) {
|
||||||
.class(
|
|
||||||
"""
|
|
||||||
border border-gray-200 rounded-lg shadow-lg space-y-4 p-4
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Row {
|
Row {
|
||||||
h1(.class("text-2xl font-bold")) { "Component Pressure Losses" }
|
h1(.class("text-2xl font-bold")) { "Component Pressure Losses" }
|
||||||
PlusButton()
|
PlusButton()
|
||||||
.attributes(
|
.attributes(
|
||||||
.hx.get(
|
.class("btn-primary text-2xl me-2"),
|
||||||
route: .project(
|
.showModal(id: ComponentLossForm.id())
|
||||||
.detail(projectID, .frictionRate(.form(.componentPressureLoss, dismiss: false))))
|
|
||||||
),
|
|
||||||
.hx.target("#componentLossForm"),
|
|
||||||
.hx.swap(.outerHTML)
|
|
||||||
)
|
)
|
||||||
|
.tooltip("Add component loss")
|
||||||
}
|
}
|
||||||
|
.attributes(.class("px-4"))
|
||||||
|
|
||||||
for row in componentPressureLosses {
|
table(.class("table table-zebra")) {
|
||||||
Row {
|
thead {
|
||||||
Label { row.name }
|
tr(.class("text-xl font-bold")) {
|
||||||
Number(row.value)
|
th { "Name" }
|
||||||
|
th { "Value" }
|
||||||
|
th(.class("min-w-[200px]")) {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
for row in sortedLosses {
|
||||||
|
TableRow(row: row)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.attributes(.class("border-b border-gray-200"))
|
|
||||||
}
|
|
||||||
|
|
||||||
Row {
|
|
||||||
Label { "Total" }
|
|
||||||
Number(total)
|
|
||||||
.attributes(.class("text-xl font-bold"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ComponentLossForm(dismiss: true, projectID: projectID)
|
ComponentLossForm(dismiss: true, projectID: projectID, componentLoss: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct TableRow: HTML, Sendable {
|
||||||
|
let row: ComponentPressureLoss
|
||||||
|
|
||||||
|
var body: some HTML<HTMLTag.tr> {
|
||||||
|
tr(.class("text-lg")) {
|
||||||
|
td { row.name }
|
||||||
|
td { Number(row.value) }
|
||||||
|
td {
|
||||||
|
div(.class("flex join items-end justify-end mx-auto")) {
|
||||||
|
Tooltip("Delete", position: .bottom) {
|
||||||
|
TrashButton()
|
||||||
|
.attributes(
|
||||||
|
.class("join-item btn-ghost"),
|
||||||
|
.hx.delete(
|
||||||
|
route: .project(
|
||||||
|
.detail(row.projectID, .componentLoss(.delete(row.id)))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
.hx.target("body"),
|
||||||
|
.hx.swap(.outerHTML),
|
||||||
|
.hx.confirm("Are your sure?")
|
||||||
|
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Tooltip("Edit", position: .bottom) {
|
||||||
|
EditButton()
|
||||||
|
.attributes(
|
||||||
|
.class("join-item btn-ghost"),
|
||||||
|
.showModal(id: ComponentLossForm.id(row))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ComponentLossForm(dismiss: true, projectID: row.projectID, componentLoss: row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
55
Sources/ViewController/Views/DuctSizing/DuctSizingView.swift
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import Elementary
|
||||||
|
import ElementaryHTMX
|
||||||
|
import ManualDCore
|
||||||
|
import Styleguide
|
||||||
|
|
||||||
|
struct DuctSizingView: HTML, Sendable {
|
||||||
|
|
||||||
|
@Environment(ProjectViewValue.$projectID) var projectID
|
||||||
|
|
||||||
|
let rooms: [DuctSizing.RoomContainer]
|
||||||
|
let trunks: [DuctSizing.TrunkContainer]
|
||||||
|
|
||||||
|
var body: some HTML {
|
||||||
|
div(.class("space-y-4")) {
|
||||||
|
PageTitleRow {
|
||||||
|
div {
|
||||||
|
PageTitle("Duct Sizes")
|
||||||
|
|
||||||
|
Alert(
|
||||||
|
"""
|
||||||
|
Must complete all the previous sections to display duct sizing calculations.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
.hidden(when: rooms.count > 0)
|
||||||
|
.attributes(.class("text-error font-bold italic mt-4"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rooms.count != 0 {
|
||||||
|
RoomsTable(rooms: rooms)
|
||||||
|
|
||||||
|
PageTitleRow {
|
||||||
|
PageTitle {
|
||||||
|
"Trunk / Runout Sizes"
|
||||||
|
}
|
||||||
|
|
||||||
|
PlusButton()
|
||||||
|
.attributes(
|
||||||
|
.class("btn-primary"),
|
||||||
|
.showModal(id: TrunkSizeForm.id())
|
||||||
|
)
|
||||||
|
.tooltip("Add trunk / runout")
|
||||||
|
}
|
||||||
|
|
||||||
|
if trunks.count > 0 {
|
||||||
|
TrunkTable(trunks: trunks, rooms: rooms)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
TrunkSizeForm(rooms: rooms, dismiss: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import Elementary
|
||||||
|
import ElementaryHTMX
|
||||||
|
import ManualDCore
|
||||||
|
import Styleguide
|
||||||
|
|
||||||
|
struct RectangularSizeForm: HTML, Sendable {
|
||||||
|
|
||||||
|
static func id(_ room: DuctSizing.RoomContainer) -> String {
|
||||||
|
let base = "rectangularSize"
|
||||||
|
return "\(base)_\(room.roomName.idString)"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Environment(ProjectViewValue.$projectID) var projectID
|
||||||
|
|
||||||
|
let id: String
|
||||||
|
let room: DuctSizing.RoomContainer
|
||||||
|
let dismiss: Bool
|
||||||
|
|
||||||
|
init(
|
||||||
|
id: String? = nil,
|
||||||
|
room: DuctSizing.RoomContainer,
|
||||||
|
dismiss: Bool = true
|
||||||
|
) {
|
||||||
|
self.id = Self.id(room)
|
||||||
|
self.room = room
|
||||||
|
self.dismiss = dismiss
|
||||||
|
}
|
||||||
|
|
||||||
|
var route: String {
|
||||||
|
SiteRoute.View.router.path(
|
||||||
|
for: .project(.detail(projectID, .ductSizing(.index)))
|
||||||
|
)
|
||||||
|
.appendingPath("room")
|
||||||
|
.appendingPath(room.roomID)
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
var rowID: String {
|
||||||
|
DuctSizingView.RoomRow.id(room)
|
||||||
|
}
|
||||||
|
|
||||||
|
var height: Int? {
|
||||||
|
room.rectangularSize?.height
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some HTML<HTMLTag.dialog> {
|
||||||
|
ModalForm(id: id, dismiss: dismiss) {
|
||||||
|
h1(.class("text-lg pb-6")) { "Rectangular Size" }
|
||||||
|
|
||||||
|
form(
|
||||||
|
.class("space-y-4"),
|
||||||
|
.hx.post(route),
|
||||||
|
.hx.target("#\(rowID)"),
|
||||||
|
.hx.swap(.outerHTML)
|
||||||
|
) {
|
||||||
|
input(.class("hidden"), .name("register"), .value(room.roomRegister))
|
||||||
|
input(.class("hidden"), .name("id"), .value(room.rectangularSize?.id))
|
||||||
|
|
||||||
|
LabeledInput(
|
||||||
|
"Height",
|
||||||
|
.name("height"),
|
||||||
|
.type(.number),
|
||||||
|
.value(height),
|
||||||
|
.placeholder("8"),
|
||||||
|
.min("0"),
|
||||||
|
.required,
|
||||||
|
.autofocus
|
||||||
|
)
|
||||||
|
|
||||||
|
SubmitButton()
|
||||||
|
.attributes(.class("btn-block"))
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
167
Sources/ViewController/Views/DuctSizing/RoomsTable.swift
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import Elementary
|
||||||
|
import ElementaryHTMX
|
||||||
|
import Foundation
|
||||||
|
import ManualDCore
|
||||||
|
import Styleguide
|
||||||
|
|
||||||
|
extension DuctSizingView {
|
||||||
|
// TODO: Remove register ID.
|
||||||
|
struct RoomsTable: HTML, Sendable {
|
||||||
|
@Environment(ProjectViewValue.$projectID) var projectID
|
||||||
|
|
||||||
|
let rooms: [DuctSizing.RoomContainer]
|
||||||
|
|
||||||
|
var body: some HTML<HTMLTag.table> {
|
||||||
|
|
||||||
|
table(.class("table table-zebra text-lg")) {
|
||||||
|
thead {
|
||||||
|
tr(.class("text-lg")) {
|
||||||
|
th { "Name" }
|
||||||
|
th { "BTU" }
|
||||||
|
th { "CFM" }
|
||||||
|
th { "Velocity" }
|
||||||
|
th(.class("w-[330px]")) { "Size" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
for room in rooms {
|
||||||
|
RoomRow(room: room)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RoomRow: HTML, Sendable {
|
||||||
|
|
||||||
|
static func id(_ room: DuctSizing.RoomContainer) -> String {
|
||||||
|
"roomRow_\(room.roomName.idString)"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Environment(ProjectViewValue.$projectID) var projectID
|
||||||
|
|
||||||
|
let room: DuctSizing.RoomContainer
|
||||||
|
let formID = UUID().idString
|
||||||
|
|
||||||
|
var deleteRoute: String {
|
||||||
|
guard let id = room.rectangularSize?.id else { return "" }
|
||||||
|
|
||||||
|
return SiteRoute.View.router.path(
|
||||||
|
for: .project(
|
||||||
|
.detail(
|
||||||
|
projectID,
|
||||||
|
.ductSizing(
|
||||||
|
.deleteRectangularSize(
|
||||||
|
room.roomID,
|
||||||
|
.init(rectangularSizeID: id, register: room.roomRegister)
|
||||||
|
))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rowID: String { Self.id(room) }
|
||||||
|
|
||||||
|
var body: some HTML<HTMLTag.tr> {
|
||||||
|
tr(.class("text-lg"), .id(rowID)) {
|
||||||
|
td { room.roomName }
|
||||||
|
td {
|
||||||
|
div(.class("flex flex-wrap grid grid-cols-2 gap-2")) {
|
||||||
|
span(.class("label")) { "Heating" }
|
||||||
|
Number(room.heatingLoad, digits: 0)
|
||||||
|
|
||||||
|
span(.class("label")) { "Cooling" }
|
||||||
|
Number(room.coolingLoad, digits: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
div(.class("flex flex-wrap grid grid-cols-2 gap-2")) {
|
||||||
|
|
||||||
|
span(.class("label")) { "Design" }
|
||||||
|
div(.class("flex justify-center")) {
|
||||||
|
Badge(number: room.designCFM.value, digits: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
span(.class("label")) { "Heating" }
|
||||||
|
div(.class("flex justify-center")) {
|
||||||
|
Number(room.heatingCFM, digits: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
span(.class("label")) { "Cooling" }
|
||||||
|
div(.class("flex justify-center")) {
|
||||||
|
Number(room.coolingCFM, digits: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
td { Number(room.velocity) }
|
||||||
|
|
||||||
|
td {
|
||||||
|
div(.class("grid grid-cols-3 gap-2 w-[330px]")) {
|
||||||
|
|
||||||
|
div(.class("label")) { "Calculated" }
|
||||||
|
div(.class("flex justify-center")) {
|
||||||
|
Badge(number: room.roundSize, digits: 2)
|
||||||
|
}
|
||||||
|
div {}
|
||||||
|
|
||||||
|
div(.class("label")) { "Final" }
|
||||||
|
div(.class("flex justify-center")) {
|
||||||
|
Badge(number: room.finalSize)
|
||||||
|
.attributes(.class("badge-secondary"))
|
||||||
|
}
|
||||||
|
div {}
|
||||||
|
|
||||||
|
div(.class("label")) { "Flex" }
|
||||||
|
div(.class("flex justify-center")) {
|
||||||
|
Badge(number: room.flexSize)
|
||||||
|
.attributes(.class("badge-primary"))
|
||||||
|
}
|
||||||
|
div {}
|
||||||
|
|
||||||
|
div(.class("label")) { "Rectangular" }
|
||||||
|
div(.class("flex justify-center")) {
|
||||||
|
if let width = room.rectangularWidth,
|
||||||
|
let height = room.rectangularSize?.height
|
||||||
|
{
|
||||||
|
Badge {
|
||||||
|
span { "\(width) x \(height)" }
|
||||||
|
}
|
||||||
|
.attributes(.class("badge-info"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div(.class("flex justify-end")) {
|
||||||
|
div(.class("join")) {
|
||||||
|
if room.rectangularSize != nil {
|
||||||
|
Tooltip("Delete Size", position: .bottom) {
|
||||||
|
TrashButton()
|
||||||
|
.attributes(.class("join-item btn-ghost"))
|
||||||
|
.attributes(
|
||||||
|
.hx.delete(deleteRoute),
|
||||||
|
.hx.target("#\(rowID)"),
|
||||||
|
.hx.swap(.outerHTML),
|
||||||
|
when: room.rectangularSize != nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Tooltip("Edit Size", position: .bottom) {
|
||||||
|
EditButton()
|
||||||
|
.attributes(
|
||||||
|
.class("join-item btn-ghost"),
|
||||||
|
.showModal(id: RectangularSizeForm.id(room))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RectangularSizeForm(room: room)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
131
Sources/ViewController/Views/DuctSizing/TrunkSizeForm.swift
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import Elementary
|
||||||
|
import ElementaryHTMX
|
||||||
|
import ManualDCore
|
||||||
|
import Styleguide
|
||||||
|
|
||||||
|
struct TrunkSizeForm: HTML, Sendable {
|
||||||
|
|
||||||
|
static func id(_ trunk: DuctSizing.TrunkContainer? = nil) -> String {
|
||||||
|
let base = "trunkSizeForm"
|
||||||
|
guard let trunk else { return base }
|
||||||
|
return "\(base)_\(trunk.id.idString)"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Environment(ProjectViewValue.$projectID) var projectID
|
||||||
|
|
||||||
|
let container: DuctSizing.TrunkContainer?
|
||||||
|
let rooms: [DuctSizing.RoomContainer]
|
||||||
|
let dismiss: Bool
|
||||||
|
|
||||||
|
var trunk: DuctSizing.TrunkSize? {
|
||||||
|
container?.trunk
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
trunk: DuctSizing.TrunkContainer? = nil,
|
||||||
|
rooms: [DuctSizing.RoomContainer],
|
||||||
|
dismiss: Bool = true
|
||||||
|
) {
|
||||||
|
self.container = trunk
|
||||||
|
self.rooms = rooms
|
||||||
|
self.dismiss = dismiss
|
||||||
|
}
|
||||||
|
|
||||||
|
var route: String {
|
||||||
|
SiteRoute.View.router
|
||||||
|
.path(for: .project(.detail(projectID, .ductSizing(.index))))
|
||||||
|
.appendingPath(SiteRoute.View.ProjectRoute.DuctSizingRoute.TrunkRoute.rootPath)
|
||||||
|
.appendingPath(trunk?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some HTML {
|
||||||
|
ModalForm(id: Self.id(container), dismiss: dismiss) {
|
||||||
|
h1(.class("text-lg font-bold mb-4")) { "Trunk / Runout Size" }
|
||||||
|
form(
|
||||||
|
.class("space-y-4"),
|
||||||
|
trunk == nil
|
||||||
|
? .hx.post(route)
|
||||||
|
: .hx.patch(route),
|
||||||
|
.hx.target("body"),
|
||||||
|
.hx.swap(.outerHTML)
|
||||||
|
) {
|
||||||
|
|
||||||
|
input(.class("hidden"), .name("projectID"), .value(projectID))
|
||||||
|
|
||||||
|
div(.class("grid grid-cols-1 md:grid-cols-2 gap-4")) {
|
||||||
|
label(.class("select w-full")) {
|
||||||
|
span(.class("label")) { "Type" }
|
||||||
|
select(.name("type")) {
|
||||||
|
for type in DuctSizing.TrunkSize.TrunkType.allCases {
|
||||||
|
option(.value(type.rawValue)) { type.rawValue.capitalized }
|
||||||
|
.attributes(.selected, when: trunk?.type == type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LabeledInput(
|
||||||
|
"Height",
|
||||||
|
.type(.text),
|
||||||
|
.name("height"),
|
||||||
|
.value(trunk?.height),
|
||||||
|
.placeholder("8 (Optional)"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
LabeledInput(
|
||||||
|
"Name",
|
||||||
|
.type(.text),
|
||||||
|
.name("name"),
|
||||||
|
.value(trunk?.name),
|
||||||
|
.placeholder("Trunk-1 (Optional)")
|
||||||
|
)
|
||||||
|
|
||||||
|
div {
|
||||||
|
h2(.class("label font-bold col-span-3 mb-6")) { "Associated Supply Runs" }
|
||||||
|
div(
|
||||||
|
.class(
|
||||||
|
"""
|
||||||
|
grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 justify-center items-center gap-4
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
for room in rooms {
|
||||||
|
div(.class("block grow")) {
|
||||||
|
div(.class("grid grid-cols-1 space-y-1")) {
|
||||||
|
div(.class("flex justify-center")) {
|
||||||
|
p(.class("label")) { room.roomName }
|
||||||
|
}
|
||||||
|
div(.class("flex justify-center")) {
|
||||||
|
input(
|
||||||
|
.class("checkbox"),
|
||||||
|
.type(.checkbox),
|
||||||
|
.name("rooms"),
|
||||||
|
.value("\(room.roomID)_\(room.roomRegister)")
|
||||||
|
)
|
||||||
|
.attributes(
|
||||||
|
.checked,
|
||||||
|
when: trunk == nil ? false : trunk!.rooms.hasRoom(room)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SubmitButton()
|
||||||
|
.attributes(.class("btn-block mt-6"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension Array where Element == DuctSizing.TrunkSize.RoomProxy {
|
||||||
|
func hasRoom(_ room: DuctSizing.RoomContainer) -> Bool {
|
||||||
|
first {
|
||||||
|
$0.id == room.roomID
|
||||||
|
&& $0.registers.contains(room.roomRegister)
|
||||||
|
} != nil
|
||||||
|
}
|
||||||
|
}
|
||||||
152
Sources/ViewController/Views/DuctSizing/TrunksTable.swift
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import Elementary
|
||||||
|
import ElementaryHTMX
|
||||||
|
import ManualDCore
|
||||||
|
import Styleguide
|
||||||
|
|
||||||
|
extension DuctSizingView {
|
||||||
|
|
||||||
|
struct TrunkTable: HTML, Sendable {
|
||||||
|
|
||||||
|
let trunks: [DuctSizing.TrunkContainer]
|
||||||
|
let rooms: [DuctSizing.RoomContainer]
|
||||||
|
|
||||||
|
private var sortedTrunks: [DuctSizing.TrunkContainer] {
|
||||||
|
trunks
|
||||||
|
.sorted(by: { $0.designCFM.value > $1.designCFM.value })
|
||||||
|
.sorted(by: { $0.type.rawValue > $1.type.rawValue })
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some HTML {
|
||||||
|
table(.class("table table-zebra text-lg")) {
|
||||||
|
thead {
|
||||||
|
tr(.class("text-lg")) {
|
||||||
|
th { "Name / Type" }
|
||||||
|
th { "Associated Supplies" }
|
||||||
|
th { "Dsn CFM" }
|
||||||
|
th { "Velocity" }
|
||||||
|
th(.class("w-[330px]")) { "Size" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
for trunk in sortedTrunks {
|
||||||
|
TrunkRow(trunk: trunk, rooms: rooms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TrunkRow: HTML, Sendable {
|
||||||
|
|
||||||
|
@Environment(ProjectViewValue.$projectID) var projectID
|
||||||
|
|
||||||
|
let trunk: DuctSizing.TrunkContainer
|
||||||
|
let rooms: [DuctSizing.RoomContainer]
|
||||||
|
|
||||||
|
var body: some HTML<HTMLTag.tr> {
|
||||||
|
tr {
|
||||||
|
td {
|
||||||
|
div(.class("grid grid-cols-1 space-y-2")) {
|
||||||
|
if let name = trunk.name {
|
||||||
|
p(.class("w-fit")) { name }
|
||||||
|
}
|
||||||
|
|
||||||
|
Badge {
|
||||||
|
trunk.trunk.type.rawValue
|
||||||
|
}
|
||||||
|
.attributes(.class("badge-info"), when: trunk.type == .supply)
|
||||||
|
.attributes(.class("badge-error"), when: trunk.type == .return)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
div(.class("flex flex-wrap space-x-2 space-y-2")) {
|
||||||
|
for id in registerIDS {
|
||||||
|
Badge { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
Number(trunk.designCFM.value, digits: 0)
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
Number(trunk.velocity)
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
div(.class("grid grid-cols-3 gap-2 w-[330px]")) {
|
||||||
|
div(.class("label")) { "Calculated" }
|
||||||
|
div(.class("flex justify-center")) {
|
||||||
|
Badge(number: trunk.roundSize, digits: 1)
|
||||||
|
}
|
||||||
|
div {}
|
||||||
|
|
||||||
|
div(.class("label")) { "Final" }
|
||||||
|
div(.class("flex justify-center")) {
|
||||||
|
Badge(number: trunk.finalSize)
|
||||||
|
.attributes(.class("badge-secondary"))
|
||||||
|
}
|
||||||
|
div {}
|
||||||
|
|
||||||
|
div(.class("label")) { "Flex" }
|
||||||
|
div(.class("flex justify-center")) {
|
||||||
|
Badge(number: trunk.flexSize)
|
||||||
|
.attributes(.class("badge-primary"))
|
||||||
|
}
|
||||||
|
div {}
|
||||||
|
|
||||||
|
div(.class("label")) { "Rectangular" }
|
||||||
|
div(.class("flex justify-center")) {
|
||||||
|
if let width = trunk.width,
|
||||||
|
let height = trunk.ductSize.height
|
||||||
|
{
|
||||||
|
Badge {
|
||||||
|
span { "\(width) x \(height)" }
|
||||||
|
}
|
||||||
|
.attributes(.class("badge-info"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div(.class("flex justify-end items-end")) {
|
||||||
|
div(.class("join")) {
|
||||||
|
if trunk.width != nil {
|
||||||
|
TrashButton()
|
||||||
|
.attributes(.class("join-item btn-ghost"))
|
||||||
|
.attributes(
|
||||||
|
.hx.delete(route: deleteRoute),
|
||||||
|
.hx.target("closest tr"),
|
||||||
|
.hx.swap(.outerHTML)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
EditButton()
|
||||||
|
.attributes(
|
||||||
|
.class("join-item btn-ghost"),
|
||||||
|
.showModal(id: TrunkSizeForm.id(trunk))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TrunkSizeForm(trunk: trunk, rooms: rooms, dismiss: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var deleteRoute: SiteRoute.View {
|
||||||
|
.project(.detail(projectID, .ductSizing(.trunk(.delete(trunk.id)))))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var registerIDS: [String] {
|
||||||
|
trunk.rooms.reduce(into: []) { array, room in
|
||||||
|
array = room.registers.reduce(into: array) { array, register in
|
||||||
|
if let room =
|
||||||
|
rooms
|
||||||
|
.first(where: { $0.roomID == room.id && $0.roomRegister == register })
|
||||||
|
{
|
||||||
|
array.append(room.roomName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sorted()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,76 +3,223 @@ import ElementaryHTMX
|
|||||||
import ManualDCore
|
import ManualDCore
|
||||||
import Styleguide
|
import Styleguide
|
||||||
|
|
||||||
// TODO: May need a multi-step form were the the effective length type is
|
// TODO: Add back buttons / capability??
|
||||||
// determined before groups selections are made in order to use the
|
|
||||||
// appropriate select field values when the type is supply vs. return.
|
|
||||||
// Currently when the select field is changed it doesn't change the group
|
|
||||||
// I can get it to add a new one.
|
|
||||||
|
|
||||||
struct EffectiveLengthForm: HTML, Sendable {
|
struct EffectiveLengthForm: HTML, Sendable {
|
||||||
|
|
||||||
|
static func id(_ equivalentLength: EffectiveLength?) -> String {
|
||||||
|
let base = "equivalentLengthForm"
|
||||||
|
guard let equivalentLength else { return base }
|
||||||
|
return "\(base)_\(equivalentLength.id.uuidString.replacing("-", with: ""))"
|
||||||
|
}
|
||||||
|
|
||||||
|
let projectID: Project.ID
|
||||||
let dismiss: Bool
|
let dismiss: Bool
|
||||||
let type: EffectiveLength.EffectiveLengthType
|
let type: EffectiveLength.EffectiveLengthType
|
||||||
|
let effectiveLength: EffectiveLength?
|
||||||
|
|
||||||
init(dismiss: Bool, type: EffectiveLength.EffectiveLengthType = .supply) {
|
var id: String { Self.id(effectiveLength) }
|
||||||
|
|
||||||
|
init(
|
||||||
|
projectID: Project.ID,
|
||||||
|
dismiss: Bool,
|
||||||
|
type: EffectiveLength.EffectiveLengthType = .supply
|
||||||
|
) {
|
||||||
|
self.projectID = projectID
|
||||||
self.dismiss = dismiss
|
self.dismiss = dismiss
|
||||||
self.type = type
|
self.type = type
|
||||||
|
self.effectiveLength = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
effectiveLength: EffectiveLength
|
||||||
|
) {
|
||||||
|
self.dismiss = true
|
||||||
|
self.type = effectiveLength.type
|
||||||
|
self.projectID = effectiveLength.projectID
|
||||||
|
self.effectiveLength = effectiveLength
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some HTML {
|
var body: some HTML {
|
||||||
ModalForm(id: "effectiveLengthForm", dismiss: dismiss) {
|
ModalForm(
|
||||||
|
id: id,
|
||||||
|
dismiss: dismiss
|
||||||
|
) {
|
||||||
h1(.class("text-2xl font-bold")) { "Effective Length" }
|
h1(.class("text-2xl font-bold")) { "Effective Length" }
|
||||||
form(.class("space-y-4 p-4")) {
|
div(.id("formStep_\(id)"), .class("mt-4")) {
|
||||||
div {
|
StepOne(projectID: projectID, effectiveLength: effectiveLength)
|
||||||
label(.for("name")) { "Name" }
|
}
|
||||||
Input(id: "name", placeholder: "Name")
|
}
|
||||||
.attributes(.type(.text), .required, .autofocus)
|
}
|
||||||
|
|
||||||
|
struct StepOne: HTML, Sendable {
|
||||||
|
let projectID: Project.ID
|
||||||
|
let effectiveLength: EffectiveLength?
|
||||||
|
|
||||||
|
var route: String {
|
||||||
|
let baseRoute = SiteRoute.View.router.path(
|
||||||
|
for: .project(.detail(projectID, .equivalentLength(.index)))
|
||||||
|
)
|
||||||
|
return "\(baseRoute)/stepOne"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some HTML {
|
||||||
|
form(
|
||||||
|
.class("space-y-4"),
|
||||||
|
.hx.post(route),
|
||||||
|
.hx.target("#formStep_\(EffectiveLengthForm.id(effectiveLength))"),
|
||||||
|
.hx.swap(.innerHTML)
|
||||||
|
) {
|
||||||
|
if let id = effectiveLength?.id {
|
||||||
|
input(.class("hidden"), .name("id"), .value("\(id)"))
|
||||||
}
|
}
|
||||||
div {
|
|
||||||
label(.for("type")) { "Type" }
|
LabeledInput(
|
||||||
GroupTypeSelect(selected: type)
|
"Name",
|
||||||
.attributes(.class("w-full border rounded-md"))
|
.name("name"),
|
||||||
|
.type(.text),
|
||||||
|
.value(effectiveLength?.name),
|
||||||
|
.required,
|
||||||
|
.autofocus
|
||||||
|
)
|
||||||
|
|
||||||
|
GroupTypeSelect(projectID: projectID, selected: effectiveLength?.type ?? .supply)
|
||||||
|
|
||||||
|
Row {
|
||||||
|
div {}
|
||||||
|
SubmitButton(title: "Next")
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StepTwo: HTML, Sendable {
|
||||||
|
let projectID: Project.ID
|
||||||
|
let stepOne: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepOne
|
||||||
|
let effectiveLength: EffectiveLength?
|
||||||
|
|
||||||
|
var route: String {
|
||||||
|
let baseRoute = SiteRoute.View.router.path(
|
||||||
|
for: .project(.detail(projectID, .equivalentLength(.index)))
|
||||||
|
)
|
||||||
|
return "\(baseRoute)/stepTwo"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some HTML {
|
||||||
|
form(
|
||||||
|
.class("space-y-4"),
|
||||||
|
.hx.post(route),
|
||||||
|
.hx.target("#formStep_\(EffectiveLengthForm.id(effectiveLength))"),
|
||||||
|
.hx.swap(.innerHTML)
|
||||||
|
) {
|
||||||
|
if let id = effectiveLength?.id {
|
||||||
|
input(.class("hidden"), .name("id"), .value("\(id)"))
|
||||||
|
}
|
||||||
|
input(.class("hidden"), .name("name"), .value(stepOne.name))
|
||||||
|
input(.class("hidden"), .name("type"), .value(stepOne.type.rawValue))
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
Label { "Straigth Lengths" }
|
Label { "Straigth Lengths" }
|
||||||
button(
|
button(
|
||||||
.type(.button),
|
.type(.button),
|
||||||
.hx.get(route: .effectiveLength(.field(.straightLength))),
|
.hx.get(
|
||||||
|
route: .project(.detail(projectID, .equivalentLength(.field(.straightLength))))
|
||||||
|
),
|
||||||
.hx.target("#straightLengths"),
|
.hx.target("#straightLengths"),
|
||||||
.hx.swap(.beforeEnd)
|
.hx.swap(.beforeEnd)
|
||||||
) {
|
) {
|
||||||
SVG(.circlePlus)
|
SVG(.circlePlus)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
div(.id("straightLengths")) {
|
div(.id("straightLengths"), .class("space-y-4")) {
|
||||||
StraightLengthField()
|
if let effectiveLength {
|
||||||
|
for length in effectiveLength.straightLengths {
|
||||||
|
StraightLengthField(value: length)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
StraightLengthField()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
div {}
|
||||||
|
SubmitButton(title: "Next")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StepThree: HTML, Sendable {
|
||||||
|
let projectID: Project.ID
|
||||||
|
let effectiveLength: EffectiveLength?
|
||||||
|
let stepTwo: SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepTwo
|
||||||
|
|
||||||
|
var route: String {
|
||||||
|
let baseRoute = SiteRoute.View.router.path(
|
||||||
|
for: .project(.detail(projectID, .equivalentLength(.index)))
|
||||||
|
)
|
||||||
|
|
||||||
|
if let effectiveLength {
|
||||||
|
return baseRoute.appendingPath(effectiveLength.id)
|
||||||
|
} else {
|
||||||
|
return baseRoute.appendingPath("stepThree")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some HTML {
|
||||||
|
form(
|
||||||
|
.class("space-y-4"),
|
||||||
|
effectiveLength == nil
|
||||||
|
? .hx.post(route)
|
||||||
|
: .hx.patch(route),
|
||||||
|
.hx.target("body"),
|
||||||
|
.hx.swap(.outerHTML)
|
||||||
|
) {
|
||||||
|
if let id = effectiveLength?.id {
|
||||||
|
input(.class("hidden"), .name("id"), .value("\(id)"))
|
||||||
|
}
|
||||||
|
input(.class("hidden"), .name("name"), .value(stepTwo.name))
|
||||||
|
input(.class("hidden"), .name("type"), .value(stepTwo.type.rawValue))
|
||||||
|
for length in stepTwo.straightLengths {
|
||||||
|
input(.class("hidden"), .name("straightLengths"), .value("\(length)"))
|
||||||
}
|
}
|
||||||
|
|
||||||
Row {
|
Row {
|
||||||
Label { "Groups" }
|
Label { "Groups" }
|
||||||
button(
|
button(
|
||||||
.type(.button),
|
.type(.button),
|
||||||
.hx.get(route: .effectiveLength(.field(.group, style: type))),
|
.hx.get(
|
||||||
|
route: .project(
|
||||||
|
.detail(projectID, .equivalentLength(.field(.group, style: stepTwo.type))))
|
||||||
|
),
|
||||||
.hx.target("#groups"),
|
.hx.target("#groups"),
|
||||||
.hx.swap(.beforeEnd)
|
.hx.swap(.beforeEnd)
|
||||||
) {
|
) {
|
||||||
SVG(.circlePlus)
|
SVG(.circlePlus)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
div(.id("groups"), .class("space-y-4")) {
|
|
||||||
GroupField(style: type)
|
a(
|
||||||
|
.href("/files/ManD.Groups.pdf"),
|
||||||
|
.target(.blank),
|
||||||
|
.class("btn btn-link")
|
||||||
|
) {
|
||||||
|
"Click here for Manual-D groups reference."
|
||||||
}
|
}
|
||||||
|
|
||||||
|
div(.id("groups"), .class("space-y-4")) {
|
||||||
|
if let effectiveLength {
|
||||||
|
for group in effectiveLength.groups {
|
||||||
|
GroupField(style: effectiveLength.type, group: group)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
GroupField(style: stepTwo.type)
|
||||||
|
}
|
||||||
|
}
|
||||||
Row {
|
Row {
|
||||||
div {}
|
div {}
|
||||||
div(.class("space-x-4")) {
|
SubmitButton()
|
||||||
CancelButton()
|
|
||||||
.attributes(
|
|
||||||
.hx.get(route: .effectiveLength(.form(dismiss: true))),
|
|
||||||
.hx.target("#effectiveLengthForm"),
|
|
||||||
.hx.swap(.outerHTML)
|
|
||||||
)
|
|
||||||
SubmitButton()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,33 +234,74 @@ struct StraightLengthField: HTML, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some HTML<HTMLTag.div> {
|
var body: some HTML<HTMLTag.div> {
|
||||||
div(.class("pb-4")) {
|
Row {
|
||||||
Input(
|
LabeledInput(
|
||||||
name: "straightLengths[]",
|
"Length",
|
||||||
placeholder: "Length"
|
.name("straightLengths"),
|
||||||
|
.type(.number),
|
||||||
|
.value(value),
|
||||||
|
.placeholder("10"),
|
||||||
|
.min("0"),
|
||||||
|
.autofocus,
|
||||||
|
.required
|
||||||
)
|
)
|
||||||
.attributes(.type(.number), .min("0"))
|
TrashButton()
|
||||||
|
.attributes(.data("remove", value: "true"))
|
||||||
}
|
}
|
||||||
|
.attributes(.hx.ext("remove"), .class("space-x-4"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct GroupField: HTML, Sendable {
|
struct GroupField: HTML, Sendable {
|
||||||
|
|
||||||
let style: EffectiveLength.EffectiveLengthType
|
let style: EffectiveLength.EffectiveLengthType
|
||||||
|
let group: EffectiveLength.Group?
|
||||||
|
|
||||||
|
init(style: EffectiveLength.EffectiveLengthType, group: EffectiveLength.Group? = nil) {
|
||||||
|
self.style = style
|
||||||
|
self.group = group
|
||||||
|
}
|
||||||
|
|
||||||
var body: some HTML {
|
var body: some HTML {
|
||||||
Row {
|
div(.class("grid grid-cols-3 gap-2 p-2 border rounded-lg shadow-sm")) {
|
||||||
// Input(name: "group[][group]", placeholder: "Group")
|
|
||||||
// .attributes(.type(.number), .min("0"))
|
|
||||||
GroupSelect(style: style)
|
GroupSelect(style: style)
|
||||||
Input(name: "group[][letter]", placeholder: "Letter")
|
|
||||||
.attributes(.type(.text))
|
LabeledInput(
|
||||||
Input(name: "group[][length]", placeholder: "Length")
|
"Letter",
|
||||||
.attributes(.type(.number), .min("0"))
|
.name("group[letter]"),
|
||||||
Input(name: "group[][quantity]", placeholder: "Quantity")
|
.type(.text),
|
||||||
.attributes(.type(.number), .min("1"), .value("1"))
|
.value(group?.letter),
|
||||||
|
.placeholder("a"),
|
||||||
|
.required
|
||||||
|
)
|
||||||
|
|
||||||
|
LabeledInput(
|
||||||
|
"Length",
|
||||||
|
.name("group[length]"),
|
||||||
|
.type(.number),
|
||||||
|
.value(group?.value),
|
||||||
|
.placeholder("10"),
|
||||||
|
.min("0"),
|
||||||
|
.required
|
||||||
|
)
|
||||||
|
|
||||||
|
LabeledInput(
|
||||||
|
"Quantity",
|
||||||
|
.name("group[quantity]"),
|
||||||
|
.type(.number),
|
||||||
|
.value(group?.quantity ?? 1),
|
||||||
|
.min("1"),
|
||||||
|
.required
|
||||||
|
)
|
||||||
|
.attributes(.class("col-span-2"))
|
||||||
|
|
||||||
|
TrashButton()
|
||||||
|
.attributes(
|
||||||
|
.data("remove", value: "true"),
|
||||||
|
.class("me-2 btn-block")
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.attributes(.class("space-x-2"))
|
.attributes(.class("space-x-2"), .hx.ext("remove"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,11 +310,15 @@ struct GroupSelect: HTML, Sendable {
|
|||||||
let style: EffectiveLength.EffectiveLengthType
|
let style: EffectiveLength.EffectiveLengthType
|
||||||
|
|
||||||
var body: some HTML {
|
var body: some HTML {
|
||||||
select(
|
label(.class("select")) {
|
||||||
.name("group")
|
span(.class("label")) { "Group" }
|
||||||
) {
|
select(
|
||||||
for value in style.selectOptions {
|
.name("group[group]"),
|
||||||
option(.value("\(value)")) { "\(value)" }
|
.autofocus
|
||||||
|
) {
|
||||||
|
for value in style.selectOptions {
|
||||||
|
option(.value("\(value)")) { "\(value)" }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,19 +327,19 @@ struct GroupSelect: HTML, Sendable {
|
|||||||
|
|
||||||
struct GroupTypeSelect: HTML, Sendable {
|
struct GroupTypeSelect: HTML, Sendable {
|
||||||
|
|
||||||
var selected: EffectiveLength.EffectiveLengthType
|
let projectID: Project.ID
|
||||||
|
let selected: EffectiveLength.EffectiveLengthType
|
||||||
|
|
||||||
var body: some HTML<HTMLTag.select> {
|
var body: some HTML<HTMLTag.label> {
|
||||||
select(.name("type"), .id("type")) {
|
label(.class("select w-full")) {
|
||||||
for value in EffectiveLength.EffectiveLengthType.allCases {
|
span(.class("label")) { "Type" }
|
||||||
option(
|
select(.name("type"), .id("type")) {
|
||||||
.value("\(value.rawValue)"),
|
for value in EffectiveLength.EffectiveLengthType.allCases {
|
||||||
.hx.get(route: .effectiveLength(.field(.group, style: value))),
|
option(
|
||||||
.hx.target("#groups"),
|
.value("\(value.rawValue)"),
|
||||||
.hx.swap(.beforeEnd),
|
) { value.title }
|
||||||
.hx.trigger(.event(.change).from("#type"))
|
.attributes(.selected, when: value == selected)
|
||||||
) { value.title }
|
}
|
||||||
.attributes(.selected, when: value == selected)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,7 +354,7 @@ extension EffectiveLength.EffectiveLengthType {
|
|||||||
case .return:
|
case .return:
|
||||||
return [5, 6, 7, 8, 10, 11, 12]
|
return [5, 6, 7, 8, 10, 11, 12]
|
||||||
case .supply:
|
case .supply:
|
||||||
return [1, 2, 4, 8, 9, 11, 12]
|
return [1, 2, 3, 4, 8, 9, 11, 12]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import Elementary
|
||||||
|
import ElementaryHTMX
|
||||||
|
import ManualDCore
|
||||||
|
import Styleguide
|
||||||
|
|
||||||
|
struct EffectiveLengthsTable: HTML, Sendable {
|
||||||
|
|
||||||
|
let effectiveLengths: [EffectiveLength]
|
||||||
|
|
||||||
|
private var sortedLengths: [EffectiveLength] {
|
||||||
|
effectiveLengths.sorted {
|
||||||
|
$0.totalEquivalentLength > $1.totalEquivalentLength
|
||||||
|
}
|
||||||
|
.sorted {
|
||||||
|
$0.type.rawValue > $1.type.rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some HTML<HTMLTag.table> {
|
||||||
|
table(.class("table table-zebra text-lg")) {
|
||||||
|
thead {
|
||||||
|
tr(.class("text-lg")) {
|
||||||
|
th { "Type" }
|
||||||
|
th { "Name" }
|
||||||
|
th { "Straight Lengths" }
|
||||||
|
th {
|
||||||
|
div(.class("grid grid-cols-3 gap-2 min-w-[220px]")) {
|
||||||
|
div(.class("flex justify-center col-span-3")) {
|
||||||
|
"Groups"
|
||||||
|
}
|
||||||
|
div { "Group" }
|
||||||
|
div(.class("flex justify-center")) {
|
||||||
|
"T.E.L."
|
||||||
|
}
|
||||||
|
div(.class("flex justify-end")) {
|
||||||
|
"Quantity"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
div(.class("flex justify-end me-[140px]")) {
|
||||||
|
"T.E.L."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody {
|
||||||
|
for row in sortedLengths {
|
||||||
|
EffectiveLenghtRow(effectiveLength: row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EffectiveLenghtRow: HTML, Sendable {
|
||||||
|
|
||||||
|
let effectiveLength: EffectiveLength
|
||||||
|
|
||||||
|
private var deleteRoute: SiteRoute.View {
|
||||||
|
.project(
|
||||||
|
.detail(
|
||||||
|
effectiveLength.projectID,
|
||||||
|
.equivalentLength(.delete(id: effectiveLength.id))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some HTML<HTMLTag.tr> {
|
||||||
|
tr(.id(effectiveLength.id.idString)) {
|
||||||
|
td {
|
||||||
|
// Type
|
||||||
|
Badge {
|
||||||
|
span { effectiveLength.type.rawValue }
|
||||||
|
}
|
||||||
|
.attributes(.class("badge-info"), when: effectiveLength.type == .supply)
|
||||||
|
.attributes(.class("badge-error"), when: effectiveLength.type == .return)
|
||||||
|
|
||||||
|
}
|
||||||
|
td { effectiveLength.name }
|
||||||
|
td {
|
||||||
|
// Lengths
|
||||||
|
div(.class("grid grid-cols-1 gap-2")) {
|
||||||
|
for length in effectiveLength.straightLengths {
|
||||||
|
Number(length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
div(.class("grid grid-cols-3 gap-2 min-w-[220px]")) {
|
||||||
|
for group in effectiveLength.groups {
|
||||||
|
span { "\(group.group)-\(group.letter)" }
|
||||||
|
div(.class("flex justify-center")) {
|
||||||
|
Number(group.value)
|
||||||
|
}
|
||||||
|
div(.class("flex justify-end")) {
|
||||||
|
Number(group.quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
// Total
|
||||||
|
// Row {
|
||||||
|
div(.class("flex justify-end mx-auto space-x-4")) {
|
||||||
|
Badge(number: effectiveLength.totalEquivalentLength, digits: 0)
|
||||||
|
.attributes(.class("badge-primary badge-lg pt-2"))
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
div(.class("flex justify-end -mt-2")) {
|
||||||
|
div(.class("join")) {
|
||||||
|
TrashButton()
|
||||||
|
.attributes(
|
||||||
|
.class("join-item btn-ghost"),
|
||||||
|
.hx.delete(route: deleteRoute),
|
||||||
|
.hx.confirm("Are you sure?"),
|
||||||
|
.hx.target("#\(effectiveLength.id.idString)"),
|
||||||
|
.hx.swap(.outerHTML)
|
||||||
|
)
|
||||||
|
.tooltip("Delete", position: .bottom)
|
||||||
|
|
||||||
|
EditButton()
|
||||||
|
.attributes(
|
||||||
|
.class("join-item btn-ghost"),
|
||||||
|
.showModal(id: EffectiveLengthForm.id(effectiveLength))
|
||||||
|
)
|
||||||
|
.tooltip("Edit", position: .bottom)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
EffectiveLengthForm(effectiveLength: effectiveLength)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -5,100 +5,37 @@ import Styleguide
|
|||||||
|
|
||||||
struct EffectiveLengthsView: HTML, Sendable {
|
struct EffectiveLengthsView: HTML, Sendable {
|
||||||
|
|
||||||
|
@Environment(ProjectViewValue.$projectID) var projectID
|
||||||
|
|
||||||
let effectiveLengths: [EffectiveLength]
|
let effectiveLengths: [EffectiveLength]
|
||||||
|
|
||||||
|
var supplies: [EffectiveLength] {
|
||||||
|
effectiveLengths.filter({ $0.type == .supply })
|
||||||
|
.sorted { $0.totalEquivalentLength > $1.totalEquivalentLength }
|
||||||
|
}
|
||||||
|
|
||||||
|
var returns: [EffectiveLength] {
|
||||||
|
effectiveLengths.filter({ $0.type == .return })
|
||||||
|
.sorted { $0.totalEquivalentLength > $1.totalEquivalentLength }
|
||||||
|
}
|
||||||
|
|
||||||
var body: some HTML {
|
var body: some HTML {
|
||||||
div(
|
div(.class("space-y-4")) {
|
||||||
.class("m-4")
|
PageTitleRow {
|
||||||
) {
|
PageTitle { "Equivalent Lengths" }
|
||||||
Row {
|
|
||||||
h1(.class("text-2xl font-bold")) { "Effective Lengths" }
|
|
||||||
PlusButton()
|
PlusButton()
|
||||||
.attributes(
|
.attributes(
|
||||||
.hx.get(route: .effectiveLength(.form(dismiss: false))),
|
.class("btn-primary"),
|
||||||
.hx.target("#effectiveLengthForm"),
|
.showModal(id: EffectiveLengthForm.id(nil))
|
||||||
.hx.swap(.outerHTML)
|
|
||||||
)
|
)
|
||||||
// button(
|
.tooltip("Add equivalent length")
|
||||||
// .hx.get(route: .effectiveLength(.form(dismiss: false))),
|
|
||||||
// .hx.target("#effectiveLengthForm"),
|
|
||||||
// .hx.swap(.outerHTML)
|
|
||||||
// ) {
|
|
||||||
// Icon(.circlePlus)
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
.attributes(.class("pb-6"))
|
.attributes(.class("pb-6"))
|
||||||
|
|
||||||
div(
|
EffectiveLengthForm(projectID: projectID, dismiss: true)
|
||||||
.id("effectiveLengths"),
|
|
||||||
.class("space-y-6")
|
|
||||||
) {
|
|
||||||
for row in effectiveLengths {
|
|
||||||
EffectiveLengthView(effectiveLength: row)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
EffectiveLengthForm(dismiss: true)
|
EffectiveLengthsTable(effectiveLengths: effectiveLengths)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct EffectiveLengthView: HTML, Sendable {
|
|
||||||
|
|
||||||
let effectiveLength: EffectiveLength
|
|
||||||
|
|
||||||
var straightLengthsTotal: Int {
|
|
||||||
effectiveLength.straightLengths
|
|
||||||
.reduce(into: 0) { $0 += $1 }
|
|
||||||
}
|
|
||||||
|
|
||||||
var groupsTotal: Double {
|
|
||||||
effectiveLength.groups.reduce(into: 0) {
|
|
||||||
$0 += ($1.value * Double($1.quantity))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some HTML<HTMLTag.div> {
|
|
||||||
div(
|
|
||||||
.class(
|
|
||||||
"""
|
|
||||||
border border-gray-200 rounded-lg shadow-lg p-4
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Row {
|
|
||||||
span(.class("text-xl font-bold")) { effectiveLength.name }
|
|
||||||
}
|
|
||||||
Row {
|
|
||||||
Label("Straight Lengths")
|
|
||||||
}
|
|
||||||
for length in effectiveLength.straightLengths {
|
|
||||||
Row {
|
|
||||||
div {}
|
|
||||||
Number(length)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Row {
|
|
||||||
Label("Groups")
|
|
||||||
Label("Equivalent Length")
|
|
||||||
Label("Quantity")
|
|
||||||
}
|
|
||||||
.attributes(.class("border-b border-gray-200"))
|
|
||||||
|
|
||||||
for group in effectiveLength.groups {
|
|
||||||
Row {
|
|
||||||
span { "\(group.group)-\(group.letter)" }
|
|
||||||
Number(group.value)
|
|
||||||
Number(group.quantity)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row {
|
|
||||||
Label("Total")
|
|
||||||
Number(Double(straightLengthsTotal) + groupsTotal, digits: 0)
|
|
||||||
.attributes(.class("text-xl font-bold"))
|
|
||||||
}
|
|
||||||
.attributes(.class("border-b border-t border-gray-200"))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ struct EquipmentInfoForm: HTML, Sendable {
|
|||||||
|
|
||||||
static let id = "equipmentForm"
|
static let id = "equipmentForm"
|
||||||
|
|
||||||
|
@Environment(ProjectViewValue.$projectID) var projectID
|
||||||
|
|
||||||
let dismiss: Bool
|
let dismiss: Bool
|
||||||
let projectID: Project.ID
|
|
||||||
let equipmentInfo: EquipmentInfo?
|
let equipmentInfo: EquipmentInfo?
|
||||||
|
|
||||||
var staticPressure: String {
|
var staticPressure: String {
|
||||||
@@ -18,29 +19,22 @@ struct EquipmentInfoForm: HTML, Sendable {
|
|||||||
return "\(staticPressure)"
|
return "\(staticPressure)"
|
||||||
}
|
}
|
||||||
|
|
||||||
var heatingCFM: String {
|
var route: String {
|
||||||
guard let heatingCFM = equipmentInfo?.heatingCFM else {
|
SiteRoute.View.router.path(
|
||||||
return ""
|
for: .project(.detail(projectID, .equipment(.index)))
|
||||||
}
|
)
|
||||||
return "\(heatingCFM)"
|
.appendingPath(equipmentInfo?.id)
|
||||||
}
|
|
||||||
|
|
||||||
var coolingCFM: String {
|
|
||||||
guard let heatingCFM = equipmentInfo?.heatingCFM else {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return "\(heatingCFM)"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some HTML {
|
var body: some HTML {
|
||||||
ModalForm(id: Self.id, dismiss: dismiss) {
|
ModalForm(id: Self.id, dismiss: dismiss) {
|
||||||
h1(.class("text-3xl font-bold pb-6 ps-2")) { "Equipment Info" }
|
h1(.class("text-3xl font-bold pb-6 ps-2")) { "Equipment Info" }
|
||||||
form(
|
form(
|
||||||
.class("space-y-4 p-4"),
|
.class("grid grid-cols-1 gap-4"),
|
||||||
equipmentInfo != nil
|
equipmentInfo != nil
|
||||||
? .hx.patch(route: .project(.detail(projectID, .equipment(.index))))
|
? .hx.patch(route)
|
||||||
: .hx.post(route: .project(.detail(projectID, .equipment(.index)))),
|
: .hx.post(route),
|
||||||
.hx.target("#equipmentInfo"),
|
.hx.target("body"),
|
||||||
.hx.swap(.outerHTML)
|
.hx.swap(.outerHTML)
|
||||||
) {
|
) {
|
||||||
input(.class("hidden"), .name("projectID"), .value("\(projectID)"))
|
input(.class("hidden"), .name("projectID"), .value("\(projectID)"))
|
||||||
@@ -49,27 +43,40 @@ struct EquipmentInfoForm: HTML, Sendable {
|
|||||||
input(.class("hidden"), .name("id"), .value("\(equipmentInfo.id)"))
|
input(.class("hidden"), .name("id"), .value("\(equipmentInfo.id)"))
|
||||||
}
|
}
|
||||||
|
|
||||||
div {
|
LabeledInput(
|
||||||
label(.for("staticPressure")) { "Static Pressure" }
|
"Static Pressure",
|
||||||
Input(id: "staticPressure", placeholder: "Static pressure")
|
.name("staticPressure"),
|
||||||
.attributes(
|
.type(.number),
|
||||||
.type(.number), .value(staticPressure), .min("0"), .max("1.0"), .step("0.1")
|
.value(staticPressure),
|
||||||
)
|
.min("0"),
|
||||||
}
|
.max("1.0"),
|
||||||
div {
|
.step("0.1"),
|
||||||
label(.for("heatingCFM")) { "Heating CFM" }
|
.required
|
||||||
Input(id: "heatingCFM", placeholder: "CFM")
|
)
|
||||||
.attributes(.type(.number), .min("0"), .value(heatingCFM))
|
|
||||||
}
|
LabeledInput(
|
||||||
div {
|
"Heating CFM",
|
||||||
label(.for("coolingCFM")) { "Cooling CFM" }
|
.name("heatingCFM"),
|
||||||
Input(id: "coolingCFM", placeholder: "CFM")
|
.type(.number),
|
||||||
.attributes(.type(.number), .min("0"), .value(coolingCFM))
|
.value(equipmentInfo?.heatingCFM),
|
||||||
}
|
.placeholder("1000"),
|
||||||
div {
|
.min("0"),
|
||||||
SubmitButton(title: "Save")
|
.required,
|
||||||
.attributes(.class("btn-block"))
|
.autofocus
|
||||||
}
|
)
|
||||||
|
|
||||||
|
LabeledInput(
|
||||||
|
"Cooling CFM",
|
||||||
|
.name("coolingCFM"),
|
||||||
|
.type(.number),
|
||||||
|
.value(equipmentInfo?.coolingCFM),
|
||||||
|
.placeholder("1000"),
|
||||||
|
.min("0"),
|
||||||
|
.required
|
||||||
|
)
|
||||||
|
|
||||||
|
SubmitButton(title: "Save")
|
||||||
|
.attributes(.class("btn-block my-6"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,42 +8,55 @@ struct EquipmentInfoView: HTML, Sendable {
|
|||||||
|
|
||||||
var body: some HTML {
|
var body: some HTML {
|
||||||
div(
|
div(
|
||||||
.class("space-y-4 border border-gray-200 rounded-lg shadow-lg p-4"),
|
.class("space-y-4"),
|
||||||
.id("equipmentInfo")
|
.id("equipmentInfo")
|
||||||
) {
|
) {
|
||||||
|
|
||||||
Row {
|
PageTitleRow {
|
||||||
h1(.class("text-2xl font-bold")) { "Equipment Info" }
|
PageTitle { "Equipment Details" }
|
||||||
|
|
||||||
EditButton()
|
EditButton()
|
||||||
.attributes(
|
.attributes(
|
||||||
.on(.click, "\(EquipmentInfoForm.id).showModal()")
|
.class("btn-primary"),
|
||||||
|
.showModal(id: EquipmentInfoForm.id)
|
||||||
)
|
)
|
||||||
|
.tooltip("Edit equipment details")
|
||||||
}
|
}
|
||||||
|
|
||||||
if let equipmentInfo {
|
if let equipmentInfo {
|
||||||
|
|
||||||
Row {
|
table(.class("table table-zebra")) {
|
||||||
Label { "Static Pressure" }
|
tbody(.class("text-lg")) {
|
||||||
Number(equipmentInfo.staticPressure)
|
tr {
|
||||||
|
td { Label { "Static Pressure" } }
|
||||||
|
td {
|
||||||
|
div(.class("flex justify-end")) {
|
||||||
|
Number(equipmentInfo.staticPressure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
td { Label { "Heating CFM" } }
|
||||||
|
td {
|
||||||
|
div(.class("flex justify-end")) {
|
||||||
|
Number(equipmentInfo.heatingCFM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
td { Label { "Cooling CFM" } }
|
||||||
|
td {
|
||||||
|
div(.class("flex justify-end")) {
|
||||||
|
Number(equipmentInfo.coolingCFM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.attributes(.class("border-b border-gray-200"))
|
|
||||||
|
|
||||||
Row {
|
|
||||||
Label { "Heating CFM" }
|
|
||||||
Number(equipmentInfo.heatingCFM)
|
|
||||||
}
|
|
||||||
.attributes(.class("border-b border-gray-200"))
|
|
||||||
|
|
||||||
Row {
|
|
||||||
Label { "Cooling CFM" }
|
|
||||||
Number(equipmentInfo.coolingCFM)
|
|
||||||
}
|
|
||||||
.attributes(.class("border-b border-gray-200"))
|
|
||||||
|
|
||||||
}
|
}
|
||||||
EquipmentInfoForm(
|
EquipmentInfoForm(
|
||||||
dismiss: true, projectID: projectID, equipmentInfo: equipmentInfo
|
dismiss: equipmentInfo != nil,
|
||||||
|
equipmentInfo: equipmentInfo
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,148 @@
|
|||||||
import Elementary
|
import Elementary
|
||||||
|
import ManualDClient
|
||||||
import ManualDCore
|
import ManualDCore
|
||||||
import Styleguide
|
import Styleguide
|
||||||
|
|
||||||
struct FrictionRateView: HTML, Sendable {
|
struct FrictionRateView: HTML, Sendable {
|
||||||
|
|
||||||
let equipmentInfo: EquipmentInfo?
|
@Environment(ProjectViewValue.$projectID) var projectID
|
||||||
|
|
||||||
let componentLosses: [ComponentPressureLoss]
|
let componentLosses: [ComponentPressureLoss]
|
||||||
let projectID: Project.ID
|
let equivalentLengths: EffectiveLength.MaxContainer
|
||||||
|
let frictionRateResponse: ManualDClient.FrictionRateResponse?
|
||||||
|
|
||||||
|
private var availableStaticPressure: Double? {
|
||||||
|
frictionRateResponse?.availableStaticPressure
|
||||||
|
}
|
||||||
|
|
||||||
|
private var frictionRateDesignValue: Double? {
|
||||||
|
frictionRateResponse?.frictionRate
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shouldShowBadges: Bool {
|
||||||
|
frictionRateDesignValue != nil || availableStaticPressure != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private var badgeColor: String {
|
||||||
|
let base = "badge-info"
|
||||||
|
guard let frictionRateDesignValue else { return base }
|
||||||
|
if frictionRateDesignValue >= 0.18 || frictionRateDesignValue <= 0.02 {
|
||||||
|
return "badge-error"
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
private var showHighErrors: Bool {
|
||||||
|
guard let frictionRateDesignValue else { return false }
|
||||||
|
return frictionRateDesignValue >= 0.18
|
||||||
|
}
|
||||||
|
|
||||||
|
private var showLowErrors: Bool {
|
||||||
|
guard let frictionRateDesignValue else { return false }
|
||||||
|
return frictionRateDesignValue <= 0.02
|
||||||
|
}
|
||||||
|
|
||||||
|
private var showNoComponentLossesError: Bool {
|
||||||
|
componentLosses.count == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private var showIncompleteSectionsError: Bool {
|
||||||
|
availableStaticPressure == nil || frictionRateDesignValue == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private var hasAlerts: Bool {
|
||||||
|
showLowErrors
|
||||||
|
|| showHighErrors
|
||||||
|
|| showNoComponentLossesError
|
||||||
|
|| showIncompleteSectionsError
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
var body: some HTML {
|
var body: some HTML {
|
||||||
div(.class("p-4 space-y-6")) {
|
div(.class("space-y-6")) {
|
||||||
h1(.class("text-4xl font-bold pb-6")) { "Friction Rate" }
|
PageTitleRow {
|
||||||
EquipmentInfoView(equipmentInfo: equipmentInfo, projectID: projectID)
|
div(.class("grid grid-cols-2 px-4 w-full")) {
|
||||||
|
|
||||||
|
PageTitle { "Friction Rate" }
|
||||||
|
|
||||||
|
div(.class("space-y-2 justify-end font-bold text-lg")) {
|
||||||
|
if shouldShowBadges {
|
||||||
|
|
||||||
|
if let frictionRateDesignValue {
|
||||||
|
LabeledContent {
|
||||||
|
span { "Friction Rate Design Value" }
|
||||||
|
} content: {
|
||||||
|
Badge(number: frictionRateDesignValue, digits: 2)
|
||||||
|
.attributes(.class("\(badgeColor) badge-lg"))
|
||||||
|
.bold()
|
||||||
|
}
|
||||||
|
.attributes(.class("justify-end mx-auto"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let availableStaticPressure {
|
||||||
|
LabeledContent {
|
||||||
|
span { "Available Static Pressure" }
|
||||||
|
} content: {
|
||||||
|
Badge(number: availableStaticPressure, digits: 2)
|
||||||
|
}
|
||||||
|
.attributes(.class("justify-end mx-auto"))
|
||||||
|
}
|
||||||
|
|
||||||
|
LabeledContent {
|
||||||
|
span { "Component Pressure Losses" }
|
||||||
|
} content: {
|
||||||
|
Badge(number: componentLosses.total, digits: 2)
|
||||||
|
}
|
||||||
|
.attributes(.class("justify-end mx-auto"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div(.class("text-error font-bold italic col-span-2")) {
|
||||||
|
Alert {
|
||||||
|
p {
|
||||||
|
"Must complete previous sections."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.hidden(when: !showIncompleteSectionsError)
|
||||||
|
|
||||||
|
Alert {
|
||||||
|
p {
|
||||||
|
"No component pressures losses"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.hidden(when: !showNoComponentLossesError)
|
||||||
|
|
||||||
|
Alert {
|
||||||
|
p(.class("block")) {
|
||||||
|
"Calculated friction rate is below 0.02. The fan may not deliver the required CFM."
|
||||||
|
br()
|
||||||
|
" * Increase the blower speed"
|
||||||
|
br()
|
||||||
|
" * Increase the blower size"
|
||||||
|
br()
|
||||||
|
" * Decrease the Total Effective Length (TEL)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.hidden(when: !showLowErrors)
|
||||||
|
|
||||||
|
Alert {
|
||||||
|
p(.class("block")) {
|
||||||
|
"Calculated friction rate is above 0.18. The fan may deliver too many CFM."
|
||||||
|
br()
|
||||||
|
" * Decrease the blower speed"
|
||||||
|
br()
|
||||||
|
" * Decreae the blower size"
|
||||||
|
br()
|
||||||
|
" * Increase the Total Effective Length (TEL)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.hidden(when: !showHighErrors)
|
||||||
|
|
||||||
|
}
|
||||||
|
.attributes(.class("mt-4"), when: hasAlerts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ComponentPressureLossesView(
|
ComponentPressureLossesView(
|
||||||
componentPressureLosses: componentLosses, projectID: projectID
|
componentPressureLosses: componentLosses, projectID: projectID
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,38 +1,111 @@
|
|||||||
import Elementary
|
import Elementary
|
||||||
import ElementaryHTMX
|
import ElementaryHTMX
|
||||||
|
import Foundation
|
||||||
import ManualDCore
|
import ManualDCore
|
||||||
import Styleguide
|
import Styleguide
|
||||||
|
|
||||||
public struct MainPage<Inner: HTML>: SendableHTMLDocument where Inner: Sendable {
|
public struct MainPage<Inner: HTML>: SendableHTMLDocument where Inner: Sendable {
|
||||||
|
|
||||||
public var title: String { "Manual-D" }
|
public var title: String { "Duct Calc" }
|
||||||
public var lang: String { "en" }
|
public var lang: String { "en" }
|
||||||
|
|
||||||
let inner: Inner
|
let inner: Inner
|
||||||
|
let theme: Theme?
|
||||||
|
let displayFooter: Bool
|
||||||
|
|
||||||
init(
|
init(
|
||||||
|
displayFooter: Bool = true,
|
||||||
|
theme: Theme? = nil,
|
||||||
_ inner: () -> Inner
|
_ inner: () -> Inner
|
||||||
) {
|
) {
|
||||||
|
self.displayFooter = displayFooter
|
||||||
|
self.theme = theme
|
||||||
self.inner = inner()
|
self.inner = inner()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var summary: String {
|
||||||
|
"""
|
||||||
|
Duct sizing based on ACCA, Manual-D.
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private var keywords: String {
|
||||||
|
"""
|
||||||
|
duct, hvac, duct-design, duct design, manual-d, manual d, design
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
|
||||||
public var head: some HTML {
|
public var head: some HTML {
|
||||||
meta(.charset(.utf8))
|
meta(.charset(.utf8))
|
||||||
meta(.name(.viewport), .content("width=device-width, initial-scale=1.0"))
|
meta(.name(.viewport), .content("width=device-width, initial-scale=1.0"))
|
||||||
|
meta(.content("ductcalc.com"), .name("og:site_name"))
|
||||||
|
meta(.content("Duct Calc"), .name("og:title"))
|
||||||
|
meta(.content(summary), .name("description"))
|
||||||
|
meta(.content(summary), .name("og:description"))
|
||||||
|
meta(.content("/images/mand_logo.png"), .name("og:image"))
|
||||||
|
meta(.content("/images/mand_logo.png"), .name("twitter:image"))
|
||||||
|
meta(.content("Duct Calc"), .name("twitter:image:alt"))
|
||||||
|
meta(.content("summary_large_image"), .name("twitter:card"))
|
||||||
|
meta(.content("1536"), .name("og:image:width"))
|
||||||
|
meta(.content("1024"), .name("og:image:height"))
|
||||||
|
meta(.content(keywords), .name(.keywords))
|
||||||
script(.src("https://unpkg.com/htmx.org@2.0.8")) {}
|
script(.src("https://unpkg.com/htmx.org@2.0.8")) {}
|
||||||
script(.src("/js/main.js")) {}
|
script(.src("/js/main.js")) {}
|
||||||
link(.rel(.stylesheet), .href("/css/output.css"))
|
link(.rel(.stylesheet), .href("/css/output.css"))
|
||||||
link(.rel(.icon), .href("/images/favicon.ico"), .custom(name: "type", value: "image/x-icon"))
|
link(
|
||||||
|
.rel(.icon),
|
||||||
|
.href("/images/favicon.ico"),
|
||||||
|
.init(name: "type", value: "image/x-icon")
|
||||||
|
)
|
||||||
|
link(
|
||||||
|
.rel(.icon),
|
||||||
|
.href("/images/favicon-32x32.png"),
|
||||||
|
.init(name: "type", value: "image/png")
|
||||||
|
)
|
||||||
|
link(
|
||||||
|
.rel(.icon),
|
||||||
|
.href("/images/favicon-16x16.png"),
|
||||||
|
.init(name: "type", value: "image/png")
|
||||||
|
)
|
||||||
|
link(
|
||||||
|
.rel(.init(rawValue: "apple-touch-icon")),
|
||||||
|
.init(name: "sizes", value: "180x180"),
|
||||||
|
.href("/images/apple-touch-icon.png")
|
||||||
|
)
|
||||||
|
link(.rel(.init(rawValue: "manifest")), .href("/site.webmanifest"))
|
||||||
|
script(
|
||||||
|
.src("https://unpkg.com/htmx-remove@latest"),
|
||||||
|
.crossorigin(.anonymous),
|
||||||
|
.integrity("sha384-NwB2Xh66PNEYfVki0ao13UAFmdNtMIdBKZ8sNGRT6hKfCPaINuZ4ScxS6vVAycPT")
|
||||||
|
) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
public var body: some HTML {
|
public var body: some HTML {
|
||||||
div(.class("h-screen w-full")) {
|
div(.class("flex flex-col min-h-screen min-w-full justify-between")) {
|
||||||
inner
|
main(.class("flex flex-col min-h-screen min-w-full grow mb-auto")) {
|
||||||
}
|
inner
|
||||||
script(.src("https://unpkg.com/lucide@latest")) {}
|
}
|
||||||
script {
|
|
||||||
"lucide.createIcons();"
|
div(.class("bottom-0 left-0 bg-error")) {
|
||||||
|
if displayFooter {
|
||||||
|
footer(
|
||||||
|
.class(
|
||||||
|
"""
|
||||||
|
footer sm:footer-horizontal footer-center
|
||||||
|
bg-base-300 text-base-content p-4
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
aside {
|
||||||
|
p {
|
||||||
|
"Copyright © \(Date().description.prefix(4)) - All rights reserved by Michael Housh"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.attributes(.data("theme", value: theme?.rawValue ?? "default"), when: theme != nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
72
Sources/ViewController/Views/Navbar.swift
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import Elementary
|
||||||
|
import ManualDCore
|
||||||
|
import Styleguide
|
||||||
|
|
||||||
|
struct Navbar: HTML, Sendable {
|
||||||
|
let sidebarToggle: Bool
|
||||||
|
let userProfile: Bool
|
||||||
|
|
||||||
|
init(
|
||||||
|
sidebarToggle: Bool,
|
||||||
|
userProfile: Bool = true
|
||||||
|
) {
|
||||||
|
self.sidebarToggle = sidebarToggle
|
||||||
|
self.userProfile = userProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some HTML<HTMLTag.nav> {
|
||||||
|
nav(
|
||||||
|
.class(
|
||||||
|
"""
|
||||||
|
navbar w-full bg-base-300 text-base-content shadow-sm mb-4
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
div(.class("flex flex-1 space-x-4 items-center")) {
|
||||||
|
if sidebarToggle {
|
||||||
|
label(
|
||||||
|
.for("my-drawer-1"),
|
||||||
|
.class("size-7"),
|
||||||
|
.init(name: "aria-label", value: "open sidebar")
|
||||||
|
) {
|
||||||
|
SVG(.sidebarToggle)
|
||||||
|
}
|
||||||
|
.navButton()
|
||||||
|
.tooltip("Open sidebar", position: .right)
|
||||||
|
}
|
||||||
|
|
||||||
|
a(
|
||||||
|
.class("flex w-fit h-fit text-xl items-end px-4 py-2"),
|
||||||
|
.href(route: .project(.index))
|
||||||
|
) {
|
||||||
|
img(
|
||||||
|
.src("/images/mand_logo_sm.webp"),
|
||||||
|
)
|
||||||
|
span { "Duct Calc" }
|
||||||
|
}
|
||||||
|
.navButton()
|
||||||
|
.tooltip("Home", position: .right)
|
||||||
|
}
|
||||||
|
if userProfile {
|
||||||
|
// TODO: Make dropdown
|
||||||
|
div(.class("flex-none")) {
|
||||||
|
a(
|
||||||
|
.href(route: .user(.profile(.index))),
|
||||||
|
) {
|
||||||
|
SVG(.circleUser)
|
||||||
|
}
|
||||||
|
.navButton()
|
||||||
|
.tooltip("Profile")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension HTML where Tag: HTMLTrait.Attributes.Global {
|
||||||
|
func navButton() -> _AttributedElement<Self> {
|
||||||
|
attributes(
|
||||||
|
.class("btn btn-square btn-ghost hover:bg-neutral hover:text-white")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,51 +7,65 @@ struct ProjectDetail: HTML, Sendable {
|
|||||||
let project: Project
|
let project: Project
|
||||||
|
|
||||||
var body: some HTML {
|
var body: some HTML {
|
||||||
div(
|
div {
|
||||||
.class(
|
PageTitleRow {
|
||||||
"""
|
PageTitle { "Project" }
|
||||||
border border-gray-200 rounded-lg shadow-lg space-y-4 p-4 m-4
|
|
||||||
"""
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Row {
|
|
||||||
h1(.class("text-2xl font-bold")) { "Project" }
|
|
||||||
EditButton()
|
EditButton()
|
||||||
.attributes(
|
.attributes(
|
||||||
|
.class("btn-primary"),
|
||||||
.on(.click, "projectForm.showModal()")
|
.on(.click, "projectForm.showModal()")
|
||||||
)
|
)
|
||||||
|
.tooltip("Edit project", position: .left)
|
||||||
}
|
}
|
||||||
|
|
||||||
Row {
|
table(.class("table table-zebra text-lg")) {
|
||||||
Label("Name")
|
tbody {
|
||||||
span { project.name }
|
tr {
|
||||||
|
td(.class("label font-bold")) { "Name" }
|
||||||
|
td {
|
||||||
|
div(.class("flex justify-end")) {
|
||||||
|
project.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
td(.class("label font-bold")) { "Street Address" }
|
||||||
|
td {
|
||||||
|
div(.class("flex justify-end")) {
|
||||||
|
project.streetAddress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
td(.class("label font-bold")) { "City" }
|
||||||
|
td {
|
||||||
|
div(.class("flex justify-end")) {
|
||||||
|
project.city
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
td(.class("label font-bold")) { "State" }
|
||||||
|
td {
|
||||||
|
div(.class("flex justify-end")) {
|
||||||
|
project.state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
td(.class("label font-bold")) { "Zip" }
|
||||||
|
td {
|
||||||
|
div(.class("flex justify-end")) {
|
||||||
|
project.zipCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.attributes(.class("border-b border-gray-200"))
|
|
||||||
|
|
||||||
Row {
|
ProjectForm(dismiss: true, project: project)
|
||||||
Label("Address")
|
|
||||||
span { project.streetAddress }
|
|
||||||
}
|
|
||||||
.attributes(.class("border-b border-gray-200"))
|
|
||||||
|
|
||||||
Row {
|
|
||||||
Label("City")
|
|
||||||
span { project.city }
|
|
||||||
}
|
|
||||||
.attributes(.class("border-b border-gray-200"))
|
|
||||||
|
|
||||||
Row {
|
|
||||||
Label("State")
|
|
||||||
span { project.state }
|
|
||||||
}
|
|
||||||
.attributes(.class("border-b border-gray-200"))
|
|
||||||
|
|
||||||
Row {
|
|
||||||
Label("Zip")
|
|
||||||
span { project.zipCode }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ProjectForm(dismiss: true, project: project)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import Styleguide
|
|||||||
|
|
||||||
struct ProjectForm: HTML, Sendable {
|
struct ProjectForm: HTML, Sendable {
|
||||||
|
|
||||||
|
static let id = "projectForm"
|
||||||
|
|
||||||
let project: Project?
|
let project: Project?
|
||||||
let dismiss: Bool
|
let dismiss: Bool
|
||||||
|
|
||||||
@@ -16,14 +18,19 @@ struct ProjectForm: HTML, Sendable {
|
|||||||
self.project = project
|
self.project = project
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var route: String {
|
||||||
|
SiteRoute.View.router.path(for: .project(.index))
|
||||||
|
.appendingPath(project?.id)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some HTML {
|
var body: some HTML {
|
||||||
ModalForm(id: "projectForm", dismiss: dismiss) {
|
ModalForm(id: Self.id, dismiss: dismiss) {
|
||||||
h1(.class("text-3xl font-bold pb-6 ps-2")) { "Project" }
|
h1(.class("text-3xl font-bold pb-6 ps-2")) { "Project" }
|
||||||
form(
|
form(
|
||||||
.class("space-y-4 p-4"),
|
.class("grid grid-cols-1 gap-4"),
|
||||||
project == nil
|
project == nil
|
||||||
? .hx.post(route: .project(.index))
|
? .hx.post(route)
|
||||||
: .hx.patch(route: .project(.index)),
|
: .hx.patch(route),
|
||||||
.hx.target("body"),
|
.hx.target("body"),
|
||||||
.hx.swap(.outerHTML)
|
.hx.swap(.outerHTML)
|
||||||
) {
|
) {
|
||||||
@@ -31,36 +38,54 @@ struct ProjectForm: HTML, Sendable {
|
|||||||
input(.class("hidden"), .name("id"), .value("\(project.id)"))
|
input(.class("hidden"), .name("id"), .value("\(project.id)"))
|
||||||
}
|
}
|
||||||
|
|
||||||
div {
|
LabeledInput(
|
||||||
label(.for("name")) { "Name" }
|
"Name",
|
||||||
Input(id: "name", placeholder: "Name")
|
.name("name"),
|
||||||
.attributes(.type(.text), .required, .autofocus, .value(project?.name))
|
.type(.text),
|
||||||
}
|
.value(project?.name),
|
||||||
div {
|
.placeholder("Project Name"),
|
||||||
label(.for("streetAddress")) { "Address" }
|
.required,
|
||||||
Input(id: "streetAddress", placeholder: "Street Address")
|
.autofocus
|
||||||
.attributes(.type(.text), .required, .value(project?.streetAddress))
|
)
|
||||||
}
|
|
||||||
div {
|
|
||||||
label(.for("city")) { "City" }
|
|
||||||
Input(id: "city", placeholder: "City")
|
|
||||||
.attributes(.type(.text), .required, .value(project?.city))
|
|
||||||
}
|
|
||||||
div {
|
|
||||||
label(.for("state")) { "State" }
|
|
||||||
Input(id: "state", placeholder: "State")
|
|
||||||
.attributes(.type(.text), .required, .value(project?.state))
|
|
||||||
}
|
|
||||||
div {
|
|
||||||
label(.for("zipCode")) { "Zip" }
|
|
||||||
Input(id: "zipCode", placeholder: "Zip code")
|
|
||||||
.attributes(.type(.text), .required, .value(project?.zipCode))
|
|
||||||
}
|
|
||||||
|
|
||||||
div(.class("flex mt-6")) {
|
LabeledInput(
|
||||||
SubmitButton()
|
"Address",
|
||||||
.attributes(.class("btn-block"))
|
.name("streetAddress"),
|
||||||
}
|
.type(.text),
|
||||||
|
.value(project?.streetAddress),
|
||||||
|
.placeholder("Street Address"),
|
||||||
|
.required
|
||||||
|
)
|
||||||
|
|
||||||
|
LabeledInput(
|
||||||
|
"City",
|
||||||
|
.name("city"),
|
||||||
|
.type(.text),
|
||||||
|
.value(project?.city),
|
||||||
|
.placeholder("City"),
|
||||||
|
.required
|
||||||
|
)
|
||||||
|
|
||||||
|
LabeledInput(
|
||||||
|
"State",
|
||||||
|
.name("state"),
|
||||||
|
.type(.text),
|
||||||
|
.value(project?.state),
|
||||||
|
.placeholder("State"),
|
||||||
|
.required
|
||||||
|
)
|
||||||
|
|
||||||
|
LabeledInput(
|
||||||
|
"Zip",
|
||||||
|
.name("zipCode"),
|
||||||
|
.type(.text),
|
||||||
|
.value(project?.zipCode),
|
||||||
|
.placeholder("Zip Code"),
|
||||||
|
.required
|
||||||
|
)
|
||||||
|
|
||||||
|
SubmitButton()
|
||||||
|
.attributes(.class("btn-block my-6"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,154 +2,252 @@ import DatabaseClient
|
|||||||
import Dependencies
|
import Dependencies
|
||||||
import Elementary
|
import Elementary
|
||||||
import ElementaryHTMX
|
import ElementaryHTMX
|
||||||
|
import Logging
|
||||||
|
import ManualDClient
|
||||||
import ManualDCore
|
import ManualDCore
|
||||||
import Styleguide
|
import Styleguide
|
||||||
|
|
||||||
// TODO: Make view async and load based on the active tab.
|
enum ProjectViewValue {
|
||||||
|
@TaskLocal static var projectID = Project.ID(0)
|
||||||
|
}
|
||||||
|
|
||||||
struct ProjectView: HTML, Sendable {
|
struct ProjectView<Inner: HTML>: HTML, Sendable where Inner: Sendable {
|
||||||
@Dependency(\.database) var database
|
|
||||||
|
|
||||||
let projectID: Project.ID
|
let projectID: Project.ID
|
||||||
let activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab
|
let activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab
|
||||||
|
let inner: Inner
|
||||||
|
let completedSteps: Project.CompletedSteps
|
||||||
|
|
||||||
init(
|
init(
|
||||||
projectID: Project.ID,
|
projectID: Project.ID,
|
||||||
activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab
|
activeTab: SiteRoute.View.ProjectRoute.DetailRoute.Tab,
|
||||||
|
completedSteps: Project.CompletedSteps,
|
||||||
|
@HTMLBuilder content: () -> Inner
|
||||||
) {
|
) {
|
||||||
self.projectID = projectID
|
self.projectID = projectID
|
||||||
self.activeTab = activeTab
|
self.activeTab = activeTab
|
||||||
|
self.inner = content()
|
||||||
|
self.completedSteps = completedSteps
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some HTML {
|
var body: some HTML {
|
||||||
div {
|
div(.class("drawer lg:drawer-open h-full")) {
|
||||||
div(.class("flex flex-row")) {
|
input(.id("my-drawer-1"), .type(.checkbox), .class("drawer-toggle"))
|
||||||
Sidebar(active: activeTab, projectID: projectID)
|
|
||||||
main(.class("flex flex-col h-screen w-full px-6 py-10")) {
|
div(.class("drawer-content overflow-auto")) {
|
||||||
switch self.activeTab {
|
Navbar(sidebarToggle: true)
|
||||||
case .project:
|
div(.class("p-4")) {
|
||||||
if let project = try await database.projects.get(projectID) {
|
inner
|
||||||
ProjectDetail(project: project)
|
.environment(ProjectViewValue.$projectID, projectID)
|
||||||
} else {
|
}
|
||||||
div {
|
}
|
||||||
"FIX ME!"
|
|
||||||
}
|
Sidebar(
|
||||||
|
active: activeTab,
|
||||||
|
projectID: projectID,
|
||||||
|
completedSteps: completedSteps
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ProjectView {
|
||||||
|
|
||||||
|
struct Sidebar: HTML {
|
||||||
|
|
||||||
|
let active: SiteRoute.View.ProjectRoute.DetailRoute.Tab
|
||||||
|
let projectID: Project.ID
|
||||||
|
let completedSteps: Project.CompletedSteps
|
||||||
|
|
||||||
|
var body: some HTML {
|
||||||
|
|
||||||
|
div(.class("drawer-side is-drawer-close:overflow-visible grow")) {
|
||||||
|
label(
|
||||||
|
.for("my-drawer-1"), .init(name: "aria-label", value: "close sidebar"),
|
||||||
|
.class("drawer-overlay")
|
||||||
|
) {}
|
||||||
|
|
||||||
|
div(
|
||||||
|
.class(
|
||||||
|
"""
|
||||||
|
flex grow h-full flex-col items-start bg-base-300 text-base-content
|
||||||
|
is-drawer-close:min-w-[80px] is-drawer-open:max-w-[300px]
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
|
||||||
|
ul(.class("w-full grow")) {
|
||||||
|
|
||||||
|
li(.class("flex w-full")) {
|
||||||
|
row(
|
||||||
|
title: "Project",
|
||||||
|
icon: .mapPin,
|
||||||
|
route: .project(.detail(projectID, .index)),
|
||||||
|
isComplete: true
|
||||||
|
)
|
||||||
|
.attributes(.data("active", value: "true"), when: active == .project)
|
||||||
}
|
}
|
||||||
case .rooms:
|
|
||||||
try await RoomsView(
|
|
||||||
projectID: projectID,
|
|
||||||
rooms: database.rooms.fetch(projectID),
|
|
||||||
sensibleHeatRatio: database.projects.getSensibleHeatRatio(projectID)
|
|
||||||
)
|
|
||||||
|
|
||||||
case .effectiveLength:
|
li(.class("w-full")) {
|
||||||
try await EffectiveLengthsView(
|
row(
|
||||||
effectiveLengths: database.effectiveLength.fetch(projectID)
|
title: "Rooms",
|
||||||
)
|
icon: .doorClosed,
|
||||||
case .frictionRate:
|
route: .project(.detail(projectID, .rooms(.index))),
|
||||||
try await FrictionRateView(
|
isComplete: completedSteps.rooms
|
||||||
equipmentInfo: database.equipment.fetch(projectID),
|
)
|
||||||
componentLosses: database.componentLoss.fetch(projectID), projectID: projectID)
|
.attributes(.data("active", value: "true"), when: active == .rooms)
|
||||||
case .ductSizing:
|
}
|
||||||
div { "FIX ME!" }
|
|
||||||
|
|
||||||
|
li(.class("flex w-full")) {
|
||||||
|
row(
|
||||||
|
title: "Equipment",
|
||||||
|
icon: .fan,
|
||||||
|
route: .project(.detail(projectID, .equipment(.index))),
|
||||||
|
isComplete: completedSteps.equipmentInfo
|
||||||
|
)
|
||||||
|
.attributes(.data("active", value: "true"), when: active == .equipment)
|
||||||
|
}
|
||||||
|
|
||||||
|
li(.class("w-full")) {
|
||||||
|
// Tooltip("Equivalent Lengths", position: .right) {
|
||||||
|
row(
|
||||||
|
title: "T.E.L.",
|
||||||
|
icon: .rulerDimensionLine,
|
||||||
|
route: .project(.detail(projectID, .equivalentLength(.index))),
|
||||||
|
isComplete: completedSteps.equivalentLength
|
||||||
|
)
|
||||||
|
.attributes(.data("active", value: "true"), when: active == .equivalentLength)
|
||||||
|
// }
|
||||||
|
|
||||||
|
}
|
||||||
|
li(.class("w-full")) {
|
||||||
|
row(
|
||||||
|
title: "Friction Rate",
|
||||||
|
icon: .squareFunction,
|
||||||
|
route: .project(.detail(projectID, .frictionRate(.index))),
|
||||||
|
isComplete: completedSteps.frictionRate
|
||||||
|
)
|
||||||
|
.attributes(.data("active", value: "true"), when: active == .frictionRate)
|
||||||
|
|
||||||
|
}
|
||||||
|
li(.class("w-full")) {
|
||||||
|
row(
|
||||||
|
title: "Duct Sizes",
|
||||||
|
icon: .wind,
|
||||||
|
route: .project(.detail(projectID, .ductSizing(.index))),
|
||||||
|
isComplete: false,
|
||||||
|
hideIsComplete: true
|
||||||
|
)
|
||||||
|
.attributes(.data("active", value: "true"), when: active == .ductSizing)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Update to use DaisyUI drawer.
|
// TODO: Use SiteRoute.View routes as href.
|
||||||
struct Sidebar: HTML {
|
private func row(
|
||||||
|
title: String,
|
||||||
let active: SiteRoute.View.ProjectRoute.DetailRoute.Tab
|
icon: SVG.Key,
|
||||||
let projectID: Project.ID
|
href: String,
|
||||||
|
isComplete: Bool,
|
||||||
var body: some HTML {
|
hideIsComplete: Bool = false
|
||||||
aside(
|
) -> some HTML<HTMLTag.button> {
|
||||||
.class(
|
button(
|
||||||
"""
|
.class(
|
||||||
h-screen sticky top-0 max-w-[280px] flex-none
|
"""
|
||||||
border-r-2 border-gray-200
|
w-full gap-1 py-2 border-b-1 border-gray-200
|
||||||
shadow-lg
|
hover:bg-neutral data-active:bg-neutral
|
||||||
"""
|
hover:text-white data-active:text-white
|
||||||
)
|
is-drawer-open:flex is-drawer-open:space-x-4
|
||||||
) {
|
is-drawer-close:grid-cols-1
|
||||||
|
"""
|
||||||
div(.class("flex")) {
|
),
|
||||||
// TODO: Move somewhere outside of the sidebar.
|
.hx.get(href),
|
||||||
button(
|
.hx.pushURL(true),
|
||||||
.class("w-full btn btn-secondary"),
|
.hx.target("body"),
|
||||||
.hx.get(route: .project(.index)),
|
.hx.swap(.outerHTML)
|
||||||
.hx.target("body"),
|
) {
|
||||||
.hx.pushURL(true),
|
div(
|
||||||
.hx.swap(.outerHTML),
|
.class(
|
||||||
|
"""
|
||||||
|
w-full p-2 gap-1
|
||||||
|
is-drawer-open:flex is-drawer-open:space-x-4
|
||||||
|
is-drawer-close:grid-cols-1
|
||||||
|
"""
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
"< All Projects"
|
div(
|
||||||
|
.class(
|
||||||
|
"""
|
||||||
|
items-center
|
||||||
|
is-drawer-open:justify-start is-drawer-open:flex is-drawer-open:space-x-4
|
||||||
|
is-drawer-close:justify-center is-drawer-close:mx-auto is-drawer-close:space-y-2
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
div(.class("flex items-center justify-center")) {
|
||||||
|
SVG(icon)
|
||||||
|
}
|
||||||
|
.attributes(.class("is-drawer-close:text-green-400"), when: isComplete)
|
||||||
|
.attributes(.class("is-drawer-close:text-error"), when: !isComplete && !hideIsComplete)
|
||||||
|
|
||||||
|
div(.class("flex items-center justify-center")) {
|
||||||
|
span { title }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hideIsComplete {
|
||||||
|
div(.class("flex grow justify-end items-end is-drawer-close:hidden")) {
|
||||||
|
if isComplete {
|
||||||
|
SVG(.badgeCheck)
|
||||||
|
} else {
|
||||||
|
SVG(.ban)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.attributes(.class("text-green-400"), when: isComplete)
|
||||||
|
.attributes(.class("text-error"), when: !isComplete)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Row {
|
|
||||||
Label("Theme")
|
|
||||||
input(.type(.checkbox), .class("toggle theme-controller"), .value("light"))
|
|
||||||
}
|
|
||||||
.attributes(.class("p-4"))
|
|
||||||
|
|
||||||
row(
|
|
||||||
title: "Project",
|
|
||||||
icon: .mapPin,
|
|
||||||
route: .project(.detail(projectID, .index(tab: .project)))
|
|
||||||
)
|
|
||||||
.attributes(.data("active", value: active == .project ? "true" : "false"))
|
|
||||||
|
|
||||||
row(title: "Rooms", icon: .doorClosed, route: .project(.detail(projectID, .rooms(.index))))
|
|
||||||
.attributes(.data("active", value: active == .rooms ? "true" : "false"))
|
|
||||||
|
|
||||||
row(title: "Equivalent Lengths", icon: .rulerDimensionLine, route: .effectiveLength(.index))
|
|
||||||
.attributes(.data("active", value: active == .effectiveLength ? "true" : "false"))
|
|
||||||
|
|
||||||
row(
|
|
||||||
title: "Friction Rate",
|
|
||||||
icon: .squareFunction,
|
|
||||||
route: .project(.detail(projectID, .frictionRate(.index)))
|
|
||||||
)
|
|
||||||
.attributes(.data("active", value: active == .frictionRate ? "true" : "false"))
|
|
||||||
|
|
||||||
row(title: "Duct Sizes", icon: .wind, href: "#")
|
|
||||||
.attributes(.data("active", value: active == .ductSizing ? "true" : "false"))
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Use SiteRoute.View routes as href.
|
private func row(
|
||||||
private func row(
|
title: String,
|
||||||
title: String,
|
icon: SVG.Key,
|
||||||
icon: Icon.Key,
|
route: SiteRoute.View,
|
||||||
href: String
|
isComplete: Bool,
|
||||||
) -> some HTML<HTMLTag.a> {
|
hideIsComplete: Bool = false
|
||||||
a(
|
) -> some HTML<HTMLTag.button> {
|
||||||
.class(
|
row(
|
||||||
"""
|
title: title, icon: icon, href: SiteRoute.View.router.path(for: route),
|
||||||
flex w-full items-center gap-4
|
isComplete: isComplete, hideIsComplete: hideIsComplete
|
||||||
hover:bg-gray-300 hover:text-gray-800
|
)
|
||||||
data-[active=true]:bg-gray-300 data-[active=true]:text-gray-800
|
|
||||||
px-4 py-2
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
.href(href)
|
|
||||||
) {
|
|
||||||
Icon(icon)
|
|
||||||
span(.class("text-xl")) {
|
|
||||||
title
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
private func row(
|
|
||||||
title: String,
|
extension ManualDClient {
|
||||||
icon: Icon.Key,
|
|
||||||
route: SiteRoute.View
|
func frictionRate(
|
||||||
) -> some HTML<HTMLTag.a> {
|
equipmentInfo: EquipmentInfo?,
|
||||||
row(title: title, icon: icon, href: SiteRoute.View.router.path(for: route))
|
componentLosses: [ComponentPressureLoss],
|
||||||
}
|
effectiveLength: EffectiveLength.MaxContainer
|
||||||
|
) async throws -> FrictionRateResponse? {
|
||||||
|
guard let staticPressure = equipmentInfo?.staticPressure else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let totalEquivalentLength = effectiveLength.total else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return try await self.frictionRate(
|
||||||
|
.init(
|
||||||
|
externalStaticPressure: staticPressure,
|
||||||
|
componentPressureLosses: componentLosses,
|
||||||
|
totalEffectiveLength: Int(totalEquivalentLength)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,25 +17,20 @@ struct ProjectsTable: HTML, Sendable {
|
|||||||
|
|
||||||
var body: some HTML {
|
var body: some HTML {
|
||||||
div {
|
div {
|
||||||
Row {
|
Navbar(sidebarToggle: false)
|
||||||
h1(.class("text-2xl font-bold")) { "Projects" }
|
div(.class("m-6")) {
|
||||||
div(
|
PageTitleRow {
|
||||||
.class("tooltip tooltip-left"),
|
PageTitle { "Projects" }
|
||||||
.data("tip", value: "Add project")
|
Tooltip("Add project") {
|
||||||
) {
|
PlusButton()
|
||||||
button(
|
.attributes(
|
||||||
.class("btn btn-primary w-[40px] text-2xl"),
|
.class("btn-primary"),
|
||||||
.hx.get(route: .project(.form(dismiss: false))),
|
.showModal(id: ProjectForm.id)
|
||||||
.hx.target("#projectForm"),
|
)
|
||||||
.hx.swap(.outerHTML)
|
|
||||||
) {
|
|
||||||
"+"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
.attributes(.class("pb-6"))
|
||||||
.attributes(.class("pb-6"))
|
|
||||||
|
|
||||||
div(.class("overflow-x-auto rounded-box border")) {
|
|
||||||
table(.class("table table-zebra")) {
|
table(.class("table table-zebra")) {
|
||||||
thead {
|
thead {
|
||||||
tr {
|
tr {
|
||||||
@@ -60,24 +55,43 @@ extension ProjectsTable {
|
|||||||
struct Rows: HTML, Sendable {
|
struct Rows: HTML, Sendable {
|
||||||
let projects: Page<Project>
|
let projects: Page<Project>
|
||||||
|
|
||||||
|
func tooltipPosition(_ n: Int) -> TooltipPosition {
|
||||||
|
if projects.metadata.page == 1 && projects.items.count == 1 {
|
||||||
|
return .left
|
||||||
|
} else if n == (projects.items.count - 1) {
|
||||||
|
return .left
|
||||||
|
} else {
|
||||||
|
return .bottom
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some HTML {
|
var body: some HTML {
|
||||||
for project in projects.items {
|
for (n, project) in projects.items.enumerated() {
|
||||||
tr(.id("\(project.id)")) {
|
tr(.id("\(project.id)")) {
|
||||||
td { DateView(project.createdAt) }
|
td { DateView(project.createdAt) }
|
||||||
td { "\(project.name)" }
|
td { "\(project.name)" }
|
||||||
td { "\(project.streetAddress)" }
|
td { "\(project.streetAddress)" }
|
||||||
td {
|
td {
|
||||||
div(.class("flex justify-end space-x-6")) {
|
div(.class("flex justify-end space-x-6")) {
|
||||||
TrashButton()
|
div(.class("join")) {
|
||||||
.attributes(
|
Tooltip("Delete project", position: tooltipPosition(n)) {
|
||||||
.hx.delete(route: .project(.delete(id: project.id))),
|
TrashButton()
|
||||||
.hx.confirm("Are you sure?"),
|
.attributes(
|
||||||
.hx.target("closest tr")
|
.class("join-item btn-ghost"),
|
||||||
)
|
.hx.delete(route: .project(.delete(id: project.id))),
|
||||||
a(
|
.hx.confirm("Are you sure?"),
|
||||||
.class("btn btn-success dark:text-white"),
|
.hx.target("closest tr")
|
||||||
.href(route: .project(.detail(project.id, .index())))
|
)
|
||||||
) { ">" }
|
}
|
||||||
|
Tooltip("View project", position: tooltipPosition(n)) {
|
||||||
|
a(
|
||||||
|
.class("join-item btn btn-success btn-ghost"),
|
||||||
|
.href(route: .project(.detail(project.id, .rooms(.index))))
|
||||||
|
) {
|
||||||
|
SVG(.chevronRight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,22 +8,41 @@ import Styleguide
|
|||||||
// TODO: Need to hold the project ID in hidden input field.
|
// TODO: Need to hold the project ID in hidden input field.
|
||||||
struct RoomForm: HTML, Sendable {
|
struct RoomForm: HTML, Sendable {
|
||||||
|
|
||||||
static let id = "roomForm"
|
static func id(_ room: Room? = nil) -> String {
|
||||||
|
let baseId = "roomForm"
|
||||||
|
guard let room else { return baseId }
|
||||||
|
return baseId.appending("_\(room.id.idString)")
|
||||||
|
}
|
||||||
|
|
||||||
let dismiss: Bool
|
let dismiss: Bool
|
||||||
let projectID: Project.ID
|
let projectID: Project.ID
|
||||||
let room: Room?
|
let room: Room?
|
||||||
|
|
||||||
|
init(
|
||||||
|
dismiss: Bool,
|
||||||
|
projectID: Project.ID,
|
||||||
|
room: Room? = nil
|
||||||
|
) {
|
||||||
|
self.dismiss = dismiss
|
||||||
|
self.projectID = projectID
|
||||||
|
self.room = room
|
||||||
|
}
|
||||||
|
|
||||||
|
var route: String {
|
||||||
|
SiteRoute.View.router.path(
|
||||||
|
for: .project(.detail(projectID, .rooms(.index)))
|
||||||
|
)
|
||||||
|
.appendingPath(room?.id)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some HTML {
|
var body: some HTML {
|
||||||
ModalForm(id: Self.id, dismiss: dismiss) {
|
ModalForm(id: Self.id(room), dismiss: dismiss) {
|
||||||
h1(.class("text-3xl font-bold pb-6")) { "Room" }
|
h1(.class("text-3xl font-bold pb-6")) { "Room" }
|
||||||
// TODO: Use htmx here.
|
|
||||||
form(
|
form(
|
||||||
.class("modal-backdrop"),
|
.class("grid grid-cols-1 gap-4"),
|
||||||
.init(name: "method", value: "dialog"),
|
|
||||||
room == nil
|
room == nil
|
||||||
? .hx.post(route: .project(.detail(projectID, .rooms(.index))))
|
? .hx.post(route)
|
||||||
: .hx.patch(route: .project(.detail(projectID, .rooms(.index)))),
|
: .hx.patch(route),
|
||||||
.hx.target("body"),
|
.hx.target("body"),
|
||||||
.hx.swap(.outerHTML)
|
.hx.swap(.outerHTML)
|
||||||
) {
|
) {
|
||||||
@@ -34,37 +53,56 @@ struct RoomForm: HTML, Sendable {
|
|||||||
input(.class("hidden"), .name("id"), .value("\(id)"))
|
input(.class("hidden"), .name("id"), .value("\(id)"))
|
||||||
}
|
}
|
||||||
|
|
||||||
div {
|
LabeledInput(
|
||||||
label(.for("name")) { "Name:" }
|
"Name",
|
||||||
Input(id: "name", placeholder: "Room Name")
|
.name("name"),
|
||||||
.attributes(.type(.text), .required, .autofocus, .value(room?.name))
|
.type(.text),
|
||||||
}
|
.placeholder("Name"),
|
||||||
div {
|
.required,
|
||||||
label(.for("heatingLoad")) { "Heating Load:" }
|
.autofocus,
|
||||||
Input(id: "heatingLoad", placeholder: "Heating Load")
|
.value(room?.name)
|
||||||
.attributes(.type(.number), .required, .min("0"), .value(room?.heatingLoad))
|
)
|
||||||
}
|
|
||||||
div {
|
LabeledInput(
|
||||||
label(.for("coolingTotal")) { "Cooling Total:" }
|
"Heating Load",
|
||||||
Input(id: "coolingTotal", placeholder: "Cooling Total")
|
.name("heatingLoad"),
|
||||||
.attributes(.type(.number), .required, .min("0"), .value(room?.coolingTotal))
|
.type(.number),
|
||||||
}
|
.placeholder("1234"),
|
||||||
div {
|
.required,
|
||||||
label(.for("coolingSensible")) { "Cooling Sensible:" }
|
.min("0"),
|
||||||
Input(id: "coolingSensible", placeholder: "Cooling Sensible (Optional)")
|
.value(room?.heatingLoad)
|
||||||
.attributes(.type(.number), .min("0"), .value(room?.coolingSensible))
|
)
|
||||||
}
|
|
||||||
div(.class("pb-6")) {
|
LabeledInput(
|
||||||
label(.for("registerCount")) { "Registers:" }
|
"Cooling Total",
|
||||||
Input(id: "registerCount", placeholder: "Register Count")
|
.name("coolingTotal"),
|
||||||
.attributes(
|
.type(.number),
|
||||||
.type(.number), .required, .min("0"),
|
.placeholder("1234"),
|
||||||
.value("\(room != nil ? room!.registerCount : 1)"),
|
.required,
|
||||||
)
|
.min("0"),
|
||||||
}
|
.value(room?.coolingTotal)
|
||||||
div(.class("flex justify-end space-x-4")) {
|
)
|
||||||
SubmitButton()
|
|
||||||
}
|
LabeledInput(
|
||||||
|
"Cooling Sensible",
|
||||||
|
.name("coolingSensible"),
|
||||||
|
.type(.number),
|
||||||
|
.placeholder("1234 (Optional)"),
|
||||||
|
.min("0"),
|
||||||
|
.value(room?.coolingSensible)
|
||||||
|
)
|
||||||
|
|
||||||
|
LabeledInput(
|
||||||
|
"Registers",
|
||||||
|
.name("registerCount"),
|
||||||
|
.type(.number),
|
||||||
|
.min("1"),
|
||||||
|
.required,
|
||||||
|
.value(room?.registerCount ?? 1)
|
||||||
|
)
|
||||||
|
|
||||||
|
SubmitButton()
|
||||||
|
.attributes(.class("btn-block"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,80 +5,112 @@ import Foundation
|
|||||||
import ManualDCore
|
import ManualDCore
|
||||||
import Styleguide
|
import Styleguide
|
||||||
|
|
||||||
// TODO: Calculate rooms sensible based on project wide SHR.
|
|
||||||
|
|
||||||
struct RoomsView: HTML, Sendable {
|
struct RoomsView: HTML, Sendable {
|
||||||
let projectID: Project.ID
|
@Environment(ProjectViewValue.$projectID) var projectID
|
||||||
|
// let projectID: Project.ID
|
||||||
let rooms: [Room]
|
let rooms: [Room]
|
||||||
let sensibleHeatRatio: Double?
|
let sensibleHeatRatio: Double?
|
||||||
|
|
||||||
var body: some HTML {
|
var body: some HTML {
|
||||||
div {
|
div(.class("flex w-full flex-col")) {
|
||||||
Row {
|
PageTitleRow {
|
||||||
h1(.class("text-2xl font-bold")) { "Room Loads" }
|
div(.class("flex grid grid-cols-3 w-full gap-y-4")) {
|
||||||
div(
|
|
||||||
.class("tooltip tooltip-left"),
|
div(.class("col-span-2")) {
|
||||||
.data("tip", value: "Add room")
|
PageTitle { "Room Loads" }
|
||||||
) {
|
}
|
||||||
button(
|
|
||||||
.showModal(id: RoomForm.id),
|
div(.class("flex justify-end grow")) {
|
||||||
.class("btn btn-primary w-[40px] text-2xl")
|
Tooltip("Project wide sensible heat ratio", position: .left) {
|
||||||
) {
|
button(
|
||||||
"+"
|
.class(
|
||||||
|
"""
|
||||||
|
btn btn-primary text-lg font-bold py-2
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
.showModal(id: SHRForm.id)
|
||||||
|
) {
|
||||||
|
div(.class("flex grow justify-end items-end space-x-4")) {
|
||||||
|
span {
|
||||||
|
"Sensible Heat Ratio"
|
||||||
|
}
|
||||||
|
if let sensibleHeatRatio {
|
||||||
|
Badge(number: sensibleHeatRatio)
|
||||||
|
} else {
|
||||||
|
Badge { "set" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.attributes(.class("border border-error"), when: sensibleHeatRatio == nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div(.class("flex items-end space-x-4 font-bold")) {
|
||||||
|
span(.class("text-lg")) { "Heating Total" }
|
||||||
|
Badge(number: rooms.heatingTotal, digits: 0)
|
||||||
|
.attributes(.class("badge-error"))
|
||||||
|
}
|
||||||
|
|
||||||
|
div(.class("flex justify-center items-end space-x-4 my-auto font-bold")) {
|
||||||
|
span(.class("text-lg")) { "Cooling Total" }
|
||||||
|
Badge(number: rooms.coolingTotal, digits: 0)
|
||||||
|
.attributes(.class("badge-success"))
|
||||||
|
}
|
||||||
|
|
||||||
|
div(.class("flex grow justify-end items-end space-x-4 me-4 my-auto font-bold")) {
|
||||||
|
span(.class("text-lg")) { "Cooling Sensible" }
|
||||||
|
Badge(number: rooms.coolingSensible(shr: sensibleHeatRatio), digits: 0)
|
||||||
|
.attributes(.class("badge-info"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.attributes(.class("pb-6"))
|
|
||||||
|
|
||||||
div(.class("border rounded-lg mb-6")) {
|
SHRForm(
|
||||||
Row {
|
sensibleHeatRatio: sensibleHeatRatio,
|
||||||
div(.class("space-x-6")) {
|
dismiss: sensibleHeatRatio != nil
|
||||||
Label("Sensible Heat Ratio")
|
)
|
||||||
if let sensibleHeatRatio {
|
|
||||||
Number(sensibleHeatRatio)
|
table(.class("table table-zebra text-lg"), .id("roomsTable")) {
|
||||||
|
thead {
|
||||||
|
tr(.class("text-lg font-bold")) {
|
||||||
|
th { "Name" }
|
||||||
|
th {
|
||||||
|
div(.class("flex justify-center")) {
|
||||||
|
"Heating Load"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
div(.class("flex justify-center")) {
|
||||||
|
"Cooling Total"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
div(.class("flex justify-center")) {
|
||||||
|
"Cooling Sensible"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
div(.class("flex justify-center")) {
|
||||||
|
"Register Count"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
th {
|
||||||
|
div(.class("flex justify-end me-2")) {
|
||||||
|
Tooltip("Add Room") {
|
||||||
|
PlusButton()
|
||||||
|
.attributes(
|
||||||
|
.class("btn-primary mx-auto"),
|
||||||
|
.showModal(id: RoomForm.id())
|
||||||
|
)
|
||||||
|
.attributes(.class("tooltip-left"))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
EditButton()
|
|
||||||
.attributes(.showModal(id: SHRForm.id))
|
|
||||||
}
|
}
|
||||||
.attributes(.class("m-4"))
|
tbody {
|
||||||
|
for room in rooms {
|
||||||
SHRForm(projectID: projectID, sensibleHeatRatio: sensibleHeatRatio)
|
RoomRow(room: room, shr: sensibleHeatRatio)
|
||||||
}
|
|
||||||
|
|
||||||
div(.class("overflow-x-auto rounded-box border")) {
|
|
||||||
table(.class("table table-zebra"), .id("roomsTable")) {
|
|
||||||
thead {
|
|
||||||
tr {
|
|
||||||
th { Label("Name") }
|
|
||||||
th { Label("Heating Load") }
|
|
||||||
th { Label("Cooling Total") }
|
|
||||||
th { Label("Register Count") }
|
|
||||||
th {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tbody {
|
|
||||||
div(.id("rooms")) {
|
|
||||||
for room in rooms {
|
|
||||||
RoomRow(room: room)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// TOTALS
|
|
||||||
tr(.class("font-bold text-xl")) {
|
|
||||||
td { Label("Total") }
|
|
||||||
td {
|
|
||||||
Number(rooms.heatingTotal)
|
|
||||||
.attributes(.class("badge badge-outline badge-error badge-xl"))
|
|
||||||
}
|
|
||||||
td {
|
|
||||||
Number(rooms.coolingTotal)
|
|
||||||
.attributes(
|
|
||||||
.class("badge badge-outline badge-success badge-xl"))
|
|
||||||
}
|
|
||||||
td {}
|
|
||||||
td {}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,44 +119,77 @@ struct RoomsView: HTML, Sendable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public struct RoomRow: HTML, Sendable {
|
public struct RoomRow: HTML, Sendable {
|
||||||
|
|
||||||
let room: Room
|
let room: Room
|
||||||
|
let shr: Double
|
||||||
|
|
||||||
|
var coolingSensible: Double {
|
||||||
|
guard let value = room.coolingSensible else {
|
||||||
|
return room.coolingTotal * shr
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
init(room: Room, shr: Double?) {
|
||||||
|
self.room = room
|
||||||
|
self.shr = shr ?? 1.0
|
||||||
|
}
|
||||||
|
|
||||||
public var body: some HTML {
|
public var body: some HTML {
|
||||||
tr(.id("\(room.id)")) {
|
tr(.id("roomRow_\(room.id.idString)")) {
|
||||||
td { room.name }
|
td { room.name }
|
||||||
td {
|
td {
|
||||||
Number(room.heatingLoad)
|
div(.class("flex justify-center")) {
|
||||||
.attributes(.class("text-error"))
|
Number(room.heatingLoad, digits: 0)
|
||||||
}
|
// .attributes(.class("text-error"))
|
||||||
td {
|
|
||||||
Number(room.coolingTotal)
|
|
||||||
.attributes(.class("text-success"))
|
|
||||||
}
|
|
||||||
// FIX: Add cooling sensible.
|
|
||||||
td {
|
|
||||||
Number(room.registerCount)
|
|
||||||
}
|
|
||||||
td {
|
|
||||||
div(.class("flex justify-end space-x-6")) {
|
|
||||||
TrashButton()
|
|
||||||
.attributes(
|
|
||||||
.hx.delete(
|
|
||||||
route: .project(.detail(room.projectID, .rooms(.delete(id: room.id))))),
|
|
||||||
.hx.target("closest tr"),
|
|
||||||
.hx.confirm("Are you sure?")
|
|
||||||
)
|
|
||||||
EditButton()
|
|
||||||
.attributes(
|
|
||||||
.hx.get(
|
|
||||||
route: .project(
|
|
||||||
.detail(room.projectID, .rooms(.form(id: room.id, dismiss: false)))
|
|
||||||
)
|
|
||||||
),
|
|
||||||
.hx.target("#roomForm"),
|
|
||||||
.hx.swap(.outerHTML)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
td {
|
||||||
|
div(.class("flex justify-center")) {
|
||||||
|
Number(room.coolingTotal, digits: 0)
|
||||||
|
// .attributes(.class("text-success"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
div(.class("flex justify-center")) {
|
||||||
|
Number(coolingSensible, digits: 0)
|
||||||
|
// .attributes(.class("text-info"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
div(.class("flex justify-center")) {
|
||||||
|
Number(room.registerCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
div(.class("flex justify-end")) {
|
||||||
|
div(.class("join")) {
|
||||||
|
Tooltip("Delete room", position: .bottom) {
|
||||||
|
TrashButton()
|
||||||
|
.attributes(
|
||||||
|
.class("join-item btn-ghost"),
|
||||||
|
.hx.delete(
|
||||||
|
route: .project(.detail(room.projectID, .rooms(.delete(id: room.id))))),
|
||||||
|
.hx.target("closest tr"),
|
||||||
|
.hx.confirm("Are you sure?")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Tooltip("Edit room", position: .bottom) {
|
||||||
|
EditButton()
|
||||||
|
.attributes(
|
||||||
|
.class("join-item btn-ghost"),
|
||||||
|
.showModal(id: RoomForm.id(room))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RoomForm(
|
||||||
|
dismiss: true,
|
||||||
|
projectID: room.projectID,
|
||||||
|
room: room
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -132,27 +197,41 @@ struct RoomsView: HTML, Sendable {
|
|||||||
struct SHRForm: HTML, Sendable {
|
struct SHRForm: HTML, Sendable {
|
||||||
static let id = "shrForm"
|
static let id = "shrForm"
|
||||||
|
|
||||||
let projectID: Project.ID
|
@Environment(ProjectViewValue.$projectID) var projectID
|
||||||
let sensibleHeatRatio: Double?
|
let sensibleHeatRatio: Double?
|
||||||
|
let dismiss: Bool
|
||||||
|
|
||||||
|
var route: String {
|
||||||
|
SiteRoute.View.router
|
||||||
|
.path(for: .project(.detail(projectID, .rooms(.index))))
|
||||||
|
.appendingPath("update-shr")
|
||||||
|
}
|
||||||
|
|
||||||
var body: some HTML {
|
var body: some HTML {
|
||||||
ModalForm(id: Self.id, dismiss: true) {
|
ModalForm(id: Self.id, dismiss: dismiss) {
|
||||||
|
h1(.class("text-xl font-bold mb-6")) {
|
||||||
|
"Sensible Heat Ratio"
|
||||||
|
}
|
||||||
form(
|
form(
|
||||||
.class("space-y-6"),
|
.class("grid grid-cols-1 gap-4"),
|
||||||
.hx.patch("/projects/\(projectID)/rooms/update-shr"),
|
.hx.patch(route),
|
||||||
.hx.target("body"),
|
.hx.target("body"),
|
||||||
.hx.swap(.outerHTML)
|
.hx.swap(.outerHTML)
|
||||||
) {
|
) {
|
||||||
input(.class("hidden"), .name("projectID"), .value("\(projectID)"))
|
input(.class("hidden"), .name("projectID"), .value("\(projectID)"))
|
||||||
div {
|
LabeledInput(
|
||||||
label(.for("sensibleHeatRatio")) { "Sensible Heat Ratio" }
|
"SHR",
|
||||||
Input(id: "sensibleHeatRatio", placeholder: "Sensible Heat Ratio")
|
.name("sensibleHeatRatio"),
|
||||||
.attributes(.min("0"), .max("1"), .step("0.01"), .value(sensibleHeatRatio))
|
.type(.number),
|
||||||
}
|
.value(sensibleHeatRatio),
|
||||||
div {
|
.placeholder("0.83"),
|
||||||
SubmitButton()
|
.min("0"),
|
||||||
.attributes(.class("btn-block"))
|
.max("1"),
|
||||||
}
|
.step("0.01"),
|
||||||
|
.autofocus
|
||||||
|
)
|
||||||
|
SubmitButton()
|
||||||
|
.attributes(.class("btn-block my-6"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,4 +246,13 @@ extension Array where Element == Room {
|
|||||||
var coolingTotal: Double {
|
var coolingTotal: Double {
|
||||||
reduce(into: 0) { $0 += $1.coolingTotal }
|
reduce(into: 0) { $0 += $1.coolingTotal }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func coolingSensible(shr: Double?) -> Double {
|
||||||
|
let shr = shr ?? 1.0
|
||||||
|
|
||||||
|
return reduce(into: 0) {
|
||||||
|
let sensible = $1.coolingSensible ?? ($1.coolingTotal * shr)
|
||||||
|
$0 += sensible
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
Sources/ViewController/Views/TestPage.swift
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import Dependencies
|
||||||
|
import Elementary
|
||||||
|
import Foundation
|
||||||
|
import ManualDCore
|
||||||
|
import Styleguide
|
||||||
|
|
||||||
|
struct TestPage: HTML, Sendable {
|
||||||
|
let trunks: [DuctSizing.TrunkContainer]
|
||||||
|
let rooms: [DuctSizing.RoomContainer]
|
||||||
|
|
||||||
|
var body: some HTML {
|
||||||
|
div(.class("overflow-auto")) {
|
||||||
|
DuctSizingView.TrunkTable(trunks: trunks, rooms: rooms)
|
||||||
|
|
||||||
|
Row {
|
||||||
|
h2(.class("text-2xl font-bold")) { "Trunk Sizes" }
|
||||||
|
|
||||||
|
PlusButton()
|
||||||
|
.attributes(
|
||||||
|
.class("me-6"),
|
||||||
|
.showModal(id: TrunkSizeForm.id())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.attributes(.class("mt-6"))
|
||||||
|
|
||||||
|
div(.class("divider -mt-2")) {}
|
||||||
|
|
||||||
|
DuctSizingView.TrunkTable(trunks: trunks, rooms: rooms)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,30 +25,12 @@ struct LoginForm: HTML, Sendable {
|
|||||||
input(.class("hidden"), .name("next"), .value(next))
|
input(.class("hidden"), .name("next"), .value(next))
|
||||||
}
|
}
|
||||||
|
|
||||||
if style == .signup {
|
|
||||||
div {
|
|
||||||
label(.class("input validator w-full")) {
|
|
||||||
SVG(.user)
|
|
||||||
input(
|
|
||||||
.type(.text), .required, .placeholder("Username"),
|
|
||||||
.name("username"), .id("username"),
|
|
||||||
.minlength("3"), .pattern(.username)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
div(.class("validator-hint hidden")) {
|
|
||||||
"Enter valid username"
|
|
||||||
br()
|
|
||||||
"Must be at least 3 characters"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
div {
|
div {
|
||||||
label(.class("input validator w-full")) {
|
label(.class("input validator w-full")) {
|
||||||
SVG(.email)
|
SVG(.email)
|
||||||
input(
|
input(
|
||||||
.type(.email), .placeholder("Email"), .required,
|
.type(.email), .placeholder("Email"), .required,
|
||||||
.name("email"), .id("email"),
|
.name("email"), .id("email"), .autofocus
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
div(.class("validator-hint hidden")) { "Enter valid email address." }
|
div(.class("validator-hint hidden")) { "Enter valid email address." }
|
||||||
|
|||||||
155
Sources/ViewController/Views/User/UserProfileForm.swift
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import Elementary
|
||||||
|
import ElementaryHTMX
|
||||||
|
import ManualDCore
|
||||||
|
import Styleguide
|
||||||
|
|
||||||
|
struct UserProfileForm: HTML, Sendable {
|
||||||
|
|
||||||
|
static func id(_ profile: User.Profile?) -> String {
|
||||||
|
let base = "userProfileForm"
|
||||||
|
guard let profile else { return base }
|
||||||
|
return "\(base)_\(profile.id.idString)"
|
||||||
|
}
|
||||||
|
|
||||||
|
let userID: User.ID
|
||||||
|
let profile: User.Profile?
|
||||||
|
let dismiss: Bool
|
||||||
|
let signup: Bool
|
||||||
|
|
||||||
|
init(
|
||||||
|
userID: User.ID,
|
||||||
|
profile: User.Profile? = nil,
|
||||||
|
dismiss: Bool,
|
||||||
|
signup: Bool = false
|
||||||
|
) {
|
||||||
|
self.userID = userID
|
||||||
|
self.profile = profile
|
||||||
|
self.dismiss = dismiss
|
||||||
|
self.signup = signup
|
||||||
|
}
|
||||||
|
|
||||||
|
var route: String {
|
||||||
|
guard !signup else {
|
||||||
|
return SiteRoute.View.router.path(for: .signup(.index))
|
||||||
|
.appendingPath("profile")
|
||||||
|
}
|
||||||
|
return SiteRoute.View.router.path(for: .user(.profile(.index)))
|
||||||
|
.appendingPath(profile?.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some HTML {
|
||||||
|
ModalForm(id: Self.id(profile), closeButton: dismiss, dismiss: dismiss) {
|
||||||
|
|
||||||
|
h1(.class("text-xl font-bold pb-6")) { "Profile" }
|
||||||
|
|
||||||
|
form(
|
||||||
|
.class("grid grid-cols-1 gap-4 p-4"),
|
||||||
|
profile == nil
|
||||||
|
? .hx.post(route)
|
||||||
|
: .hx.patch(route),
|
||||||
|
.hx.target("body"),
|
||||||
|
.hx.swap(.outerHTML)
|
||||||
|
) {
|
||||||
|
if let profile {
|
||||||
|
input(.class("hidden"), .name("id"), .value(profile.id))
|
||||||
|
}
|
||||||
|
input(.class("hidden"), .name("userID"), .value(userID))
|
||||||
|
|
||||||
|
label(.class("input w-full")) {
|
||||||
|
span(.class("label")) { "First Name" }
|
||||||
|
input(.name("firstName"), .value(profile?.firstName), .required, .autofocus)
|
||||||
|
}
|
||||||
|
|
||||||
|
label(.class("input w-full")) {
|
||||||
|
span(.class("label")) { "Last Name" }
|
||||||
|
input(.name("lastName"), .value(profile?.lastName), .required)
|
||||||
|
}
|
||||||
|
|
||||||
|
label(.class("input w-full")) {
|
||||||
|
span(.class("label")) { "Company" }
|
||||||
|
input(.name("companyName"), .value(profile?.companyName), .required)
|
||||||
|
}
|
||||||
|
|
||||||
|
label(.class("input w-full")) {
|
||||||
|
span(.class("label")) { "Address" }
|
||||||
|
input(.name("streetAddress"), .value(profile?.streetAddress), .required)
|
||||||
|
}
|
||||||
|
|
||||||
|
label(.class("input w-full")) {
|
||||||
|
span(.class("label")) { "City" }
|
||||||
|
input(.name("city"), .value(profile?.city), .required)
|
||||||
|
}
|
||||||
|
|
||||||
|
label(.class("input w-full")) {
|
||||||
|
span(.class("label")) { "State" }
|
||||||
|
input(.name("state"), .value(profile?.state), .required)
|
||||||
|
}
|
||||||
|
|
||||||
|
label(.class("input w-full")) {
|
||||||
|
span(.class("label")) { "Zip" }
|
||||||
|
input(.name("zipCode"), .value(profile?.zipCode), .required)
|
||||||
|
}
|
||||||
|
|
||||||
|
div(.class("dropdown dropdown-top")) {
|
||||||
|
div(.class("input btn m-1 w-full"), .tabindex(0), .role(.init(rawValue: "button"))) {
|
||||||
|
"Theme"
|
||||||
|
SVG(.chevronDown)
|
||||||
|
}
|
||||||
|
ul(
|
||||||
|
.tabindex(-1),
|
||||||
|
.class("dropdown-content bg-base-300 rounded-box z-1 p-2 shadow-2xl")
|
||||||
|
) {
|
||||||
|
li {
|
||||||
|
input(
|
||||||
|
.type(.radio),
|
||||||
|
.name("theme"),
|
||||||
|
.class("theme-controller w-full btn btn-sm btn-block btn-ghost justify-start"),
|
||||||
|
.init(name: "aria-label", value: "Default"),
|
||||||
|
.value("default")
|
||||||
|
)
|
||||||
|
.attributes(.checked, when: profile?.theme == .default)
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
span(.class("text-sm font-bold text-gray-400")) {
|
||||||
|
"Light"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for theme in Theme.lightThemes {
|
||||||
|
li {
|
||||||
|
input(
|
||||||
|
.type(.radio),
|
||||||
|
.name("theme"),
|
||||||
|
.class("theme-controller w-full btn btn-sm btn-block btn-ghost justify-start"),
|
||||||
|
.init(name: "aria-label", value: "\(theme.rawValue.capitalized)"),
|
||||||
|
.value(theme.rawValue)
|
||||||
|
)
|
||||||
|
.attributes(.checked, when: profile?.theme == theme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
span(.class("text-sm font-bold text-gray-400")) {
|
||||||
|
"Dark"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for theme in Theme.darkThemes {
|
||||||
|
li {
|
||||||
|
input(
|
||||||
|
.type(.radio),
|
||||||
|
.name("theme"),
|
||||||
|
.class("theme-controller w-full btn btn-sm btn-block btn-ghost justify-start"),
|
||||||
|
.init(name: "aria-label", value: "\(theme.rawValue.capitalized)"),
|
||||||
|
.value(theme.rawValue)
|
||||||
|
)
|
||||||
|
.attributes(.checked, when: profile?.theme == theme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SubmitButton()
|
||||||
|
.attributes(.class("btn-block"))
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
57
Sources/ViewController/Views/User/UserView.swift
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import Elementary
|
||||||
|
import ManualDCore
|
||||||
|
import Styleguide
|
||||||
|
|
||||||
|
struct UserView: HTML, Sendable {
|
||||||
|
let user: User
|
||||||
|
let profile: User.Profile?
|
||||||
|
|
||||||
|
var body: some HTML {
|
||||||
|
div {
|
||||||
|
Navbar(sidebarToggle: false, userProfile: false)
|
||||||
|
|
||||||
|
div(.class("p-4")) {
|
||||||
|
Row {
|
||||||
|
h1(.class("text-2xl font-bold")) { "Account" }
|
||||||
|
EditButton()
|
||||||
|
.attributes(.showModal(id: UserProfileForm.id(profile)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let profile {
|
||||||
|
table(.class("table table-zebra border rounded-lg")) {
|
||||||
|
tr {
|
||||||
|
td { Label("Name") }
|
||||||
|
td { "\(profile.firstName) \(profile.lastName)" }
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
td { Label("Company") }
|
||||||
|
td { profile.companyName }
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
td { Label("Street Address") }
|
||||||
|
td { profile.streetAddress }
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
td { Label("City") }
|
||||||
|
td { profile.city }
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
td { Label("State") }
|
||||||
|
td { profile.state }
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
td { Label("Zip Code") }
|
||||||
|
td { profile.zipCode }
|
||||||
|
}
|
||||||
|
tr {
|
||||||
|
td { Label("Theme") }
|
||||||
|
td { profile.theme?.rawValue ?? "" }
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UserProfileForm(userID: user.id, profile: profile, dismiss: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,4 +72,33 @@ struct ProjectRouteTests {
|
|||||||
let route = try router.parse(&request)
|
let route = try router.parse(&request)
|
||||||
#expect(route == .project(.index))
|
#expect(route == .project(.index))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
func formData() throws {
|
||||||
|
let p = Body {
|
||||||
|
FormData {
|
||||||
|
Optionally {
|
||||||
|
Field("id", default: nil) { EffectiveLength.ID.parser() }
|
||||||
|
}
|
||||||
|
Field("name", .string)
|
||||||
|
Field("type") { EffectiveLength.EffectiveLengthType.parser() }
|
||||||
|
Many {
|
||||||
|
Field("straightLengths") {
|
||||||
|
Int.parser()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.map(.memberwise(SiteRoute.View.ProjectRoute.EquivalentLengthRoute.StepTwo.init))
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequestData(
|
||||||
|
body: .init(
|
||||||
|
"name=Test&type=supply&straightLengths=20&straightLengths=10"
|
||||||
|
.utf8
|
||||||
|
)
|
||||||
|
)
|
||||||
|
let value = try p.parse(&request)
|
||||||
|
print(value)
|
||||||
|
#expect(value.straightLengths == [20, 10])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
11
justfile
@@ -1,4 +1,5 @@
|
|||||||
docker_image := "manuald"
|
docker_image := "ductcalc"
|
||||||
|
docker_tag := "latest"
|
||||||
|
|
||||||
install-deps:
|
install-deps:
|
||||||
@curl -sL daisyui.com/fast | bash
|
@curl -sL daisyui.com/fast | bash
|
||||||
@@ -9,8 +10,8 @@ run-css:
|
|||||||
run:
|
run:
|
||||||
@swift run App serve --log debug
|
@swift run App serve --log debug
|
||||||
|
|
||||||
build-docker:
|
build-docker file="docker/Dockerfile":
|
||||||
@podman build -f docker/Dockerfile.dev -t {{docker_image}}:dev .
|
@podman build -f {{file}} -t {{docker_image}}:{{docker_tag}} .
|
||||||
|
|
||||||
run-dev:
|
run-docker:
|
||||||
@podman run -it --rm -v $PWD:/app -p 8080:8080 {{docker_image}}:dev
|
@podman run -it --rm -v $PWD:/app -p 8080:8080 {{docker_image}}:{{docker_tag}}
|
||||||
|
|||||||