Add lead workflows, PDF signing, AR projects and photo gallery integration

This commit is contained in:
Dennis Nemec
2026-11-08 17:49:39 +01:00
parent a343bde741
commit 8d18873412
82 changed files with 6401 additions and 34 deletions

View File

@ -0,0 +1,32 @@
import Foundation
/// Neutral projection for project selection. Persistence and customer associations
/// belong to the host application's service.
struct ARProject: Identifiable, Equatable {
let id: UUID
let name: String
let tank: Tank
let createdAt: Date
let source: Source
let reference: String?
enum Source { case supplied, custom }
}
@MainActor
protocol ARProjectsProviding {
func updateProject(id: UUID, name: String, tank: Tank) throws -> ARProject
func deleteProject(id: UUID) throws
func loadProjects() throws -> [ARProject]
func createProject(name: String, tank: Tank) throws -> ARProject
}
nonisolated extension TankSize {
var volumeTitle: String {
switch self {
case .t1_2: "2.700 l"
case .t2_1: "4.850 l"
case .t2_9: "6.400 l"
}
}
}

View File

@ -0,0 +1,36 @@
import Foundation
struct Lead: Identifiable, Equatable, Sendable {
let id: String
let companyName: String?
let contact: LeadContact?
let address: LeadAddress
let phoneNumber: String?
let createdAt: Date
let offerCount: Int
let unsignedContractCount: Int
var isBookmarked: Bool
}
struct LeadContact: Equatable, Sendable {
let title: String?
let firstName: String
let lastName: String
}
struct LeadAddress: Equatable, Sendable {
let street: String?
let postalCode: String
let city: String
}
enum LeadError: Error, Equatable, Sendable {
case offline
case timeout
case serviceUnavailable
case unauthorized
case forbidden
case notFound(leadID: String)
case invalidResponse
case unexpected
}

View File

@ -0,0 +1,77 @@
import Foundation
struct LeadDetail: Equatable, Sendable {
let lead: Lead
let customerType: String
let contactName: String?
let contactRole: String?
let mobile: String?
let email: String?
let tankOptions: [LeadTankOption]
var offers: [LeadOffer]
let photos: [LeadPhoto]
}
struct LeadTankOption: Identifiable, Equatable, Sendable {
let id: String
let title: String
let tank: Tank
}
struct LeadOffer: Identifiable, Equatable, Sendable {
let id: String
let title: String
let kind: LeadOfferKind
let amount: Decimal
let billing: LeadBilling
let validUntil: Date?
let acceptedAt: Date?
var groups: [LeadDocumentGroup]
var tank: Tank? = nil
var documents: [LeadDocument] { groups.flatMap(\.documents) }
var signedCount: Int { documents.filter { $0.status == .signed }.count }
var isComplete: Bool { !documents.isEmpty && signedCount == documents.count }
}
enum LeadOfferKind: String, Sendable { case purchase, rental, service }
enum LeadBilling: String, Sendable { case once, monthly, annually }
struct LeadDocumentGroup: Identifiable, Equatable, Sendable {
let id: String
let title: String
var documents: [LeadDocument]
var signedCount: Int { documents.filter { $0.status == .signed }.count }
var isComplete: Bool { !documents.isEmpty && signedCount == documents.count }
}
struct LeadDocument: Identifiable, Equatable, Sendable {
let id: String
let title: String
let pageCount: Int
var status: LeadSignatureStatus
let file: LeadFileReference
var signaturePlacement: PDFSignaturePlacement? = nil
}
enum LeadSignatureStatus: String, Sendable { case pending, signed }
struct LeadPhoto: Identifiable, Equatable, Sendable {
let id: String
let title: String
let isAR: Bool
let thumbnail: LeadFileReference
let original: LeadFileReference
}
struct LeadFileReference: Hashable, Sendable {
let id: String
let version: String
let kind: LeadFileKind
}
enum LeadFileKind: String, Sendable { case pdf, image }
enum LeadFileError: Error, Equatable {
case notFound, invalidFile, storageUnavailable
}

View File

@ -0,0 +1,34 @@
import Foundation
@MainActor
protocol LeadDetailRepository {
func detail(id: String, salesCode: String) async throws -> LeadDetail
func file(_ reference: LeadFileReference, leadID: String, salesCode: String) async throws -> URL
}
struct GetLeadDetailUseCase {
let repository: any LeadDetailRepository
func execute(id: String, salesCode: String) async throws -> LeadDetail {
try await repository.detail(id: id, salesCode: salesCode)
}
}
struct DownloadLeadDocumentUseCase {
let repository: any LeadDetailRepository
func execute(_ document: LeadDocument, leadID: String, salesCode: String) async throws -> URL {
guard document.file.kind == .pdf else { throw LeadFileError.invalidFile }
return try await repository.file(document.file, leadID: leadID, salesCode: salesCode)
}
}
struct LoadLeadPhotoUseCase {
let repository: any LeadDetailRepository
func execute(_ photo: LeadPhoto, original: Bool = false,
leadID: String, salesCode: String) async throws -> URL {
try await repository.file(original ? photo.original : photo.thumbnail,
leadID: leadID, salesCode: salesCode)
}
}

View File

@ -0,0 +1,23 @@
import Foundation
struct LeadQuery: Equatable, Sendable {
var searchText = ""
var sort: LeadSort = .newest
var filter: LeadFilter = .all
var normalized: LeadQuery {
var query = self
query.searchText = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
return query
}
}
enum LeadSort: Sendable {
case newest
case nameAscending
}
enum LeadFilter: Sendable {
case all
case unsignedContracts
}

View File

@ -0,0 +1,8 @@
import Foundation
@MainActor
protocol LeadRepository {
func leads(for salesCode: String, query: LeadQuery) async throws -> [Lead]
func lead(id: String, salesCode: String) async throws -> Lead
func setBookmark(_ bookmarked: Bool, leadID: String, salesCode: String) async throws -> Lead
}

View File

@ -0,0 +1,33 @@
import Foundation
struct LoadLeadsUseCase {
let repository: any LeadRepository
func execute(salesCode: String) async throws -> [Lead] {
try await repository.leads(for: salesCode, query: LeadQuery())
}
}
struct SearchLeadsUseCase {
let repository: any LeadRepository
func execute(salesCode: String, query: LeadQuery) async throws -> [Lead] {
try await repository.leads(for: salesCode, query: query.normalized)
}
}
struct SetLeadBookmarkUseCase {
let repository: any LeadRepository
func execute(_ bookmarked: Bool, leadID: String, salesCode: String) async throws -> Lead {
try await repository.setBookmark(bookmarked, leadID: leadID, salesCode: salesCode)
}
}
struct GetLeadUseCase {
let repository: any LeadRepository
func execute(id: String, salesCode: String) async throws -> Lead {
try await repository.lead(id: id, salesCode: salesCode)
}
}