Get new frontend to type check clean

Epic undertaking.
This commit is contained in:
Eli Ribble 2026-03-31 14:52:53 +00:00
parent 6f9a511874
commit 21b7b68f50
No known key found for this signature in database
52 changed files with 1616 additions and 1126 deletions

View file

@ -28,6 +28,7 @@ type reviewTask struct {
}
type reviewTaskPool struct {
Condition string `json:"condition"`
Site types.Site `json:"site"`
}
type contentListReviewTask struct {
Tasks []reviewTask `json:"tasks"`

View file

@ -17,7 +17,7 @@ type contentURLAPI struct {
ReviewTask string `json:"review_task"`
Signal string `json:"signal"`
Upload string `json:"upload"`
Users string `json:"users"`
User string `json:"users"`
}
type contentURLs struct {
API contentURLAPI `json:"api"`
@ -51,7 +51,7 @@ func getUserSelf(ctx context.Context, r *http.Request, user platform.User, query
ReviewTask: config.MakeURLNidus("/api/review-task"),
Signal: config.MakeURLNidus("/api/signal"),
Upload: config.MakeURLNidus("/api/upload"),
Users: config.MakeURLNidus("/api/user"),
User: config.MakeURLNidus("/api/user"),
},
Tegola: urls.Tegola,
Tile: config.MakeURLNidus("/api/tile/{z}/{y}/{x}"),
@ -96,6 +96,7 @@ func listUserSuggestion(ctx context.Context, r *http.Request, user platform.User
}, nil
}
func userPut(ctx context.Context, r *http.Request, user platform.User, updates platform.User) {
func userPut(ctx context.Context, r *http.Request, user platform.User, updates platform.User) (string, *nhttp.ErrorWithStatus) {
//if updates.Avatar
return "", nil
}

View file

@ -132,6 +132,42 @@ var Users = Table[
Generated: false,
AutoIncr: false,
},
IsActive: column{
Name: "is_active",
DBType: "boolean",
Default: "",
Comment: "",
Nullable: false,
Generated: false,
AutoIncr: false,
},
IsDronePilot: column{
Name: "is_drone_pilot",
DBType: "boolean",
Default: "NULL",
Comment: "",
Nullable: true,
Generated: false,
AutoIncr: false,
},
IsWarrant: column{
Name: "is_warrant",
DBType: "boolean",
Default: "NULL",
Comment: "",
Nullable: true,
Generated: false,
AutoIncr: false,
},
Avatar: column{
Name: "avatar",
DBType: "uuid",
Default: "NULL",
Comment: "",
Nullable: true,
Generated: false,
AutoIncr: false,
},
},
Indexes: userIndexes{
UserPkey: index{
@ -210,11 +246,15 @@ type userColumns struct {
PasswordHashType column
PasswordHash column
Role column
IsActive column
IsDronePilot column
IsWarrant column
Avatar column
}
func (c userColumns) AsSlice() []column {
return []column{
c.ID, c.ArcgisAccessToken, c.ArcgisLicense, c.ArcgisRefreshToken, c.ArcgisRefreshTokenExpires, c.ArcgisRole, c.DisplayName, c.Email, c.OrganizationID, c.Username, c.PasswordHashType, c.PasswordHash, c.Role,
c.ID, c.ArcgisAccessToken, c.ArcgisLicense, c.ArcgisRefreshToken, c.ArcgisRefreshTokenExpires, c.ArcgisRole, c.DisplayName, c.Email, c.OrganizationID, c.Username, c.PasswordHashType, c.PasswordHash, c.Role, c.IsActive, c.IsDronePilot, c.IsWarrant, c.Avatar,
}
}

View file

@ -22,6 +22,7 @@ import (
"github.com/aarondl/opt/null"
"github.com/aarondl/opt/omit"
"github.com/aarondl/opt/omitnull"
"github.com/google/uuid"
)
// User is an object representing the database table.
@ -39,6 +40,10 @@ type User struct {
PasswordHashType enums.Hashtype `db:"password_hash_type" `
PasswordHash string `db:"password_hash" `
Role enums.Userrole `db:"role" `
IsActive bool `db:"is_active" `
IsDronePilot null.Val[bool] `db:"is_drone_pilot" `
IsWarrant null.Val[bool] `db:"is_warrant" `
Avatar null.Val[uuid.UUID] `db:"avatar" `
R userR `db:"-" `
}
@ -88,7 +93,7 @@ type userR struct {
func buildUserColumns(alias string) userColumns {
return userColumns{
ColumnsExpr: expr.NewColumnsExpr(
"id", "arcgis_access_token", "arcgis_license", "arcgis_refresh_token", "arcgis_refresh_token_expires", "arcgis_role", "display_name", "email", "organization_id", "username", "password_hash_type", "password_hash", "role",
"id", "arcgis_access_token", "arcgis_license", "arcgis_refresh_token", "arcgis_refresh_token_expires", "arcgis_role", "display_name", "email", "organization_id", "username", "password_hash_type", "password_hash", "role", "is_active", "is_drone_pilot", "is_warrant", "avatar",
).WithParent("user_"),
tableAlias: alias,
ID: psql.Quote(alias, "id"),
@ -104,6 +109,10 @@ func buildUserColumns(alias string) userColumns {
PasswordHashType: psql.Quote(alias, "password_hash_type"),
PasswordHash: psql.Quote(alias, "password_hash"),
Role: psql.Quote(alias, "role"),
IsActive: psql.Quote(alias, "is_active"),
IsDronePilot: psql.Quote(alias, "is_drone_pilot"),
IsWarrant: psql.Quote(alias, "is_warrant"),
Avatar: psql.Quote(alias, "avatar"),
}
}
@ -123,6 +132,10 @@ type userColumns struct {
PasswordHashType psql.Expression
PasswordHash psql.Expression
Role psql.Expression
IsActive psql.Expression
IsDronePilot psql.Expression
IsWarrant psql.Expression
Avatar psql.Expression
}
func (c userColumns) Alias() string {
@ -150,10 +163,14 @@ type UserSetter struct {
PasswordHashType omit.Val[enums.Hashtype] `db:"password_hash_type" `
PasswordHash omit.Val[string] `db:"password_hash" `
Role omit.Val[enums.Userrole] `db:"role" `
IsActive omit.Val[bool] `db:"is_active" `
IsDronePilot omitnull.Val[bool] `db:"is_drone_pilot" `
IsWarrant omitnull.Val[bool] `db:"is_warrant" `
Avatar omitnull.Val[uuid.UUID] `db:"avatar" `
}
func (s UserSetter) SetColumns() []string {
vals := make([]string, 0, 13)
vals := make([]string, 0, 17)
if s.ID.IsValue() {
vals = append(vals, "id")
}
@ -193,6 +210,18 @@ func (s UserSetter) SetColumns() []string {
if s.Role.IsValue() {
vals = append(vals, "role")
}
if s.IsActive.IsValue() {
vals = append(vals, "is_active")
}
if !s.IsDronePilot.IsUnset() {
vals = append(vals, "is_drone_pilot")
}
if !s.IsWarrant.IsUnset() {
vals = append(vals, "is_warrant")
}
if !s.Avatar.IsUnset() {
vals = append(vals, "avatar")
}
return vals
}
@ -236,6 +265,18 @@ func (s UserSetter) Overwrite(t *User) {
if s.Role.IsValue() {
t.Role = s.Role.MustGet()
}
if s.IsActive.IsValue() {
t.IsActive = s.IsActive.MustGet()
}
if !s.IsDronePilot.IsUnset() {
t.IsDronePilot = s.IsDronePilot.MustGetNull()
}
if !s.IsWarrant.IsUnset() {
t.IsWarrant = s.IsWarrant.MustGetNull()
}
if !s.Avatar.IsUnset() {
t.Avatar = s.Avatar.MustGetNull()
}
}
func (s *UserSetter) Apply(q *dialect.InsertQuery) {
@ -244,7 +285,7 @@ func (s *UserSetter) Apply(q *dialect.InsertQuery) {
})
q.AppendValues(bob.ExpressionFunc(func(ctx context.Context, w io.StringWriter, d bob.Dialect, start int) ([]any, error) {
vals := make([]bob.Expression, 13)
vals := make([]bob.Expression, 17)
if s.ID.IsValue() {
vals[0] = psql.Arg(s.ID.MustGet())
} else {
@ -323,6 +364,30 @@ func (s *UserSetter) Apply(q *dialect.InsertQuery) {
vals[12] = psql.Raw("DEFAULT")
}
if s.IsActive.IsValue() {
vals[13] = psql.Arg(s.IsActive.MustGet())
} else {
vals[13] = psql.Raw("DEFAULT")
}
if !s.IsDronePilot.IsUnset() {
vals[14] = psql.Arg(s.IsDronePilot.MustGetNull())
} else {
vals[14] = psql.Raw("DEFAULT")
}
if !s.IsWarrant.IsUnset() {
vals[15] = psql.Arg(s.IsWarrant.MustGetNull())
} else {
vals[15] = psql.Raw("DEFAULT")
}
if !s.Avatar.IsUnset() {
vals[16] = psql.Arg(s.Avatar.MustGetNull())
} else {
vals[16] = psql.Raw("DEFAULT")
}
return bob.ExpressSlice(ctx, w, d, start, vals, "", ", ", "")
}))
}
@ -332,7 +397,7 @@ func (s UserSetter) UpdateMod() bob.Mod[*dialect.UpdateQuery] {
}
func (s UserSetter) Expressions(prefix ...string) []bob.Expression {
exprs := make([]bob.Expression, 0, 13)
exprs := make([]bob.Expression, 0, 17)
if s.ID.IsValue() {
exprs = append(exprs, expr.Join{Sep: " = ", Exprs: []bob.Expression{
@ -425,6 +490,34 @@ func (s UserSetter) Expressions(prefix ...string) []bob.Expression {
}})
}
if s.IsActive.IsValue() {
exprs = append(exprs, expr.Join{Sep: " = ", Exprs: []bob.Expression{
psql.Quote(append(prefix, "is_active")...),
psql.Arg(s.IsActive),
}})
}
if !s.IsDronePilot.IsUnset() {
exprs = append(exprs, expr.Join{Sep: " = ", Exprs: []bob.Expression{
psql.Quote(append(prefix, "is_drone_pilot")...),
psql.Arg(s.IsDronePilot),
}})
}
if !s.IsWarrant.IsUnset() {
exprs = append(exprs, expr.Join{Sep: " = ", Exprs: []bob.Expression{
psql.Quote(append(prefix, "is_warrant")...),
psql.Arg(s.IsWarrant),
}})
}
if !s.Avatar.IsUnset() {
exprs = append(exprs, expr.Join{Sep: " = ", Exprs: []bob.Expression{
psql.Quote(append(prefix, "avatar")...),
psql.Arg(s.Avatar),
}})
}
return exprs
}
@ -3221,6 +3314,10 @@ type userWhere[Q psql.Filterable] struct {
PasswordHashType psql.WhereMod[Q, enums.Hashtype]
PasswordHash psql.WhereMod[Q, string]
Role psql.WhereMod[Q, enums.Userrole]
IsActive psql.WhereMod[Q, bool]
IsDronePilot psql.WhereNullMod[Q, bool]
IsWarrant psql.WhereNullMod[Q, bool]
Avatar psql.WhereNullMod[Q, uuid.UUID]
}
func (userWhere[Q]) AliasedAs(alias string) userWhere[Q] {
@ -3242,6 +3339,10 @@ func buildUserWhere[Q psql.Filterable](cols userColumns) userWhere[Q] {
PasswordHashType: psql.Where[Q, enums.Hashtype](cols.PasswordHashType),
PasswordHash: psql.Where[Q, string](cols.PasswordHash),
Role: psql.Where[Q, enums.Userrole](cols.Role),
IsActive: psql.Where[Q, bool](cols.IsActive),
IsDronePilot: psql.WhereNull[Q, bool](cols.IsDronePilot),
IsWarrant: psql.WhereNull[Q, bool](cols.IsWarrant),
Avatar: psql.WhereNull[Q, uuid.UUID](cols.Avatar),
}
}

View file

@ -19,10 +19,11 @@
"sass": "^1.98.0",
"typescript": "^5.9.3",
"vite": "^8.0.1",
"vite-plugin-checker": "^0.12.0",
"vue-tsc": "^3.2.6"
},
"scripts": {
"build": "vite build",
"build": "vue-tsc && vite build",
"dev": "vite",
"generate-icons": "node generate-icons.js",
"typecheck": "vue-tsc --noEmit",

View file

@ -76,8 +76,8 @@ func (o Organization) FieldseekerSyncLatest(ctx context.Context) (*models.Fields
}
type ServiceArea struct {
Min Point
Max Point
Min Point `json:"min"`
Max Point `json:"max"`
}
func (o Organization) ServiceRequestRecent(ctx context.Context) ([]*models.FieldseekerServicerequest, error) {

View file

@ -18,9 +18,9 @@ type Pool struct {
ID int32 `db:"id" json:"-"`
}
type UploadPoolError struct {
Column uint
Line uint
Message string
Column uint `json:"column"`
Line uint `json:"line"`
Message string `json:"message"`
}
func errorsByLine(ctx context.Context, file *models.FileuploadFile) ([]UploadPoolError, map[int32][]UploadPoolError, error) {

View file

@ -14,6 +14,7 @@ import (
"github.com/Gleipnir-Technology/nidus-sync/db/models"
nhttp "github.com/Gleipnir-Technology/nidus-sync/http"
"github.com/Gleipnir-Technology/nidus-sync/platform/geocode"
//"github.com/Gleipnir-Technology/nidus-sync/platform/types"
"github.com/aarondl/opt/omit"
"github.com/aarondl/opt/omitnull"
"github.com/stephenafamo/scan"

View file

@ -5,13 +5,13 @@ import (
)
type Report struct {
Log []LogEntry `db:"-" json:"log"`
Address Address `db:"address" json:"address"`
AddressRaw string `db:"address_raw" json:"address_raw"`
Created time.Time `db:"created" json:"created"`
ID int32 `db:"id" json:"-"`
Images []Image `db:"images" json:"images"`
Location *Location `db:"location" json:"location"`
Log []LogEntry `db:"-" json:"log"`
Nuisance *Nuisance `db:"nuisance" json:"nuisance"`
PublicID string `db:"public_id" json:"public_id"`
Reporter Contact `db:"reporter" json:"reporter"`

21
platform/types/site.go Normal file
View file

@ -0,0 +1,21 @@
package types
import (
"time"
)
type Site struct {
Address Address `json:"address"`
Created time.Time `json:"created"`
CreatorID int32 `json:"creator_id"`
FileID int32 `json:"file_id"`
ID int32 `json:"id"`
Notes string `json:"notes"`
OrganizationID int32 `json:"organization_id"`
Owner *Contact `json:"owner"`
ParcelID *int32 `json:"parcel_id"`
Resident *Contact `json:"resident"`
ResidentOwned bool `json:"resident_owned"`
Tags map[string]string `json:"tags"`
Version int32 `json:"version"`
}

View file

@ -15,6 +15,7 @@ import (
"github.com/Gleipnir-Technology/nidus-sync/db/models"
"github.com/Gleipnir-Technology/nidus-sync/debug"
"github.com/aarondl/opt/omit"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
)
@ -54,7 +55,7 @@ func (u User) HasRoot() bool {
func newUser(ctx context.Context, org Organization, user *models.User) User {
u := User{
Active: true,
Avatar: user.Avatar,
Avatar: user.Avatar.GetOr(uuid.UUID{}).String(),
DisplayName: user.DisplayName,
ID: int(user.ID),
Initials: extractInitials(user.DisplayName),

96
pnpm-lock.yaml generated
View file

@ -48,12 +48,19 @@ importers:
vite:
specifier: ^8.0.1
version: 8.0.1(sass@1.98.0)(yaml@2.8.3)
vite-plugin-checker:
specifier: ^0.12.0
version: 0.12.0(typescript@5.9.3)(vite@8.0.1(sass@1.98.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3))
vue-tsc:
specifier: ^3.2.6
version: 3.2.6(typescript@5.9.3)
packages:
'@babel/code-frame@7.29.0':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
'@babel/generator@7.29.1':
resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==}
engines: {node: '>=6.9.0'}
@ -587,6 +594,9 @@ packages:
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
engines: {node: '>=18'}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
@ -723,9 +733,17 @@ packages:
node-addon-api@7.1.1:
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
npm-run-path@6.0.0:
resolution: {integrity: sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA==}
engines: {node: '>=18'}
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
path-key@4.0.0:
resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==}
engines: {node: '>=12'}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@ -825,6 +843,9 @@ packages:
resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==}
engines: {node: '>=16'}
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinyglobby@0.2.15:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
@ -843,6 +864,10 @@ packages:
ufo@1.6.3:
resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==}
unicorn-magic@0.3.0:
resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==}
engines: {node: '>=18'}
unplugin-utils@0.3.1:
resolution: {integrity: sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==}
engines: {node: '>=20.19.0'}
@ -851,6 +876,43 @@ packages:
resolution: {integrity: sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==}
engines: {node: ^20.19.0 || >=22.12.0}
vite-plugin-checker@0.12.0:
resolution: {integrity: sha512-CmdZdDOGss7kdQwv73UyVgLPv0FVYe5czAgnmRX2oKljgEvSrODGuClaV3PDR2+3ou7N/OKGauDDBjy2MB07Rg==}
engines: {node: '>=16.11'}
peerDependencies:
'@biomejs/biome': '>=1.7'
eslint: '>=9.39.1'
meow: ^13.2.0
optionator: ^0.9.4
oxlint: '>=1'
stylelint: '>=16'
typescript: '*'
vite: '>=5.4.21'
vls: '*'
vti: '*'
vue-tsc: ~2.2.10 || ^3.0.0
peerDependenciesMeta:
'@biomejs/biome':
optional: true
eslint:
optional: true
meow:
optional: true
optionator:
optional: true
oxlint:
optional: true
stylelint:
optional: true
typescript:
optional: true
vls:
optional: true
vti:
optional: true
vue-tsc:
optional: true
vite@8.0.1:
resolution: {integrity: sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==}
engines: {node: ^20.19.0 || >=22.12.0}
@ -936,6 +998,12 @@ packages:
snapshots:
'@babel/code-frame@7.29.0':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
js-tokens: 4.0.0
picocolors: 1.1.1
'@babel/generator@7.29.1':
dependencies:
'@babel/parser': 7.29.2
@ -1456,6 +1524,8 @@ snapshots:
is-what@5.5.0: {}
js-tokens@4.0.0: {}
jsesc@3.1.0: {}
json-stringify-pretty-compact@4.0.0: {}
@ -1577,8 +1647,15 @@ snapshots:
node-addon-api@7.1.1:
optional: true
npm-run-path@6.0.0:
dependencies:
path-key: 4.0.0
unicorn-magic: 0.3.0
path-browserify@1.0.1: {}
path-key@4.0.0: {}
pathe@2.0.3: {}
pbf@4.0.1:
@ -1683,6 +1760,8 @@ snapshots:
dependencies:
copy-anything: 4.0.5
tiny-invariant@1.3.3: {}
tinyglobby@0.2.15:
dependencies:
fdir: 6.5.0(picomatch@4.0.3)
@ -1697,6 +1776,8 @@ snapshots:
ufo@1.6.3: {}
unicorn-magic@0.3.0: {}
unplugin-utils@0.3.1:
dependencies:
pathe: 2.0.3
@ -1708,6 +1789,21 @@ snapshots:
picomatch: 4.0.3
webpack-virtual-modules: 0.6.2
vite-plugin-checker@0.12.0(typescript@5.9.3)(vite@8.0.1(sass@1.98.0)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@5.9.3)):
dependencies:
'@babel/code-frame': 7.29.0
chokidar: 4.0.3
npm-run-path: 6.0.0
picocolors: 1.1.1
picomatch: 4.0.3
tiny-invariant: 1.3.3
tinyglobby: 0.2.15
vite: 8.0.1(sass@1.98.0)(yaml@2.8.3)
vscode-uri: 3.1.0
optionalDependencies:
typescript: 5.9.3
vue-tsc: 3.2.6(typescript@5.9.3)
vite@8.0.1(sass@1.98.0)(yaml@2.8.3):
dependencies:
lightningcss: 1.32.0

View file

@ -2,8 +2,8 @@
<div class="app-container">
<Sidebar v-if="$route.meta.showSidebar" />
<MainContent>
<div v-if="userStore.loading">Loading...</div>
<div v-else-if="userStore.error">Error: {{ userStore.error }}</div>
<div v-if="session.loading">Loading...</div>
<div v-else-if="session.error">Error: {{ session.error }}</div>
<router-view v-else />
</MainContent>
</div>
@ -11,15 +11,15 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { useUserStore } from "@/store/user";
import { useSessionStore } from "@/store/session";
import Sidebar from "./components/layout/Sidebar.vue";
import MainContent from "./components/layout/MainContent.vue";
import NavigationLink from "@/components/common/NavigationLink.vue";
const userStore = useUserStore();
const session = useSessionStore();
onMounted(() => {
userStore.fetchUser();
session.fetchSession();
});
</script>

View file

@ -1,24 +1,31 @@
// src/api/axios.js or similar
import axios from "axios";
// src/api/axios.ts
import axios, { AxiosInstance } from "axios";
import router from "@/router";
// Extend the AxiosInstance interface
declare module "axios" {
interface AxiosInstance {
isAuthenticated(): boolean;
}
}
const apiClient = axios.create({
baseURL: "/api",
withCredentials: true,
});
// Response interceptor to catch auth failures
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response && error.response.status === 401) {
// Session expired or not authenticated
router.push("/login");
}
return Promise.reject(error);
},
);
apiClient.isAuthenticated = () => {
return true;
};
export default apiClient;

View file

@ -49,8 +49,8 @@
<div
v-if="
!(
selectedCommunication?.public_report.reporter.has_email ||
selectedCommunication?.public_report.reporter.has_phone
selectedCommunication?.public_report?.reporter.has_email ||
selectedCommunication?.public_report?.reporter.has_phone
)
"
class="mb-3"
@ -62,8 +62,8 @@
</div>
<div
v-if="
selectedCommunication?.public_report.reporter.has_email ||
selectedCommunication?.public_report.reporter.has_phone
selectedCommunication?.public_report?.reporter.has_email ||
selectedCommunication?.public_report?.reporter.has_phone
"
class="mb-3"
>
@ -74,7 +74,7 @@
>
<select
class="form-select form-select-sm"
@change="applyMessageTemplate($event.target.value)"
@change="handleTemplateChange"
>
<option value="">Select a template...</option>
<option value="received">Report Received</option>
@ -107,7 +107,8 @@
<h6><i class="bi bi-clock-history"></i> Activity Log</h6>
<div class="small">
<div
v-for="entry in selectedCommunication.public_report.log || []"
v-for="entry in selectedCommunication?.public_report?.log ||
[]"
:key="entry.created"
class="border-start border-2 ps-2 mb-2"
>
@ -128,8 +129,8 @@
</div>
<div
v-if="
!selectedCommunication.public_report.log ||
selectedCommunication.public_report.log.length === 0
!selectedCommunication?.public_report?.log ||
selectedCommunication?.public_report?.log.length === 0
"
class="text-muted"
>
@ -146,6 +147,7 @@
<script setup lang="ts">
import { ref } from "vue";
import { Communication, User } from "@/types";
interface Emits {
(e: "markSignal"): void;
(e: "markInvalid"): void;
@ -154,27 +156,30 @@ interface Emits {
interface Props {
loading: boolean;
selectedCommunication: Communication | null;
user: User | null;
}
const emit = defineEmits<Emits>();
const messageText = ref("");
const props = withDefaults(defineProps<Props>(), {});
function applyMessageTemplate(template) {
function applyMessageTemplate(template: string) {
const templates = {
received: `Dear ${selectedCommunication.value?.public_report.reporter.name || "Resident"},\n\nThank you for submitting your report to the Mosquito Control District. We have received your communication and it has been assigned to our team for review.\n\nWe will be in touch if we need any additional information.\n\nBest regards,\nMosquito Control District`,
scheduled: `Dear ${selectedCommunication.value?.public_report.reporter.name || "Resident"},\n\nGood news! Based on your report, we have scheduled a service visit to your area. Our technicians will be conducting mosquito control operations within the next 3-5 business days.\n\nNo action is required on your part.\n\nBest regards,\nMosquito Control District`,
completed: `Dear ${selectedCommunication.value?.public_report.reporter.name || "Resident"},\n\nWe wanted to let you know that our team has completed mosquito control operations in your area based on your recent report.\n\nIf you continue to experience issues, please don't hesitate to submit a new report.\n\nBest regards,\nMosquito Control District`,
need_info: `Dear ${selectedCommunication.value?.public_report.reporter.name || "Resident"},\n\nThank you for your recent report. In order to better assist you, we need some additional information:\n\n- [Specify what information is needed]\n\nPlease reply to this message with the requested details.\n\nBest regards,\nMosquito Control District`,
received: `Dear ${props.selectedCommunication?.public_report?.reporter.name || "Resident"},\n\nThank you for submitting your report to the Mosquito Control District. We have received your communication and it has been assigned to our team for review.\n\nWe will be in touch if we need any additional information.\n\nBest regards,\nMosquito Control District`,
scheduled: `Dear ${props.selectedCommunication?.public_report?.reporter.name || "Resident"},\n\nGood news! Based on your report, we have scheduled a service visit to your area. Our technicians will be conducting mosquito control operations within the next 3-5 business days.\n\nNo action is required on your part.\n\nBest regards,\nMosquito Control District`,
completed: `Dear ${props.selectedCommunication?.public_report?.reporter.name || "Resident"},\n\nWe wanted to let you know that our team has completed mosquito control operations in your area based on your recent report.\n\nIf you continue to experience issues, please don't hesitate to submit a new report.\n\nBest regards,\nMosquito Control District`,
need_info: `Dear ${props.selectedCommunication?.public_report?.reporter.name || "Resident"},\n\nThank you for your recent report. In order to better assist you, we need some additional information:\n\n- [Specify what information is needed]\n\nPlease reply to this message with the requested details.\n\nBest regards,\nMosquito Control District`,
};
if (templates[template]) {
messageText.value = templates[template];
if (template in templates) {
messageText.value = templates[template as keyof typeof templates];
}
}
function formatDate(date) {
function formatDate(date: string) {
return new Date(date).toLocaleString();
}
function handleTemplateChange(event: Event) {
const target = event.target as HTMLSelectElement;
applyMessageTemplate(target.value);
}
function markInvalid() {
emit("markInvalid");
}

View file

@ -33,21 +33,19 @@
<div class="card shadow-sm mb-3">
<div class="card-header bg-white pane-header">Communication Workbench</div>
<div class="card-body">
<div v-if="loading || session.user == null" class="loading">
Loading...
</div>
<div v-else>
<div class="map-container">
<MapMultipoint
id="map"
:bounds="mapBounds"
:markers="mapMarkers"
:organizationId="user.organization.id"
:tegola="user.urls.tegola"
:xmin="user.organization.service_area?.min.x ?? 0"
:ymin="user.organization.service_area?.min.y ?? 0"
:xmax="user.organization.service_area?.max.x ?? 0"
:ymax="user.organization.service_area?.max.y ?? 0"
:organizationId="session.user?.organization.id"
:tegola="session.urls?.tegola ?? ''"
/>
</div>
<div v-if="loading" class="loading">Loading...</div>
<div v-else>
<div
v-if="!selectedCommunication"
class="d-flex flex-column align-items-center justify-content-center text-muted"
@ -58,10 +56,11 @@
<div v-if="selectedCommunication" class="h-100 d-flex flex-column">
<PublicreportCard
:report="selectedCommunication.public_report"
v-if="selectedCommunication?.public_report"
:report="selectedCommunication?.public_report"
@viewImage="openPhotoViewer"
/>
<!-- Report Details -->
<p v-else>No public report</p>
</div>
</div>
</div>
@ -73,27 +72,29 @@ import { computed } from "vue";
import MapMultipoint from "@/components/MapMultipoint.vue";
import PublicreportCard from "@/components/PublicreportCard.vue";
import TimeRelative from "@/components/TimeRelative.vue";
import { Bounds, Communication, Marker, User } from "@/types";
import { useSessionStore } from "@/store/session";
interface Emits {
(e: "viewImage", index: int): void;
(e: "viewImage", index: number): void;
}
interface Props {
loading: boolean;
mapBounds?: Bounds;
mapMarkers: Marker[];
selectedCommunication: Communication | null;
user: User | null;
}
const emit = defineEmits<Emits>();
const props = defineProps<Props>();
const nuisance = computed(() => {
return props.selectedCommunication?.value?.public_report?.nuisance || null;
return props.selectedCommunication?.public_report?.nuisance || null;
});
const session = useSessionStore();
const water = computed(() => {
return props.selectedCommunication?.value?.public_report?.water || null;
return props.selectedCommunication?.public_report?.water || null;
});
function openPhotoViewer(index) {
function openPhotoViewer(index: number) {
emit("viewImage", index);
}
</script>

View file

@ -61,7 +61,7 @@
</div>
<div class="list-group list-group-flush">
<div v-if="loading" class="loading">Loading...</div>
<div v-if="loading || all == null" class="loading">Loading...</div>
<div
v-else-if="all.length > 0"
v-for="comm in filteredCommunications"
@ -109,14 +109,14 @@
<div>
<i class="bi bi-geo-alt text-muted"></i>
<span class="fw-medium">{{
comm.public_report.address.postal_code
comm.public_report?.address.postal_code
}}</span>
</div>
<small>{{ formatAddress(comm.public_report.address) }}</small>
<small>{{ formatAddress(comm.public_report?.address) }}</small>
<div
v-if="
comm.public_report.images &&
comm.public_report.images.length > 0
comm.public_report?.images &&
comm.public_report?.images.length > 0
"
class="mt-1"
>
@ -142,8 +142,9 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import TimeRelative from "../components/TimeRelative.vue";
import { Communication } from "../types";
import TimeRelative from "@/components/TimeRelative.vue";
import { formatAddress } from "@/format";
import { Communication, LogEntry, PublicReport } from "@/types";
interface Props {
all: Communication[] | null;
@ -156,7 +157,7 @@ interface Emits {
}
const props = withDefaults(defineProps<Props>(), {
selectedIndex: null,
selectedId: null,
});
const emit = defineEmits<Emits>();
@ -169,14 +170,14 @@ const handleClick = (id: string) => {
emit("select", id);
}
};
const searchFilter = ref("");
const typeFilter = ref("all");
const searchFilter = ref<string>("");
const typeFilter = ref<string>("all");
function selectCommunication(communication: Communication) {
// Emit both events - one for general use, one for v-model
console.log("selected", communication);
emit("select-item", communication);
emit("update:selectedItem", communication);
emit("select", communication.id);
//emit("update:selectedItem", communication);
//messageText.value = "";
//updateMap();
}
@ -193,14 +194,29 @@ const filteredCommunications = computed(() => {
});
});
// Methods
function filterMatches(filter, comm) {
// Implement your filter logic here
function filterMatches(filter: string, comm: Communication) {
const pr = comm.public_report;
// When we have non-public-report communications fix this.
if (pr == null) {
return false;
}
return filterMatchesPublicReport(filter, pr);
}
function filterMatchesLogEntry(filter: string, logs: LogEntry[]) {
for (const le of logs) {
if (le.message.includes(filter)) {
return true;
}
function formatAddress(a) {
if (a.number === "" && a.street === "") {
return "no address provided";
}
return `${a.number} ${a.street}, ${a.locality}`;
}
function filterMatchesPublicReport(filter: string, pr: PublicReport) {
if (
pr.address_raw.includes(filter) ||
pr.public_id.includes(filter) ||
filterMatchesLogEntry(filter, pr.log)
) {
return true;
}
return false;
}
</script>

View file

@ -1,22 +1,28 @@
<template>
<p>A flyover pool</p>
<div v-if="session.user">
<MapProxiedArcgisTile
id="tile-map"
:latitude="pool.location.latitude"
:longitude="pool.location.longitude"
:markers="tileMapMarkers"
:organizationId="user.organization.id"
:tegola="user.urls.tegola"
:location="location"
:markers="markers"
:organizationId="session.user?.organization.id"
:tegola="session.urls?.tegola ?? ''"
:urlTiles="session.urls?.tile ?? ''"
/>
</div>
<div v-else>
<p>Loading...</p>
</div>
</template>
<script setup lang="ts">
import { useUserStore } from "../store/user";
import MapProxiedArcgisTile from "@/components/MapProxiedArcgisTile.vue";
import { Location, Marker } from "@/types";
import { useSessionStore } from "@/store/session";
interface Props {
pool: Pool;
location: Location;
markers: Marker[];
}
const props = defineProps<Props>();
const user = useUserStore();
const session = useSessionStore();
</script>

View file

@ -15,7 +15,7 @@
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
Photo {{ currentPhotoIndex + 1 }} of
Image {{ currentImageIndex + 1 }} of
{{ images.length || 0 }}
</h5>
<button
@ -27,28 +27,28 @@
<div class="modal-body text-center">
<div v-if="images && show">
<img
:src="images[currentPhotoIndex].url_content"
:src="images[currentImageIndex].url_content"
class="img-fluid rounded"
style="max-height: 60vh"
/>
<!-- EXIF Data Section -->
<div class="mt-4 pt-3 border-top text-start">
<h6 class="text-muted mb-3">Photo Information</h6>
<h6 class="text-muted mb-3">Image Information</h6>
<div class="row g-3">
<div class="col-md-4">
<small class="text-muted d-block">Date Taken</small>
<span>
{{ images[currentPhotoIndex].exif?.created || "N/A" }}
{{ images[currentImageIndex].exif?.created || "N/A" }}
</span>
</div>
<div class="col-md-4">
<small class="text-muted d-block">Camera</small>
<span>
{{
(images[currentPhotoIndex].exif?.make || "") +
(images[currentImageIndex].exif?.make || "") +
" " +
(images[currentPhotoIndex].exif?.model || "") || "N/A"
(images[currentImageIndex].exif?.model || "") || "N/A"
}}
</span>
</div>
@ -56,10 +56,10 @@
<small class="text-muted d-block"
>Distance from Reporter</small
>
<span v-if="images[currentPhotoIndex].location != null">
<span v-if="images[currentImageIndex].location != null">
{{
formatDistance(
images[currentPhotoIndex].distance_from_reporter_meters,
images[currentImageIndex].distance_from_reporter_meters,
)
}}
</span>
@ -73,14 +73,14 @@
<button
class="btn btn-outline-secondary"
@click="emit('imagePrevious')"
:disabled="currentPhotoIndex === 0"
:disabled="currentImageIndex === 0"
>
<i class="bi bi-chevron-left"></i> Previous
</button>
<button
class="btn btn-outline-secondary"
@click="emit('imageNext')"
:disabled="currentPhotoIndex >= (images?.length || 1) - 1"
:disabled="currentImageIndex >= (images?.length || 1) - 1"
>
Next <i class="bi bi-chevron-right"></i>
</button>
@ -96,31 +96,19 @@
</template>
<script setup lang="ts">
import { formatDistance } from "@/format";
import { Image } from "@/types";
interface Emits {
(e: "close"): void;
(e: "imageNext"): void;
(e: "imagePrevious"): void;
}
interface Props {
currentPhotoIndex: int | null;
images: Photo[] | null;
currentImageIndex: number;
images: Image[];
show: boolean;
}
const emit = defineEmits<Emits>();
const props = defineProps<Props>();
function formatDistance(meters) {
if (meters === undefined || meters === null) {
return "unknown";
}
if (meters < 1) {
const mm = Math.round(meters * 1000);
return `${mm} mm`;
} else if (meters >= 1000) {
const km = Math.round(meters / 1000);
return `${km} km`;
} else {
const m = Math.round(meters);
return `${m} m`;
}
}
</script>

View file

@ -1,40 +1,72 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";
<style scoped>
.map-container {
background-color: #e9ecef;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
height: 500px;
margin-top: 20px;
position: relative;
}
.map {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
height: 100%;
width: 100%;
}
</style>
<template>
<div class="map-container">
<div ref="mapContainer" class="map"></div>
</div>
</template>
<script setup lang="ts">
import "maplibre-gl/dist/maplibre-gl.css";
import type { LngLatBoundsLike, Map as MapLibreMap } from "maplibre-gl";
import maplibregl from "maplibre-gl";
import { onMounted, onUnmounted, ref, shallowRef, type Ref } from "vue";
import { Bounds, Marker } from "@/types";
interface Emits {
(e: "cell-click", cell: number): void;
}
interface Props {
centroid: [number, number];
bounds?: Bounds;
markers: Marker[];
organizationId: number;
tegola: string;
xmin: number;
ymin: number;
xmax: number;
ymax: number;
}
interface CellClickDetail {
cell: string;
}
const emit = defineEmits<Emits>();
const props = withDefaults(defineProps<Props>(), {
organizationId: 0,
// default bounds cover a bunch of the continental US
bounds: () => {
return {
max: { lng: -70, lat: 50 },
min: { lng: -125, lat: 25 },
};
},
});
const emit = defineEmits<{
"cell-click": [detail: CellClickDetail];
}>();
const boundsSafe = props.bounds as Bounds;
const mapContainer = ref<HTMLElement | null>(null);
const map = ref<maplibregl.Map | null>(null);
const map: Ref<MapLibreMap | null> = shallowRef(null);
function _bounds(): LngLatBoundsLike {
return new maplibregl.LngLatBounds(
new maplibregl.LngLat(boundsSafe.min.lng, boundsSafe.min.lat),
new maplibregl.LngLat(boundsSafe.max.lng, boundsSafe.max.lat),
);
}
const initializeMap = () => {
if (!mapContainer.value) return;
const bounds: [[number, number], [number, number]] = [
[props.xmin, props.ymin],
[props.xmax, props.ymax],
];
const bounds = _bounds();
map.value = new maplibregl.Map({
bounds: bounds,
@ -43,7 +75,8 @@ const initializeMap = () => {
});
console.log("Initializing map to bounds", bounds);
const mapInstance = map.value;
if (mapInstance) {
map.value.on("load", () => {
if (!map.value) return;
@ -57,7 +90,11 @@ const initializeMap = () => {
map.value.addLayer({
id: "mosquito_source",
type: "fill",
filter: ["==", ["zoom"], ["+", 2, ["to-number", ["get", "resolution"]]]],
filter: [
"==",
["zoom"],
["+", 2, ["to-number", ["get", "resolution"]]],
],
source: "tegola",
"source-layer": "mosquito_source",
paint: {
@ -69,7 +106,11 @@ const initializeMap = () => {
map.value.addLayer({
id: "service_request",
type: "fill",
filter: ["==", ["zoom"], ["+", 2, ["to-number", ["get", "resolution"]]]],
filter: [
"==",
["zoom"],
["+", 2, ["to-number", ["get", "resolution"]]],
],
source: "tegola",
"source-layer": "service_request",
paint: {
@ -81,7 +122,11 @@ const initializeMap = () => {
map.value.addLayer({
id: "trap",
type: "fill",
filter: ["==", ["zoom"], ["+", 2, ["to-number", ["get", "resolution"]]]],
filter: [
"==",
["zoom"],
["+", 2, ["to-number", ["get", "resolution"]]],
],
source: "tegola",
"source-layer": "trap",
paint: {
@ -118,15 +163,14 @@ const initializeMap = () => {
const feature = e.features[0];
const properties = feature.properties;
emit("cell-click", {
cell: properties.cell,
});
emit("cell-click", properties.cell);
};
map.value.on("click", "mosquito_source", handleClick);
map.value.on("click", "service_request", handleClick);
map.value.on("click", "trap", handleClick);
});
}
};
const jumpTo = (args: maplibregl.JumpToOptions) => {
@ -151,30 +195,3 @@ defineExpose({
jumpTo,
});
</script>
<template>
<div class="map-container">
<div ref="mapContainer" class="map"></div>
</div>
</template>
<style scoped>
.map-container {
background-color: #e9ecef;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
height: 500px;
margin-top: 20px;
position: relative;
}
.map {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
height: 100%;
width: 100%;
}
</style>

View file

@ -21,31 +21,42 @@
<script setup lang="ts">
import "maplibre-gl/dist/maplibre-gl.css";
import { onMounted, onUnmounted, ref, watch } from "vue";
import { Bounds, Marker } from "@/types";
import type { LngLatBoundsLike, Map as MapLibreMap } from "maplibre-gl";
import maplibregl from "maplibre-gl";
import {
computed,
onMounted,
onUnmounted,
ref,
shallowRef,
watch,
type Ref,
} from "vue";
import { Bounds, Marker } from "@/types";
interface Emits {}
interface Props {
bounds?: Bounds;
markers: Marker[];
organizationId: int;
organizationId: number;
tegola: string;
}
const emit = defineEmits<Emits>();
const props = withDefaults(defineProps<Props>(), {
// default bounds cover a bunch of the continental US
bounds: {
max: { x: -70, y: 50 },
min: { x: -125, y: 25 },
bounds: () => {
return {
max: { lng: -70, lat: 50 },
min: { lng: -125, lat: 25 },
};
},
});
const boundsSafe = props.bounds as Bounds;
const error = ref<string | null>(null);
const mapContainer = ref<HTMLElement | null>(null);
const map = ref<maplibregl.Map | null>(null);
const markerInstances = ref<Map<string, maplibregl.Marker>>(new Map());
const markers = ref<Map<string, maplibregl.Marker>>(new Map());
const map: Ref<MapLibreMap | null> = shallowRef(null);
const markerInstances = shallowRef<Map<string, maplibregl.Marker>>(new Map());
watch(
() => props.bounds,
(newBounds) => {
@ -70,12 +81,12 @@ watch(
{ deep: true },
);
const _bounds = () => {
return [
[props.bounds.min.x, props.bounds.min.y],
[props.bounds.max.y, props.bounds.max.y],
];
};
function _bounds(): LngLatBoundsLike {
return new maplibregl.LngLatBounds(
new maplibregl.LngLat(boundsSafe.min.lng, boundsSafe.min.lat),
new maplibregl.LngLat(boundsSafe.max.lng, boundsSafe.max.lat),
);
}
const _initializeMap = () => {};
@ -90,18 +101,19 @@ onMounted(() => {
container: mapContainer.value,
style: "https://tiles.stadiamaps.com/styles/osm_bright.json",
});
const mapInstance = map.value;
// Wait for map to load, then add the markers
map.value.on("load", () => {
mapInstance.on("load", () => {
if (props.organizationId !== 0) {
map.value.addSource("tegola", {
mapInstance.addSource("tegola", {
type: "vector",
tiles: [
`${props.tegola}maps/nidus/{z}/{x}/{y}?id=${props.organizationId}&organization_id=${props.organizationId}`,
],
});
map.value.addLayer({
mapInstance.addLayer({
id: "service-area",
source: "tegola",
"source-layer": "service-area-bounds",
@ -114,7 +126,9 @@ onMounted(() => {
updateMarkers(props.markers);
});
} catch (e) {
error.value = e;
error.value = e instanceof Error ? e.message : "an error ocurred";
console.error("Error on map multipoint init", e);
//throw new Error(error.value);
}
});

View file

@ -21,15 +21,24 @@
<script setup lang="ts">
import "maplibre-gl/dist/maplibre-gl.css";
import { ref, watch, onMounted, onBeforeUnmount } from "vue";
import { Point } from "@/types";
import type { LngLatBoundsLike, Map as MapLibreMap } from "maplibre-gl";
import maplibregl from "maplibre-gl";
import {
ref,
watch,
onMounted,
onBeforeUnmount,
shallowRef,
type Ref,
} from "vue";
import { Location, MapClickEvent, Marker, Point } from "@/types";
interface Emits {
(e: "map-click", latitude: Number, longitude: Number): void;
(e: "map-click", event: MapClickEvent): void;
}
interface Props {
location: Point;
location: Location;
markers: Marker[];
organizationId: Number;
tegola: string;
urlTiles: string;
@ -39,9 +48,9 @@ const props = defineProps<Props>();
const error = ref<string | null>(null);
const mapContainer = ref<HTMLElement | null>(null);
const map = ref<maplibregl.Map | null>(null);
const markerInstances = ref<Map<string, maplibrgl.Marker>>(new Map());
const markers = ref<Map<string, maplibrgl.Marker>>(new Map());
const map: Ref<MapLibreMap | null> = shallowRef(null);
const markerInstances = ref<Map<string, maplibregl.Marker>>(new Map());
const markers = ref<Map<string, maplibregl.Marker>>(new Map());
// Watch for latitude/longitude changes
watch(
@ -49,7 +58,7 @@ watch(
([newLocation]) => {
if (map.value) {
map.value.jumpTo({
center: [newLocation.longitude, newLocation.latitude],
center: [newLocation.lng, newLocation.lat],
zoom: 19,
});
}
@ -61,21 +70,22 @@ const initializeMap = () => {
try {
map.value = new maplibregl.Map({
center: [props.location.longitude, props.location.latitude],
center: [props.location.lng, props.location.lat],
container: mapContainer.value,
style: "https://tiles.stadiamaps.com/styles/osm_bright.json",
zoom: 19,
});
const mapInstance = map.value;
map.value.on("load", () => {
mapInstance.on("load", () => {
if (props.organizationId !== 0) {
map.value.addSource("tegola", {
mapInstance.addSource("tegola", {
type: "vector",
tiles: [
`${props.tegola}maps/nidus/{z}/{x}/{y}?id=${props.organizationId}&organization_id=${props.organizationId}`,
],
});
map.value.addLayer({
mapInstance.addLayer({
id: "service-area",
source: "tegola",
"source-layer": "service-area-bounds",
@ -86,83 +96,33 @@ const initializeMap = () => {
});
}
map.value.addSource("flyover", {
mapInstance.addSource("flyover", {
type: "raster",
tiles: [props.urlTiles],
});
map.value.addLayer({
mapInstance.addLayer({
id: "flyover-layer",
source: "flyover",
type: "raster",
});
/*
map.value.on("click", (e) => {
mapInstance.on("click", (e) => {
emit("map-click", {
lng: e.lngLat.lng,
location: {
lat: e.lngLat.lat,
map: getCurrentInstance(),
lng: e.lngLat.lng,
},
map: mapInstance,
point: e.point,
});
});
*/
});
} catch (e) {
console.error("hey dummy", e);
}
};
const addLayer = (a) => {
return map.value?.addLayer(a);
};
const addSource = (a, b) => {
return map.value?.addSource(a, b);
};
const jumpTo = (args) => {
return map.value?.jumpTo(args);
};
const once = (a, b) => {
return map.value?.once(a, b);
};
const queryRenderedFeatures = (a) => {
return map.value?.queryRenderedFeatures(a);
};
const fitBounds = (bounds, options) => {
return map.value?.fitBounds(bounds, options);
};
const setLayoutProperty = (layout, property, value) => {
return map.value?.setLayoutProperty(layout, property, value);
};
const setMarkers = (newMarkers) => {
console.log("Setting map markers", newMarkers);
markers.value.forEach((marker) => marker.remove());
markers.value = newMarkers;
for (let m of newMarkers) {
m.addTo(map.value);
}
};
// Expose methods to parent components
defineExpose({
addLayer,
addSource,
jumpTo,
once,
queryRenderedFeatures,
fitBounds,
setLayoutProperty,
setMarkers,
map,
});
onMounted(() => {
setTimeout(() => initializeMap(), 0);
});

View file

@ -107,7 +107,7 @@ interface Emits {
}
interface Props {
creating: boolean;
selectedSignalIDs: Set<int>;
selectedSignalIDs: Set<number>;
}
const emit = defineEmits<Emits>();
const props = defineProps<Props>();

View file

@ -10,18 +10,18 @@
Active Investigation Workbench
</div>
<div class="card-body">
<div class="map-container">
<div class="map-container" v-if="session.user">
<MapMultipoint
id="map"
:bounds="session.user.organization.service_area"
:markers="markers"
:organization-id="user.organization.id"
:tegola="user.urls.tegola"
:xmin="user?.organization.service_area?.xmin ?? 0"
:ymin="user?.organization.service_area?.ymin ?? 0"
:xmax="user?.organization.service_area?.xmax ?? 0"
:ymax="user?.organization.service_area?.ymax ?? 0"
:organizationId="session.user.organization.id"
:tegola="session.urls?.tegola ?? ''"
></MapMultipoint>
</div>
<div v-else>
<p>loading...</p>
</div>
<div class="row g-3">
<div class="col-md-12">
@ -54,12 +54,11 @@
<div v-show="showMapTile" class="map-container">
<MapProxiedArcgisTile
ref="mapTile"
class="map"
:organization-id="user.organization.id"
:tegola="user.urls.tegola"
:url-tiles="user.urls.tile"
:location="selectedSignalLocation()"
:markers="[]"
:organizationId="session.user?.organization.id ?? 0"
:tegola="session.urls?.tegola ?? ''"
:urlTiles="session.urls?.tile ?? ''"
@map-click="updateSignalLocation"
>
</MapProxiedArcgisTile>
@ -68,20 +67,22 @@
</div>
</template>
<script setup lang="ts">
import MapMultipoint from "./MapMultipoint.vue";
import MapProxiedArcgisTile from "./MapProxiedArcgisTile.vue";
import { shortAddress } from "../format";
import TimeRelative from "./TimeRelative.vue";
import PlanningColumnDetailEntry from "./PlanningColumnDetailEntry.vue";
import { useUserStore } from "../store/user";
import MapMultipoint from "@/components/MapMultipoint.vue";
import MapProxiedArcgisTile from "@/components/MapProxiedArcgisTile.vue";
import PlanningColumnDetailEntry from "@/components/PlanningColumnDetailEntry.vue";
import TimeRelative from "@/components/TimeRelative.vue";
import { shortAddress } from "@/format";
import { useSessionStore } from "@/store/session";
import { Location, MapClickEvent, Marker, Signal } from "@/types";
interface Props {
markers: Marker[];
selectedSignals: Array<Signal>;
}
const props = defineProps<Props>();
const user = useUserStore();
const session = useSessionStore();
const configureMapTile = () => {
/*
if (!mapTile.value) return;
mapTile.value.on("load", () => {
@ -108,48 +109,44 @@ const configureMapTile = () => {
type: "circle",
});
});
*/
};
const selectedSignalLocation = () => {
const first_pool = props.selectedSignals
.values()
.reduce((accumulator, current) => {
const selectedSignalLocation = (): Location => {
const first_pool = props.selectedSignals.reduce(
(accumulator: Signal | null, current: Signal) => {
if (accumulator == null && current.type === "flyover pool") {
return current;
}
return accumulator;
}, null);
},
null as Signal | null,
);
const loc = first_pool?.location;
return (
loc || {
latitude: 0,
longitude: 0,
lat: 0,
lng: 0,
}
);
};
const showMapTile = () => {
return (
selectedSignalLocation() &&
props.selectedSignals.value
.values()
.reduce(
(accumulator, current) =>
accumulator || current.type === "flyover pool",
const hasPool = props.selectedSignals.reduce(
(accumulator: boolean | null, current: Signal) => {
return accumulator || current.type === "flyover pool";
},
false,
)
);
return selectedSignalLocation() && hasPool;
};
const updateSignalLocation = (event) => {
const signalId = event.detail.signalId;
console.log("map click", signalId, event.detail);
const updateSignalLocation = (event: MapClickEvent) => {
console.log("map click", event.location);
//const signalId = event.detail.signalId;
const map = event.detail.map;
const loc = {
latitude: event.detail.lat,
longitude: event.detail.lng,
};
//const map = event.map;
//const loc = event.location;
map.SetMarkers([loc]);
poolLocations.value[signalId] = loc;
//map.SetMarkers([loc]);
//poolLocations.value[signalId] = loc;
};
</script>

View file

@ -2,21 +2,25 @@
<TimeRelative :time="signal.created"></TimeRelative>
<p>{{ shortAddress(signal.address) }}</p>
<div v-if="signal.type == 'flyover pool' && signal.pool">
<FlyoverPoolCard :pool="signal.pool" />
<FlyoverPoolCard :location="signal.location" :markers="[]" />
</div>
<div v-else-if="signal.type == 'publicreport nuisance'">
<div v-else-if="signal.type == 'publicreport nuisance' && signal.report">
<PublicreportCard :report="signal.report" />
</div>
<div v-else-if="signal.type == 'publicreport water'">
<div v-else-if="signal.type == 'publicreport water' && signal.report">
<PublicreportCard :report="signal.report" />
</div>
<div v-else>
<p>No report or pool</p>
</div>
</template>
<script setup lang="ts">
import { shortAddress } from "@/format";
import FlyoverPoolCard from "@/components/FlyoverPoolCard.vue";
import PublicreportCard from "@/components/PublicreportCard.vue";
import TimeRelative from "@/components/TimeRelative.vue";
import { shortAddress } from "@/format";
import { Signal } from "@/types";
interface Props {
signal: Signal;
}

View file

@ -68,7 +68,6 @@
class="form-select form-select-sm mb-2 disabled"
disabled
v-model="filters.species"
@change="loadSignals()"
>
<option value="">All Species</option>
<option value="aedes_aegypti">Aedes aegypti</option>
@ -82,7 +81,6 @@
class="form-select form-select-sm mb-2 disabled"
disabled
v-model="filters.type"
@change="loadSignals()"
>
<option value="">All Types</option>
<option value="public_report">Public Report</option>
@ -97,7 +95,6 @@
class="form-select form-select-sm disabled"
disabled
v-model="filters.sort"
@change="loadSignals()"
>
<option value="newest">Newest First</option>
<option value="priority">Highest Priority</option>
@ -164,7 +161,7 @@
:key="followup.id"
class="border rounded p-2 mb-2 signal-item"
:class="{ selected: isSelected(followup.id) }"
@click="toggleSignal(followup)"
@click="toggleFollowup(followup)"
>
<div class="small fw-semibold">{{ followup.title }}</div>
<div class="text-muted small">{{ followup.description }}</div>
@ -201,18 +198,19 @@
<script setup lang="ts">
import { ref } from "vue";
import PlanningColumnListEntry from "@/components/PlanningColumnListEntry.vue";
import { Followup, Lead, Signal } from "@/types";
interface Emits {
(e: "refresh"): void;
(e: "signalDeselect", integer): void;
(e: "signalSelect", integer): void;
(e: "signalDeselect", id: number): void;
(e: "signalSelect", id: number): void;
}
interface Props {
error: string | null;
leads: Lead[] | null;
leads: Lead[];
loading: boolean;
planFollowups: Followup[] | null;
selectedSignalIDs: Set<int>;
planFollowups: Followup[];
selectedSignalIDs: Set<number>;
signals: Signal[] | null;
}
const emit = defineEmits<Emits>();
@ -222,14 +220,17 @@ const filters = ref({
type: "",
sort: "newest",
});
const isSelected = (id) => {
return props.selectedSignalIDs.values().some((s) => s.id === id);
const isSelected = (id: number): boolean => {
return props.selectedSignalIDs.has(id);
};
const toggleSignal = (signal: int) => {
if (props.selectedSignalIDs.has(signal)) {
emit("signalDeselect", signal);
const toggleFollowup = (followup: Followup) => {
console.log("fake toggleSignal", followup);
};
const toggleSignal = (signal: Signal) => {
if (props.selectedSignalIDs.has(signal.id)) {
emit("signalDeselect", signal.id);
} else {
emit("signalSelect", signal);
emit("signalSelect", signal.id);
}
};
</script>

View file

@ -21,7 +21,9 @@
</template>
<script setup lang="ts">
import { shortAddress } from "../format";
import { shortAddress } from "@/format";
import { Signal } from "@/types";
interface Props {
selected: boolean;
signal: Signal;
@ -42,7 +44,7 @@ function location(signal: Signal): string {
if (signal.address != null) {
return shortAddress(signal.address);
} else {
return `${signal.location.latitude}, ${signal.location.longitude}`;
return `${signal.location.lat}, ${signal.location.lng}`;
}
}
function title(signal: Signal): string {
@ -52,6 +54,8 @@ function title(signal: Signal): string {
return "Nuisance";
} else if (signal.type == "publicreport water") {
return "Standing water";
} else {
return `Unknown ${signal.type}`;
}
}
</script>

View file

@ -316,16 +316,17 @@ import MapMultipoint from "@/components/MapMultipoint.vue";
import PublicreportCard from "@/components/PublicreportCard.vue";
import TimeRelative from "@/components/TimeRelative.vue";
import { formatAddress } from "@/format";
import { PublicReport } from "@/types";
interface Emits {
(e: "viewImage", index: int): void;
(e: "viewImage", index: number): void;
}
interface Props {
report: Publicreport;
report: PublicReport;
}
const emit = defineEmits<Emits>();
const props = defineProps<Props>();
function openPhotoViewer(index) {
function openPhotoViewer(index: number) {
emit("viewImage", index);
}
</script>

View file

@ -56,11 +56,18 @@
<script setup lang="ts">
import MapMultipoint from "@/components/MapMultipoint.vue";
import MapProxiedArcgisTile from "@/components/MapProxiedArcgisTile.vue";
import ReviewTask from "@/types";
import { Changes, ReviewTask } from "@/types";
interface Props {
changes: Changes;
selectedTask?: ReviewTask;
submitting: boolean;
}
const props = defineProps<Props>();
function discardEntry() {
console.log("Fake discard entry");
}
function markReviewed() {
console.log("Fake mark reviewed");
}
</script>

View file

@ -34,11 +34,10 @@
<input
type="text"
class="form-control"
v-model="selectedTaskChanges.location.longitude"
v-model="poolLocation.lng"
:class="{
'border-warning':
selectedTaskChanges.location.longitude !==
selectedTask.location.longitude,
poolLocation.lng !== selectedTask.pool?.location.lng,
}"
/>
</div>
@ -50,11 +49,10 @@
<input
type="text"
class="form-control"
v-model="selectedTaskChanges.location.latitude"
v-model="poolLocation.lat"
:class="{
'border-warning':
selectedTaskChanges.location?.latitude !==
selectedTask.location?.latitude,
poolLocation.lat !== selectedTask.pool?.location?.lat,
}"
/>
</div>
@ -65,11 +63,10 @@
<div class="col-sm-9">
<select
class="form-select"
v-model="selectedTaskChanges.pool.condition"
v-model="poolCondition"
:class="{
'border-warning':
selectedTaskChanges.pool.condition !==
selectedTask.pool.condition,
poolCondition !== selectedTask.pool?.condition,
}"
>
<option value="">-- Select --</option>
@ -83,23 +80,22 @@
</div>
</div>
<div v-if="selectedTaskChanges.pool.ownerContact" class="row mb-3">
<div class="row mb-3">
<label class="col-sm-3 col-form-label fw-bold">Owner Contact:</label>
<div class="col-sm-9">
<input
type="text"
class="form-control"
v-model="selectedTaskChanges.pool.owner_contact"
v-model="siteOwner.name"
:class="{
'border-warning':
selectedTaskChanges.pool.owner_contact !==
selectedTask.pool.owner_contact,
siteOwner.name !== selectedTask.pool?.site.owner?.name,
}"
/>
</div>
</div>
<div v-if="selectedTaskChanges.pool.resident_contact" class="row mb-4">
<div class="row mb-4">
<label class="col-sm-3 col-form-label fw-bold">
Resident Contact:
</label>
@ -107,11 +103,10 @@
<input
type="text"
class="form-control"
v-model="selectedTaskChanges.pool.resident_contact"
v-model="siteResident.name"
:class="{
'border-warning':
selectedTaskChanges.pool.resident_contact !==
selectedTask.pool.resident_contact,
siteResident.name !== selectedTask.pool?.site.resident?.name,
}"
/>
</div>
@ -120,50 +115,75 @@
</div>
<!-- Map Components -->
<div class="map-container">
<div class="map-container" v-if="session.user">
<MapMultipoint
ref="mapMultipoint"
id="map"
:bounds="mapBounds"
:markers="mapMarkers"
:organizationId="user.organization.id"
:tegola="user.urls.tegola"
:xmin="user.organization.service_area?.min.x ?? 0"
:ymin="user.organization.service_area?.min.y ?? 0"
:xmax="user.organization.service_area?.max.x ?? 0"
:ymax="user.organization.service_area?.max.y ?? 0"
:organizationId="session.user.organization.id"
:tegola="session.urls?.tegola ?? ''"
></MapMultipoint>
</div>
<div v-else>
<p>loading...</p>
</div>
<div class="map-container">
<div class="map-container" v-if="session.user && selectedTask.pool">
<MapProxiedArcgisTile
ref="mapTile"
class="map"
:location="selectedTask.location"
:organization-id="user.organization.id"
:tegola="user.urls.tegola"
:urlTiles="user.urls.tile"
:location="selectedTask.pool?.location"
:markers="[]"
:organizationId="session.user?.organization.id"
:tegola="session.urls?.tegola ?? ''"
:urlTiles="session.urls?.tile ?? ''"
@map-click="doPoolLocation"
></MapProxiedArcgisTile>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import MapMultipoint from "@/components/MapMultipoint.vue";
import MapProxiedArcgisTile from "@/components/MapProxiedArcgisTile.vue";
import { formatAddress } from "@/format";
import ReviewTask from "@/types";
import { useSessionStore } from "@/store/session";
import {
Bounds,
Contact,
Location,
MapClickEvent,
Marker,
Pool,
ReviewTask,
User,
} from "@/types";
interface Props {
loading: boolean;
mapBounds?: Bounds;
mapMarkers: Marker[];
selectedTaskChanges: ReviewTask;
newPoolCondition: string;
newPoolLocation: Location;
selectedTask?: ReviewTask;
user: User | null;
}
const props = defineProps<Props>();
function doPoolLocation(lat, lng) {
console.log("pool location", lat, lng);
const poolCondition = ref<string>("unknown");
const poolLocation = ref<Location>({
lat: 0,
lng: 0,
});
const siteOwner = ref<Contact>({
has_email: false,
has_phone: false,
name: "",
});
const siteResident = ref<Contact>({
has_email: false,
has_phone: false,
name: "",
});
const session = useSessionStore();
function doPoolLocation(event: MapClickEvent) {
console.log("pool location", event);
}
</script>

View file

@ -52,7 +52,7 @@
<i class="bi bi-droplet"></i>
<strong>Pool {{ task.id }}</strong>
</div>
<small class="text-muted">{{ task.condition }}</small>
<small class="text-muted">{{ task.pool?.condition }}</small>
</div>
<small class="text-muted d-block mt-1">
{{ formatAddress(task.address) }}
@ -61,15 +61,16 @@
</template>
<script setup lang="ts">
import { formatAddress } from "@/format";
import { ReviewTask } from "@/types";
interface Emits {
(e: "doSelectTask", id: int): void;
(e: "doSelectTask", id: number): void;
}
interface Props {
error: string | null;
selectedTaskID: int | null;
selectedTaskID: number | null;
tasks: ReviewTask[];
total: int;
total: number;
}
const emit = defineEmits<Emits>();
const props = defineProps<Props>();

View file

@ -26,7 +26,9 @@
to="/_/communication"
icon="messaging"
label="Communication"
:notificationCount="user?.notification_counts?.communication ?? 0"
:notificationCount="
session.user?.notification_counts.communication ?? 0
"
/>
</li>
<li>
@ -40,7 +42,7 @@
to="/_/review"
icon="review"
label="Review"
:notificationCount="user?.notification_counts?.review ?? 0"
:notificationCount="session.user?.notification_counts.review ?? 0"
/>
</li>
<li>
@ -60,17 +62,17 @@
<script setup lang="ts">
import { ref, reactive, onMounted, onBeforeUnmount, nextTick } from "vue";
import { Tooltip, Popover } from "bootstrap";
import NavigationLink from "../common/NavigationLink.vue";
import { useUserStore } from "../../store/user";
import NavigationLink from "@/components/common/NavigationLink.vue";
import { SSEManager } from "@/SSEManager";
import { useSessionStore } from "@/store/session";
// Reactive state
const isCollapsed = ref(false);
const user = useUserStore();
const session = useSessionStore();
// Bootstrap tooltip instances
let tooltipInstances = [];
let sseUnsubscribe = null;
let tooltipInstances: Tooltip[] = [];
// Initialize Bootstrap components
const initializeBootstrap = () => {
@ -142,11 +144,6 @@ onMounted(async () => {
onBeforeUnmount(() => {
// Cleanup Bootstrap tooltips
tooltipInstances.forEach((tooltip) => tooltip.dispose());
// Unsubscribe from SSE
if (sseUnsubscribe) {
sseUnsubscribe();
}
});
</script>

View file

@ -9,6 +9,21 @@ export function formatAddress(address?: Address): string {
}
return `${address.number} ${address.street}, ${address.locality}`;
}
export function formatDistance(meters: number | undefined) {
if (meters === undefined || meters === null) {
return "unknown";
}
if (meters < 1) {
const mm = Math.round(meters * 1000);
return `${mm} mm`;
} else if (meters >= 1000) {
const km = Math.round(meters / 1000);
return `${km} km`;
} else {
const m = Math.round(meters);
return `${m} m`;
}
}
export function formatRelativeTime(dateString: string): string {
if (!dateString) return "";
@ -25,7 +40,7 @@ export function formatRelativeTime(dateString: string): string {
return `${diffDays} day${diffDays > 1 ? "s" : ""} ago`;
}
export function shortAddress(a: Address): string {
if (!a) return "";
export function shortAddress(a: Address | undefined): string {
if (!a) return "unknown";
return `${a.number} ${a.street}, ${a.locality}`;
}

View file

@ -2,7 +2,7 @@ import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { Communication } from "../types";
import { SSEManager } from "../SSEManager";
import { useUserStore } from "./user";
import { useSessionStore } from "./session";
export const useCommunicationStore = defineStore("communication", () => {
// State
@ -17,9 +17,9 @@ export const useCommunicationStore = defineStore("communication", () => {
}
});
// Actions
async function fetchAll() {
const userStore = useUserStore();
if (userStore.urls == null) {
async function fetchAll(): Promise<Communication[]> {
const session = useSessionStore();
if (session.urls == null) {
throw new Error("can't fetch without user URL data");
}
@ -31,7 +31,7 @@ export const useCommunicationStore = defineStore("communication", () => {
//if (typeFilter.value) params.append("type", typeFilter.value);
const response = await fetch(
`${userStore.urls.api.communication}?${params}`,
`${session.urls.api.communication}?${params}`,
);
if (!response.ok) {
@ -39,6 +39,7 @@ export const useCommunicationStore = defineStore("communication", () => {
}
const data = await response.json();
all.value = data.communications;
return data.communications;
} catch (err) {
console.error("Error loading communications:", err);
throw err;

View file

@ -1,15 +1,14 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { ReviewTask } from "../types";
import { SSEManager } from "../SSEManager";
import { useUserStore } from "./user";
import { SSEManager } from "@/SSEManager";
import { ReviewTask } from "@/types";
import { useSessionStore } from "@/store/session";
export const useReviewTaskStore = defineStore("review-task", () => {
// State
const _byID = ref<Map<int, ReviewTask>>(new Map());
const all = ref<ReviewTask[] | null>(null);
const loading = ref(false);
const error = ref(null);
const _byID = ref<Map<number, ReviewTask>>(new Map());
const loading = ref<boolean>(false);
const error = ref<string | null>(null);
// Subscription
SSEManager.subscribe("*", (e) => {
@ -18,12 +17,15 @@ export const useReviewTaskStore = defineStore("review-task", () => {
}
});
// Actions
function byID(id: int) {
function all(): ReviewTask[] {
return Array.from(_byID.value.values());
}
function byID(id: number) {
return _byID.value.get(id);
}
async function fetchAll(): Promise<void> {
const userStore = useUserStore();
if (userStore.urls == null) {
const session = useSessionStore();
if (session.urls == null) {
throw new Error("can't fetch without user URL data");
}
@ -34,15 +36,13 @@ export const useReviewTaskStore = defineStore("review-task", () => {
params.append("sort", "-created");
//if (typeFilter.value) params.append("type", typeFilter.value);
const response = await fetch(
`${userStore.urls.api.review_task}?${params}`,
);
const response = await fetch(`${session.urls.api.review_task}?${params}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
all.value = data.tasks;
_byID.value = new Map();
for (const t of data.tasks) {
_byID.value.set(t.id, t);
}
@ -54,16 +54,16 @@ export const useReviewTaskStore = defineStore("review-task", () => {
loading.value = false;
}
}
async function fetchOne(id: int) {
const userStore = useUserStore();
if (userStore.urls == null) {
async function fetchOne(id: number) {
const session = useSessionStore();
if (session.urls == null) {
throw new Error("can't fetch without user URL data");
}
loading.value = true;
error.value = null;
try {
const response = await fetch(`${userStore.urls.api.review_task}/${id}`);
const response = await fetch(`${session.urls.api.review_task}/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
@ -76,6 +76,9 @@ export const useReviewTaskStore = defineStore("review-task", () => {
throw err;
}
}
function remove(id: number) {
_byID.value.delete(id);
}
return {
// State
@ -84,5 +87,6 @@ export const useReviewTaskStore = defineStore("review-task", () => {
byID,
fetchAll,
fetchOne,
remove,
};
});

62
ts/store/session.ts Normal file
View file

@ -0,0 +1,62 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { SSEManager } from "@/SSEManager";
import {
Organization,
URLs,
User,
UserNotificationCounts,
UserResponse,
} from "@/types";
export const useSessionStore = defineStore("session", () => {
// State
const error = ref<string | null>(null);
const loading = ref(false);
const user = ref<User | null>(null);
const urls = ref<URLs | null>(null);
// Subscription
SSEManager.subscribe("*", (e) => {
if (e.type !== "heartbeat") {
fetchSession();
}
});
// Actions
async function fetchSession(): Promise<UserResponse> {
loading.value = true;
error.value = null;
try {
const response = await fetch("/api/user/self");
if (!response.ok) throw new Error("Failed to fetch user");
const data: UserResponse = await response.json();
user.value = data.self;
urls.value = data.urls;
return data;
} catch (e) {
error.value = e instanceof Error ? e.message : "an error ocurred";
console.error("Error fetching user:", e);
throw new Error(error.value);
} finally {
loading.value = false;
}
}
async function isAuthenticated(): Promise<boolean> {
console.log("pretend check user auth");
return true;
}
return {
// State
error,
loading,
user,
urls,
// Actions
fetchSession,
isAuthenticated,
};
});

View file

@ -2,7 +2,7 @@ import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { Signal } from "../types";
import { SSEManager } from "../SSEManager";
import { useUserStore } from "./user";
import { useSessionStore } from "@/store/session";
export const useSignalStore = defineStore("signal", () => {
// State
@ -18,8 +18,8 @@ export const useSignalStore = defineStore("signal", () => {
});
// Actions
async function fetchAll() {
const userStore = useUserStore();
if (userStore.urls == null) {
const session = useSessionStore();
if (session.urls == null) {
throw new Error("can't fetch without user URL data");
}
@ -30,7 +30,7 @@ export const useSignalStore = defineStore("signal", () => {
params.append("sort", "-created");
//if (typeFilter.value) params.append("type", typeFilter.value);
const response = await fetch(`${userStore.urls.api.signal}?${params}`);
const response = await fetch(`${session.urls.api.signal}?${params}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);

View file

@ -1,12 +1,12 @@
import { defineStore } from "pinia";
import { ref } from "vue";
import { Upload } from "../types";
import { SSEManager } from "../SSEManager";
import { useUserStore } from "./user";
import { Upload } from "@/types";
import { SSEManager } from "@/SSEManager";
import { useSessionStore } from "@/store/session";
export const useUploadStore = defineStore("upload", () => {
// State
const _byID = ref<Map<int, Upload>>(new Map());
const _byID = ref<Map<number, Upload>>(new Map());
const all = ref<Upload[] | null>(null);
const loading = ref(false);
const error = ref(null);
@ -18,12 +18,12 @@ export const useUploadStore = defineStore("upload", () => {
}
});
// Actions
function byID(id: int) {
function byID(id: number) {
return _byID.value.get(id);
}
async function fetchAll() {
const userStore = useUserStore();
if (userStore.urls == null) {
const session = useSessionStore();
if (session.urls == null) {
throw new Error("can't fetch without user URL data");
}
@ -34,7 +34,7 @@ export const useUploadStore = defineStore("upload", () => {
params.append("sort", "-created");
//if (typeFilter.value) params.append("type", typeFilter.value);
const response = await fetch(`${userStore.urls.api.upload}?${params}`);
const response = await fetch(`${session.urls.api.upload}?${params}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
@ -49,16 +49,16 @@ export const useUploadStore = defineStore("upload", () => {
throw err;
}
}
async function fetchOne(id: int) {
const userStore = useUserStore();
if (userStore.urls == null) {
async function fetchOne(id: number) {
const session = useSessionStore();
if (session.urls == null) {
throw new Error("can't fetch without user URL data");
}
loading.value = true;
error.value = null;
try {
const response = await fetch(`${userStore.urls.api.upload}/${id}`);
const response = await fetch(`${session.urls.api.upload}/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);

View file

@ -1,104 +0,0 @@
import { defineStore } from "pinia";
import { ref, computed } from "vue";
import { SSEManager } from "../SSEManager";
// Define interfaces matching your Go structs
interface URLsAPI {
communication: string;
signal: string;
}
interface URLs {
api: URLsAPI;
tegola: string;
tile: string;
}
interface User {
display_name: string;
initials: string;
notification_counts: NotificationCounts;
notifications: any[]; // Replace with proper type
organization: string; // Replace with proper type
role: string;
username: string;
}
interface UserResponse {
self: User;
urls: URLs;
}
interface NotificationCounts {
// Add the actual structure based on your API
[key: string]: number;
}
export const useUserStore = defineStore("user", () => {
// State
const display_name = ref<string | null>(null);
const error = ref<string | null>(null);
const initials = ref<string | null>(null);
const loading = ref(false);
const notification_counts = ref<NotificationCounts | null>(null);
const notifications = ref<any[] | null>(null);
const organization = ref<string | null>(null);
const role = ref<string | null>(null);
const urls = ref<URLs | null>(null);
const username = ref<string | null>(null);
// Subscription
SSEManager.subscribe("*", (e) => {
if (e.type !== "heartbeat") {
fetchUser();
}
});
// Actions
async function fetchUser() {
loading.value = true;
error.value = null;
try {
const response = await fetch("/api/user/self");
if (!response.ok) throw new Error("Failed to fetch user");
const data: UserResponse = await response.json();
display_name.value = data.self.display_name;
initials.value = data.self.initials;
notification_counts.value = data.self.notification_counts;
notifications.value = data.self.notifications;
organization.value = data.self.organization;
role.value = data.self.role;
urls.value = data.urls;
username.value = data.self.username;
console.log("loaded user data", data);
} catch (e) {
error.value = e instanceof Error ? e.message : "an error ocurred";
console.error("Error fetching user:", e);
} finally {
loading.value = false;
}
}
async function isAuthenticated(): boolean {
console.log("pretend check user auth");
return true;
}
return {
// State
display_name,
error,
initials,
loading,
notification_counts,
notifications,
organization,
role,
urls,
username,
// Actions
fetchUser,
isAuthenticated,
};
});

View file

@ -2,11 +2,11 @@ import { defineStore } from "pinia";
import { ref } from "vue";
import { User } from "../types";
import { SSEManager } from "../SSEManager";
import { useUserStore } from "./user";
import { useSessionStore } from "./session";
export const useUsersStore = defineStore("users", () => {
// State
const _byID = ref<Map<int, User>>(new Map());
const _byID = ref<Map<number, User>>(new Map());
const all = ref<User[] | null>(null);
const loading = ref(false);
const error = ref(null);
@ -18,14 +18,14 @@ export const useUsersStore = defineStore("users", () => {
}
});
// Actions
function byID(id: int) {
function byID(id: number) {
const result = _byID.value.get(id);
console.log("user", id, result);
return result;
}
async function fetchAll(): Promise<User[]> {
const userStore = useUserStore();
if (userStore.urls == null) {
const session = useSessionStore();
if (session.urls == null) {
throw new Error("can't fetch without user URL data");
}
@ -36,7 +36,7 @@ export const useUsersStore = defineStore("users", () => {
params.append("sort", "-created");
//if (typeFilter.value) params.append("type", typeFilter.value);
const response = await fetch(`${userStore.urls.api.users}?${params}`);
const response = await fetch(`${session.urls.api.user}?${params}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
@ -52,16 +52,16 @@ export const useUsersStore = defineStore("users", () => {
throw err;
}
}
async function fetchOne(id: int) {
const userStore = useUserStore();
if (userStore.urls == null) {
async function fetchOne(id: number) {
const session = useSessionStore();
if (session.urls == null) {
throw new Error("can't fetch without user URL data");
}
loading.value = true;
error.value = null;
try {
const response = await fetch(`${userStore.urls.api.user}/${id}`);
const response = await fetch(`${session.urls.api.user}/${id}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);

View file

@ -1,7 +1,22 @@
import type { Map as MapLibreMap } from "maplibre-gl";
export interface Address {
country: string;
locality: string;
number: string;
postal_code: string;
raw: string;
region: string;
street: string;
unit: string;
}
export interface Bounds {
min: Location;
max: Location;
}
export interface Changes {
updated: string[];
unchanged: string[];
}
export interface Communication {
@ -10,24 +25,240 @@ export interface Communication {
public_report: PublicReport | null;
type: string;
}
export interface Point {
lat: Number;
lng: Number;
export interface Contact {
has_email: boolean;
has_phone: boolean;
name?: string;
}
export interface Bounds {
min: Point;
max: Point;
export interface CSVPoolDetailCount {
existing: number;
new: number;
outside: number;
}
export interface CSVPoolError {
column: number;
line: number;
message: string;
}
export interface CSVPoolDetail {
count: CSVPoolDetailCount;
errors: CSVPoolError[];
pools: UploadPoolRow[];
}
export interface Exif {
created: string;
make: string;
model: string;
}
export interface Followup {
description: string;
id: number;
title: string;
}
export interface Image {
distance_from_reporter_meters?: number;
exif: Exif;
exif_make: string;
exif_model: string;
exif_datetime: string;
location?: Location;
report_id: number;
url_content: string;
uuid: string;
}
export interface Lead {
description: string;
id: number;
title: string;
}
export interface Location {
lat: number;
lng: number;
}
export interface LogEntry {
created: string;
id: number;
message: string;
report_id: number;
type: string;
user_id: number;
}
export interface MapClickEvent {
location: Location;
map: MapLibreMap;
point: Point;
}
export interface Marker {
color: string;
draggable: boolean;
id: string;
location: Point;
location: Location;
}
export interface Nuisance {
additional_info: string;
duration: string;
is_location_backyard: boolean;
is_location_frontyard: boolean;
is_location_garden: boolean;
is_location_other: boolean;
is_location_pool: boolean;
source_container: boolean;
source_description: string;
source_gutter: boolean;
source_stagnant: boolean;
time_of_day_day: boolean;
time_of_day_early: boolean;
time_of_day_evening: boolean;
time_of_day_night: boolean;
}
export interface Organization {
id: number;
service_area?: Bounds;
}
export interface Point {
x: number;
y: number;
}
export interface Pool {
condition: string;
id: number;
location: Location;
site: Site;
}
export interface PublicReport {
address: Address;
address_raw: string;
created: string;
images: Image[];
location?: Location;
log: LogEntry[];
nuisance?: Nuisance;
public_id: string;
reporter: Contact;
status: string;
type: string;
water?: Water;
}
export interface Signal {
address?: Address;
addressed?: string;
addressor?: number;
created: string;
creator: number;
id: number;
location: Location;
pool?: Pool;
report?: PublicReport;
species?: string;
type: string;
}
export interface ReviewTask {
address: Address;
addressed?: string;
addressor?: User;
created: string;
creator: User;
pool?: ReviewTaskPool;
id: number;
}
export interface ReviewTaskPool {
condition: string;
location: Location;
owner: Contact;
site: Site;
}
export interface Signal {}
export interface Site {
address: Address;
created: string;
creator_id: number;
file_id: number;
id: number;
location: Location;
notes: string;
organization_id: number;
owner?: Contact;
parcel_id?: number;
resident?: Contact;
resident_owned: boolean;
tags: Map<string, string>;
version: number;
}
export interface Upload {
created: string;
filename: string;
id: number;
recordcount: number;
status: string;
type: string;
csv_pool?: CSVPoolDetail;
}
export interface UploadPoolRow {
address: Address;
condition: string;
errors: UploadPoolError[];
status: string;
tags: Map<string, string>;
}
export interface UploadPoolError {
column: number;
line: number;
message: string;
}
export interface URLs {
api: URLsAPI;
tegola: string;
tile: string;
}
// Define interfaces matching your Go structs
interface URLsAPI {
avatar: string;
communication: string;
publicreport_message: string;
review_task: string;
signal: string;
upload: string;
user: string;
}
export interface User {
active: boolean;
avatar: string;
display_name: string;
id: number;
initials: string;
notifications: Notification[];
notification_counts: UserNotificationCounts;
organization: Organization;
role: string;
tags: string[];
uri: string;
username: string;
}
export interface UserNotificationCounts {
communication: number;
home: number;
review: number;
}
export interface UserResponse {
self: User;
urls: URLs;
}
export interface Water {
access_comments: string;
access_gate: boolean;
access_fence: boolean;
access_locked: boolean;
access_dog: boolean;
access_other: boolean;
comments: string;
has_adult: boolean;
has_backyard_permission: boolean;
has_larvae: boolean;
has_pupae: boolean;
is_reporter_confidential: boolean;
is_reporter_owner: boolean;
owner: Contact;
}

View file

@ -27,8 +27,7 @@
:mapBounds="mapBounds || undefined"
:mapMarkers="mapMarkers"
:selectedCommunication="selectedCommunication"
:user="user"
@viewImage="openPhotoViewer"
@viewImage="openImageViewer"
/>
</template>
<template #right>
@ -38,17 +37,16 @@
@markSignal="markSignal"
@sendMessage="sendMessage"
:selectedCommunication="selectedCommunication"
:user="user"
/>
</template>
</ThreeColumn>
<PhotoViewerModal
@close="showPhotoModal = false"
<ImageViewerModal
@close="showImageModal = false"
@imageNext="imageNext()"
@imagePrevious="imagePrevious()"
:images="currentImages"
:currentPhotoIndex="currentPhotoIndex"
:show="showPhotoModal"
:currentImageIndex="currentImageIndex"
:show="showImageModal"
/>
<ToastNotification
:message="toastMessage"
@ -62,35 +60,36 @@ import { computed, onMounted, ref } from "vue";
import maplibregl from "maplibre-gl";
import { useCommunicationStore } from "@/store/communication";
import { useUserStore } from "@/store/user";
import { useSessionStore } from "@/store/session";
import CommunicationColumnAction from "@/components/CommunicationColumnAction.vue";
import CommunicationColumnDetail from "@/components/CommunicationColumnDetail.vue";
import CommunicationColumnList from "@/components/CommunicationColumnList.vue";
import PhotoViewerModal from "@/components/PhotoViewerModal.vue";
import ImageViewerModal from "@/components/ImageViewerModal.vue";
import ThreeColumn from "@/components/layout/ThreeColumn.vue";
import ToastNotification from "@/components/ToastNotification.vue";
import { Bounds, Communication, Marker } from "@/types";
const communication = useCommunicationStore();
const user = useUserStore();
const session = useSessionStore();
onMounted(() => {
fetchCommunications();
});
// Refs
const currentPhotoIndex = ref<int>(0);
const currentImageIndex = ref<number>(0);
const error = ref<string | null>(null);
const loading = ref<boolean>(true);
const mapBounds = ref<Bounds | null>(null);
const mapMarkers = ref<Marker[]>([]);
const selectedId = ref<string | null>(null);
const showPhotoModal = ref(false);
const showImageModal = ref(false);
const toastMessage = ref("");
const toastShow = ref(false);
const toastTitle = ref("");
const currentPhoto = computed(() => {
const currentImage = computed(() => {
const comm = selectedCommunication.value;
return comm.public_report?.images[currentPhotoIndex] ?? null;
return comm?.public_report?.images[currentImageIndex.value] ?? null;
});
const currentImages = computed(() => {
const comm = selectedCommunication.value;
@ -99,7 +98,8 @@ const currentImages = computed(() => {
}
return comm.public_report.images ?? [];
});
const selectedCommunication = computed<Communication | null>(() => {
const selectedCommunication = computed<Communication | null>(
(): Communication | null => {
if (selectedId.value == null) {
return null;
}
@ -107,8 +107,9 @@ const selectedCommunication = computed<Communication | null>(() => {
return null;
}
const result = communication.all.find((c) => c.id == selectedId.value);
return result;
});
return result || null;
},
);
const handleDeselect = (id: string) => {
selectedId.value = null;
updateMap();
@ -119,24 +120,15 @@ const handleSelect = (id: string) => {
};
async function fetchCommunications() {
await communication.fetchAll();
// if we already had something selected, reset it using the new data
if (selectedCommunication.value) {
const matching = communication.all.filter((c) => {
return c.id === selectedCommunication.value.id;
});
if (matching.length > 0) {
selectedCommunication.value = matching[0];
}
}
}
function imageNext() {
currentPhotoIndex.value = Math.min(
currentImageIndex.value = Math.min(
currentImages.value.length - 1,
currentPhotoIndex.value + 1,
currentImageIndex.value + 1,
);
}
function imagePrevious() {
currentPhotoIndex.value = Math.max(0, currentPhotoIndex.value - 1);
currentImageIndex.value = Math.max(0, currentImageIndex.value - 1);
}
async function loadFromAPI() {
loading.value = true;
@ -144,19 +136,22 @@ async function loadFromAPI() {
try {
await Promise.all([fetchCommunications()]);
} catch (err) {
error.value = err.message;
error.value = err instanceof Error ? err.message : "fetch error";
console.error("Error loading data:", err);
} finally {
loading.value = false;
}
}
function openPhotoViewer(index) {
currentPhotoIndex.value = index;
showPhotoModal.value = true;
function openImageViewer(index: number) {
currentImageIndex.value = index;
showImageModal.value = true;
}
async function markInvalid() {
if (selectedCommunication.value == null) {
return;
}
console.log("Marking report as invalid:", selectedCommunication.value.id);
const payload = {
reportID: selectedCommunication.value.id,
@ -178,6 +173,9 @@ async function markInvalid() {
}
async function markSignal() {
if (selectedCommunication.value == null) {
return;
}
console.log("Marking report as signal:", selectedCommunication.value.id);
try {
const report_id = selectedCommunication.value.id;
@ -201,12 +199,15 @@ async function markSignal() {
);
await fetchCommunications();
} catch (err) {
error.value = err.message;
error.value = err instanceof Error ? err.message : "fetch error";
console.error("Error creating lead:", err);
}
}
function removeCurrentFromList() {
if (communication.all == null) {
return;
}
const index = communication.all.findIndex((c) => c.id === selectedId.value);
if (index > -1) {
communication.all.splice(index, 1);
@ -220,14 +221,15 @@ function removeCurrentFromList() {
}
async function sendMessage(message: string) {
if (!message.trim()) return;
console.log("Sending message reporter:", message.value);
if (selectedCommunication.value == null) return;
if (session.urls == null) return;
console.log("Sending message reporter:", message);
const payload = {
message: message.value,
message: message,
reportID: selectedCommunication.value.id,
};
const response = await fetch(user.urls.api.publicreport_message, {
const response = await fetch(session.urls?.api.publicreport_message, {
method: "POST",
headers: {
"Content-Type": "application/json",
@ -241,11 +243,10 @@ async function sendMessage(message: string) {
showNotification(
"Message Sent",
`Message successfully sent to ${selectedCommunication.value.public_report.reporter.name}`,
`Message successfully sent to ${selectedCommunication.value.public_report?.reporter.name}`,
);
messageText.value = "";
}
function showNotification(title, message) {
function showNotification(title: string, message: string) {
toastTitle.value = title;
toastMessage.value = message;
toastShow.value = true;
@ -268,35 +269,26 @@ function updateMap() {
color: "#FF0000",
draggable: false,
id: String(Date.now()),
location: {
lng: loc.longitude,
lat: loc.latitude,
},
location: loc,
},
];
console.log("markers now", mapMarkers.value);
let min = { lat: loc.latitude, lng: loc.longitude };
let max = { lat: loc.latitude, lng: loc.longitude };
let min = loc;
let max = loc;
for (const i of selectedCommunication.value.public_report.images) {
if (
i.location != null &&
i.location.latitude != 0 &&
i.location.longitude != 0
) {
for (const i of selectedCommunication.value?.public_report?.images ?? []) {
if (i.location != null && i.location.lat != 0 && i.location.lng != 0) {
mapMarkers.value.push({
color: "#00FF00",
draggable: false,
location: {
lat: i.location.latitude,
lng: i.location.longitude,
},
id: new Date().toISOString(),
location: i.location,
});
min.lat = Math.min(min.lat, i.location.latitude);
min.lng = Math.min(min.lng, i.location.longitude);
max.lat = Math.max(max.lat, i.location.latitude);
max.lng = Math.max(max.lng, i.location.longitude);
min.lat = Math.min(min.lat, i.location.lat);
min.lng = Math.min(min.lng, i.location.lng);
max.lat = Math.max(max.lat, i.location.lat);
max.lng = Math.max(max.lng, i.location.lng);
}
}
@ -311,9 +303,6 @@ function updateMap() {
},
};
}
function onFilterChange(filters) {
console.log("Filters changed");
}
// Lifecycle hooks
onMounted(async () => {
await loadFromAPI();

View file

@ -120,17 +120,15 @@
<h3 class="section-title">Mosquito Activity Heatmap</h3>
<div class="row">
<div class="col-12">
<p v-if="dashboard.serviceArea.min.x === 0.0">
<p v-if="session.user && dashboard.serviceArea.min.x === 0.0">
No service area for this organization yet
</p>
<map-aggregate
<MapAggregate
v-else
:organization-id="dashboard.organization.id"
:tegola="dashboard.tegolaUrl"
:xmin="dashboard.serviceArea.min.x"
:ymin="dashboard.serviceArea.min.y"
:xmax="dashboard.serviceArea.max.x"
:ymax="dashboard.serviceArea.max.y"
:bounds="session.user?.organization.service_area"
:markers="[]"
:organizationId="dashboard.organization.id"
:tegola="session.urls?.tegola ?? ''"
/>
</div>
</div>
@ -152,17 +150,6 @@
<th>Action</th>
</tr>
</thead>
<tbody>
<tr v-for="(sr, i) in dashboard.recentRequests" :key="i">
<td>{{ formatTimeRelative(sr.date) }}</td>
<td>Service Request</td>
<td>{{ sr.location }}</td>
<td><span class="badge bg-success">Completed</span></td>
<td>
<a href="#" class="btn btn-sm btn-outline-primary">View</a>
</td>
</tr>
</tbody>
</table>
</div>
</div>
@ -173,7 +160,9 @@
<script setup lang="ts">
import { onMounted, reactive } from "vue";
import MapAggregate from "../components/MapAggregate.vue";
import MapAggregate from "@/components/MapAggregate.vue";
import { useSessionStore } from "@/store/session";
const dashboard = reactive({
counts: {
service_requests: 0,
@ -182,7 +171,7 @@ const dashboard = reactive({
},
organization: {
name: "",
id: "",
id: 0,
},
isSyncOngoing: false,
lastSync: new Date(),
@ -193,6 +182,7 @@ const dashboard = reactive({
},
recentRequests: [],
});
const session = useSessionStore();
onMounted(async () => {});
function formatBigNumber(n: number): string {
// Convert the number to a string

View file

@ -60,28 +60,28 @@
import { computed, ref, onMounted, nextTick } from "vue";
import MapMultipoint from "../components/MapMultipoint.vue";
import PlanningColumnAction from "../components/PlanningColumnAction.vue";
import PlanningColumnDetail from "../components/PlanningColumnDetail.vue";
import PlanningColumnList from "../components/PlanningColumnList.vue";
import ThreeColumn from "../components/layout/ThreeColumn.vue";
import TimeRelative from "../components/TimeRelative.vue";
import { useSignalStore } from "../store/signal";
import { useUserStore } from "../store/user";
import PlanningColumnAction from "@/components/PlanningColumnAction.vue";
import PlanningColumnDetail from "@/components/PlanningColumnDetail.vue";
import PlanningColumnList from "@/components/PlanningColumnList.vue";
import ThreeColumn from "@/components/layout/ThreeColumn.vue";
import TimeRelative from "@/components/TimeRelative.vue";
import { useSignalStore } from "@/store/signal";
import { useSessionStore } from "@/store/session";
import { Lead, Location, Point, Signal } from "@/types";
// Refs
const mapTile = ref(null);
// State
const apiBase = ref("/api");
const creating = ref(false);
const error = ref(null);
const leads = ref([]);
const loading = ref(false);
const error = ref<string | null>(null);
const leads = ref<Lead[]>([]);
const loading = ref<boolean>(false);
const planFollowups = ref([]);
const poolLocations = ref({});
const selectedSignalIDs = ref(new Set<int>([]));
const selectedSignalIDs = ref(new Set<number>([]));
const signal = useSignalStore();
const user = useUserStore();
const session = useSessionStore();
function doAddToLead() {
console.log("doAddToLead");
@ -111,21 +111,21 @@ function doSplitLead() {
console.log("doSplitLead");
}
// Helper functions (outside component)
const getBoundingBox = (points) => {
const getBoundingBox = (points: Location[]) => {
if (!points || points.length === 0) {
return null;
}
let minLat = points[0].latitude;
let maxLat = points[0].latitude;
let minLng = points[0].longitude;
let maxLng = points[0].longitude;
let minLat = points[0].lat;
let maxLat = points[0].lat;
let minLng = points[0].lng;
let maxLng = points[0].lng;
for (const point of points) {
if (point.latitude < minLat) minLat = point.latitude;
if (point.latitude > maxLat) maxLat = point.latitude;
if (point.longitude < minLng) minLng = point.longitude;
if (point.longitude > maxLng) maxLng = point.longitude;
if (point.lat < minLat) minLat = point.lat;
if (point.lat > maxLat) maxLat = point.lat;
if (point.lng < minLng) minLng = point.lng;
if (point.lng > maxLng) maxLng = point.lng;
}
return new window.maplibregl.LngLatBounds(
@ -140,16 +140,18 @@ const selectedSignals = computed(() => {
if (selectedSignalIDs.value.size == 0 || signal.all == null) {
return [];
}
const result = signal.all.filter((s) => selectedSignalIDs.value.has(s));
const result = signal.all.filter((s: Signal) =>
selectedSignalIDs.value.has(s.id),
);
return result;
});
const updateMap = (signals) => {
const updateMap = (signals: Signal[]) => {
const locations = signals.map((s) => s.location);
const markers = locations.map((l) =>
new window.maplibregl.Marker({
color: "#FF0000",
draggable: false,
}).setLngLat([l.longitude, l.latitude]),
}).setLngLat([l.lng, l.lat]),
);
/*
@ -170,7 +172,7 @@ const loadData = async () => {
try {
await signal.fetchAll();
} catch (err) {
error.value = err.message;
error.value = err instanceof Error ? err.message : "fetch error";
console.error("Error loading data:", err);
} finally {
loading.value = false;
@ -179,7 +181,7 @@ const loadData = async () => {
const loadPlanFollowups = async () => {
try {
const response = await fetch(`${apiBase.value}/plan-followups`);
const response = await fetch("/api/plan-followups");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
@ -203,7 +205,7 @@ const createLead = async () => {
creating.value = true;
try {
const response = await fetch(`${apiBase.value}/leads`, {
const response = await fetch("api/leads", {
method: "POST",
headers: {
"Content-Type": "application/json",
@ -227,7 +229,6 @@ const createLead = async () => {
await loadData();
} catch (err) {
console.error("Error creating lead:", err);
alert(`Failed to create lead: ${err.message}`);
} finally {
creating.value = false;
}
@ -237,7 +238,7 @@ const markAsAddressed = async () => {
if (selectedSignalIDs.value.size === 0) return;
try {
const response = await fetch(`${apiBase.value}/signal/mark-addressed`, {
const response = await fetch("/api/signal/mark-addressed", {
method: "POST",
headers: {
"Content-Type": "application/json",
@ -251,24 +252,24 @@ const markAsAddressed = async () => {
throw new Error(`HTTP error! status: ${response.status}`);
}
/*
signals.value = signals.value.filter(
(signal) => !selectedSignalIDs.value.has(s.id),
);
*/
clearSelection();
alert("Signals marked as addressed");
} catch (err) {
console.error("Error marking signals as addressed:", err);
alert(`Failed to mark signals: ${err.message}`);
}
};
const refresh = () => {
loadData();
};
const signalDeselect = (id: int) => {
const signalDeselect = (id: number) => {
selectedSignalIDs.value.delete(id);
};
const signalSelect = (id: int) => {
const signalSelect = (id: number) => {
selectedSignalIDs.value.add(id);
};
// Lifecycle

View file

@ -144,12 +144,8 @@
<div class="mb-3">
<label for="emailFrom" class="form-label">From Account</label>
<select class="form-select" id="emailFrom" name="emailFrom">
<option :value="forwardEmailRMOAddress">
{{ forwardEmailRMOAddress }}
</option>
<option :value="forwardEmailNidusAddress">
{{ forwardEmailNidusAddress }}
</option>
<option value="RMO address"></option>
<option value="Sync address"></option>
</select>
</div>
<div class="mb-3">
@ -210,7 +206,7 @@
name="organizationID"
placeholder="Organization ID"
type="text"
:value="organizationID"
:value="session"
/>
</div>
<div class="mb-3">
@ -421,4 +417,9 @@
</div>
</template>
<script setup lang="ts"></script>
<script setup lang="ts">
import { ref } from "vue";
import { useSessionStore } from "@/store/session";
const session = useSessionStore();
</script>

View file

@ -143,7 +143,7 @@
upload.status
}}</span>
</td>
<td>{{ upload.record_count }} entries</td>
<td>{{ upload.recordcount }} entries</td>
<td>
<RouterLink :to="`/_/configuration/upload/${upload.id}`"
><button class="btn btn-sm btn-outline-primary">View</button>
@ -160,9 +160,10 @@
import { computed, onMounted } from "vue";
import TimeRelative from "@/components/TimeRelative.vue";
import { useUploadStore } from "@/store/upload";
import { Upload } from "@/types";
const uploadStore = useUploadStore();
const uploads = computed(() => {
const uploads = computed((): Upload[] | null => {
return uploadStore.all;
});
onMounted(() => {

View file

@ -100,11 +100,12 @@ tr.has-error {
</style>
<template>
<div class="container mt-4 results-container">
<div v-if="upload">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>Upload Results: {{ upload?.filename }}</h2>
<span class="badge rounded-pill" :class="upload?.status">
<i class="bi me-1" :class="getUploadStatusIcon(upload?.status)"></i>
{{ getUploadStatusDisplay(upload?.status) }}
<h2>Upload Results: {{ upload.filename ?? "" }}</h2>
<span class="badge rounded-pill" :class="upload.status">
<i class="bi me-1" :class="getUploadStatusIcon(upload.status)"></i>
{{ getUploadStatusDisplay(upload.status) }}
</span>
</div>
@ -113,7 +114,7 @@ tr.has-error {
<div class="card summary-card h-100 border-primary">
<div class="card-body text-center">
<h1 class="display-4 text-primary">
{{ upload?.csv_pool.count.existing }}
{{ upload.csv_pool?.count.existing }}
</h1>
<h5>Existing Pools</h5>
<p class="text-muted">Matches found in previous records</p>
@ -124,7 +125,7 @@ tr.has-error {
<div class="card summary-card h-100 border-success">
<div class="card-body text-center">
<h1 class="display-4 text-success">
{{ upload?.csv_pool.count.new }}
{{ upload.csv_pool?.count.new }}
</h1>
<h5>New Pools</h5>
<p class="text-muted">Not found in existing records</p>
@ -135,7 +136,7 @@ tr.has-error {
<div class="card summary-card h-100 border-warning">
<div class="card-body text-center">
<h1 class="display-4 text-warning">
{{ upload?.csv_pool.count.outside }}
{{ upload.csv_pool?.count.outside }}
</h1>
<h5>Outside District</h5>
<p class="text-muted">Potential geocoding errors</p>
@ -145,19 +146,15 @@ tr.has-error {
</div>
<div class="card mb-4">
<div v-if="user == null">
<div v-if="session.user == null">
<p>loading</p>
</div>
<div v-else>
<MapMultipoint
id="map"
:bounds="session.user?.organization.service_area"
:markers="[]"
:organization-id="user.organization.id"
:tegola="user.urls.tegola"
:xmin="user?.organization?.serviceArea?.min.x ?? 0"
:ymin="user?.organization?.serviceArea?.min.y ?? 0"
:xmax="user?.organization?.serviceArea?.max.x ?? 0"
:ymax="user?.organization?.serviceArea?.max.y ?? 0"
:organizationId="session.user?.organization.id"
:tegola="session.urls?.tegola ?? ''"
></MapMultipoint>
</div>
</div>
@ -182,7 +179,7 @@ tr.has-error {
</div>
<div class="card-body">
<div
v-for="error in upload?.errors"
v-for="error in upload.csv_pool?.errors"
:key="error.message"
class="alert alert-danger"
role="alert"
@ -206,7 +203,9 @@ tr.has-error {
<template v-else>
<div
v-if="!upload?.csv_pool.pools || upload.csv_pool.pools.length === 0"
v-if="
!upload.csv_pool?.pools || upload.csv_pool.pools.length === 0
"
class="alert alert-warning"
role="alert"
>
@ -234,17 +233,21 @@ tr.has-error {
v-for="(pool, index) in upload.csv_pool.pools"
:key="index"
:class="{
'has-error': pool.errors && pool.errors.length > 0,
'has-error': hasError(upload.csv_pool, index),
}"
:style="getRowStyle(pool)"
>
<td>
<i
v-if="pool.errors && pool.errors.length > 0"
v-if="hasError(upload.csv_pool, index)"
class="bi bi-info-circle-fill text-primary ms-1"
data-bs-toggle="tooltip"
data-bs-placement="top"
:title="pool.errors.map((e) => e.message).join(', ')"
:title="
errorsForLine(upload.csv_pool, index)
.map((e) => e.message)
.join(', ')
"
></i>
</td>
<td>{{ pool.address?.number }}</td>
@ -261,7 +264,7 @@ tr.has-error {
{{ titleCase(pool.condition) }}
</span>
</td>
<td>{{ pool.tags?.length || 0 }}</td>
<td>{{ pool.tags?.size || 0 }}</td>
</tr>
</tbody>
</table>
@ -289,49 +292,27 @@ tr.has-error {
</button>
</div>
</div>
<div v-else>
<p>loading...</p>
</div>
</div>
</template>
<script setup lang="ts">
import * as bootstrap from "bootstrap";
import { ref, onMounted, computed } from "vue";
import { useRouter } from "vue-router";
import MapMultipoint from "@/components/MapMultipoint.vue";
import { useUploadStore } from "@/store/upload";
import { useUserStore } from "@/store/user";
interface Address {
number: string;
street: string;
locality: string;
postal_code: string;
}
import { useSessionStore } from "@/store/session";
import { CSVPoolDetail, CSVPoolError, Upload, UploadPoolRow } from "@/types";
interface ErrorMessage {
message: string;
}
interface Pool {
address?: Address;
status: string;
condition: string;
tags?: string[];
errors?: ErrorMessage[];
}
interface CSVPool {
pools: Pool[];
}
interface Upload {
name: string;
status: string;
countExisting: number;
countNew: number;
countOutside: number;
errors?: ErrorMessage[];
csv_pool?: CSVPool;
}
interface Props {
id: int;
id: number;
}
const props = defineProps<Props>();
@ -340,7 +321,7 @@ const router = useRouter();
const showIssuesOnly = ref(false);
const isSubmitting = ref(false);
const uploadStore = useUploadStore();
const user = useUserStore();
const session = useSessionStore();
const upload = ref<Upload | null>(null);
@ -371,7 +352,7 @@ const titleCase = (str?: string): string => {
if (!str) return "";
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
};
const getRowStyle = (pool: Pool) => {
const getRowStyle = (pool: UploadPoolRow) => {
if (showIssuesOnly.value) {
const hasError = pool.errors && pool.errors.length > 0;
return { display: hasError ? "table-row" : "none" };
@ -384,6 +365,7 @@ const handleShowIssuesOnly = () => {
};
const initializeMap = () => {
/*
if (!map) return;
map.addEventListener("load", () => {
@ -407,6 +389,7 @@ const initializeMap = () => {
},
});
});
*/
};
const handleDiscard = async () => {
@ -453,7 +436,18 @@ const handleCommit = async () => {
isSubmitting.value = false;
}
};
function hasError(csv: CSVPoolDetail, index: number): boolean {
return !!errorsForLine(csv, index);
}
function errorsForLine(csv: CSVPoolDetail, index: number): CSVPoolError[] {
let results = [];
for (const e of csv.errors) {
if (e.line == index) {
results.push(e);
}
}
return results;
}
onMounted(() => {
initializeMap();
uploadStore.fetchOne(props.id).then((u) => {

View file

@ -102,13 +102,13 @@
import CSVUpload from "@/components/CSVUpload.vue";
import { router } from "@/router";
function onError(err) {
function onError(err: Error) {
console.error("CSV upload error", err);
}
function onFileSelected(file) {
function onFileSelected(file: File) {
console.log("file selected", file);
}
function onUploadSuccess(data) {
function onUploadSuccess(data: any) {
console.log("upload success", data);
router.push("/_" + data.uri);
}

View file

@ -47,7 +47,6 @@
<tr>
<th>User</th>
<th>Role</th>
<th>Status</th>
<th>Tags</th>
<th>Actions</th>
</tr>
@ -58,25 +57,17 @@
<div class="d-flex align-items-center">
<img
:src="user.avatar"
:alt="user.name"
:alt="user.display_name"
class="tech-photo me-3"
/>
<div>
<div class="fw-bold">{{ user.name }}</div>
<div class="fw-bold">{{ user.display_name }}</div>
</div>
</div>
</td>
<td>
<span class="badge bg-success">{{ user.role }}</span>
</td>
<td>
<span
class="badge status-badge"
:class="getStatusClass(user.status)"
>
{{ user.status }}
</span>
</td>
<td>
<span
v-for="tag in user.tags"
@ -137,11 +128,6 @@ const urlConfiguration = ref<URLConfiguration>({
userAdd: "/configuration/user/add", // Update with your actual route
});
// Methods
const getStatusClass = (status: string): string => {
return status === "Active" ? "bg-success" : "bg-secondary";
};
const getTagClass = (tag: string): string => {
if (tag === "warrant service") return "bg-warrant";
if (tag === "drone pilot") return "bg-drone";
@ -149,12 +135,16 @@ const getTagClass = (tag: string): string => {
};
const deactivateUser = (userId: number): void => {
if (users.value == null) {
return;
}
const user = users.value.find((u) => u.id === userId);
if (user) {
user.status = "Inactive";
if (!user) {
return;
}
user.active = false;
// Add your deactivation logic here (e.g., API call)
console.log(`Deactivating user: ${userId}`);
}
};
// Lifecycle hooks

View file

@ -40,7 +40,7 @@ pre {
<div class="card-header bg-primary text-white">
<h4 class="mb-0">User Configuration</h4>
</div>
<div v-if="user_" class="card-body">
<div v-if="user" class="card-body">
<!-- Avatar Section -->
<div class="row mb-4">
<div class="col-md-12">
@ -88,7 +88,7 @@ pre {
</label>
<input
id="displayName"
v-model="user_.display_name"
v-model="user.display_name"
type="text"
class="form-control"
placeholder="Enter display name"
@ -102,7 +102,7 @@ pre {
</label>
<input
id="username"
v-model="user_.username"
v-model="user.username"
type="text"
class="form-control"
readonly
@ -117,14 +117,14 @@ pre {
<label for="userRole" class="form-label fw-bold">
User Role
</label>
<select id="userRole" v-model="user_.role" class="form-select">
<select id="userRole" v-model="user.role" class="form-select">
<option value="">Select a role</option>
<option
v-for="role in availableRoles"
:key="role.value"
:value="role.value"
v-for="option in optionRoles"
:key="option.value"
:value="option.value"
>
{{ role.label }}
{{ option.label }}
</option>
</select>
</div>
@ -137,7 +137,7 @@ pre {
type="radio"
class="btn-check"
id="statusActive"
v-model="user_.status"
v-model="user.active"
value="active"
/>
<label class="btn btn-outline-success" for="statusActive">
@ -148,7 +148,7 @@ pre {
type="radio"
class="btn-check"
id="statusInactive"
v-model="user_.status"
v-model="user.active"
value="inactive"
/>
<label class="btn btn-outline-secondary" for="statusInactive">
@ -164,7 +164,7 @@ pre {
<label class="form-label fw-bold">User Tags</label>
<div class="mb-2">
<span
v-for="tag in user_.tags"
v-for="tag in user.tags"
:key="tag"
class="badge bg-info text-dark me-2 mb-2"
>
@ -177,7 +177,7 @@ pre {
></button>
</span>
<span
v-if="user_.tags.length === 0"
v-if="user.tags.length === 0"
class="text-muted fst-italic"
>
No tags added
@ -190,7 +190,7 @@ pre {
v-for="tag in availableTags"
:key="tag"
:value="tag"
:disabled="user_.tags.includes(tag)"
:disabled="user.tags.includes(tag)"
>
{{ tag }}
</option>
@ -199,7 +199,7 @@ pre {
class="btn btn-outline-primary"
type="button"
@click="addTag"
:disabled="!selectedTag || user_.tags.includes(selectedTag)"
:disabled="!selectedTag || user.tags.includes(selectedTag)"
>
<i class="bi bi-plus-lg"></i> Add Tag
</button>
@ -241,25 +241,16 @@ pre {
<script setup lang="ts">
import { computed, defineComponent, onMounted, ref, reactive } from "vue";
import { useUserStore } from "@/store/user";
import { useSessionStore } from "@/store/session";
import { useUsersStore } from "@/store/users";
interface User {
avatar: string;
displayName: string;
username: string;
role: string;
status: "active" | "inactive";
tags: string[];
}
interface Role {
value: string;
label: string;
}
import { User } from "@/types";
interface Props {
id: int;
id: number;
}
interface Option {
label: string;
value: string;
}
const avatar = ref<string>("");
const fileInput = ref<HTMLInputElement | null>(null);
@ -267,13 +258,13 @@ const props = defineProps<Props>();
const selectedFile = ref<File | null>(null);
const selectedTag = ref<string>("");
const usersStore = useUsersStore();
const user = useUserStore();
const user_ = ref<User | null>(null);
const session = useSessionStore();
const user = ref<User | null>(null);
const defaultAvatar =
"https://via.placeholder.com/150/cccccc/666666?text=No+Avatar";
const availableRoles: Role[] = [
const optionRoles: Option[] = [
{ value: "account-owner", label: "Account Owner" },
{ value: "manager", label: "Manager" },
{ value: "tech1", label: "Tech 1" },
@ -308,35 +299,51 @@ const handleAvatarChange = (event: Event) => {
};
const removeAvatar = () => {
avatar = "";
avatar.value = "";
if (fileInput.value) {
fileInput.value.value = "";
}
};
const addTag = () => {
if (selectedTag.value && !user_.value.tags.includes(selectedTag.value)) {
user_.value.tags.push(selectedTag.value);
if (user.value == null) {
return;
}
if (selectedTag.value && !user.value.tags.includes(selectedTag.value)) {
user.value.tags.push(selectedTag.value);
selectedTag.value = "";
}
};
const removeTag = (tag: string) => {
const index = user_.value.tags.indexOf(tag);
if (user.value == null) {
return;
}
const index = user.value.tags.indexOf(tag);
if (index > -1) {
user_.value.tags.splice(index, 1);
user.value.tags.splice(index, 1);
}
};
interface UserRequestPut {
avatar: string | null;
}
const saveChanges = async () => {
console.log("Saving user changes");
let userPayload = {};
let payload: UserRequestPut = {
avatar: "",
};
if (selectedFile.value) {
try {
const formData = new FormData();
formData.append("file", selectedFile.value);
const response = await fetch(user.urls.api.avatar, {
const url = session.urls?.api.avatar;
if (!url) {
console.log("empty avatar url");
return;
}
const response = await fetch(url, {
body: formData,
method: "POST",
});
@ -345,17 +352,22 @@ const saveChanges = async () => {
}
const data = await response.json();
userPayload.avatar = data.uri;
payload.avatar = data.uri;
} catch (error) {
console.error("Failed to upload avatar", error);
}
}
const response = await fetch(user.urls.api.users, {
const url = session.urls?.api.user;
if (!url) {
console.log("empty avatar url");
return;
}
const response = await fetch(url, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(userPayload),
body: JSON.stringify(payload),
});
if (!response.ok) {
const errorData = await response.json();
@ -381,7 +393,7 @@ onMounted(() => {
usersStore.fetchAll().then((users) => {
for (const u of users) {
if (u.id == props.id) {
user_.value = u;
user.value = u;
console.log("User set to", u);
}
}

View file

@ -66,7 +66,7 @@ body {
@doSelectTask="selectTask"
:error="error"
:selectedTaskID="selectedTaskID"
:tasks="reviewTask.all"
:tasks="reviewTask.all()"
:total="totalPending"
/>
<div v-else>
@ -78,13 +78,13 @@ body {
:loading="loading"
:mapBounds="mapBounds || undefined"
:mapMarkers="mapMarkers"
:selectedTaskChanges="selectedTaskChanges"
:newPoolCondition="newPoolCondition"
:newPoolLocation="newPoolLocation"
:selectedTask="selectedTask"
:user="user"
/>
</template>
<template #right>
<ReviewPoolColumnAction :submitting="submitting" />
<ReviewPoolColumnAction :changes="changes" :submitting="submitting" />
</template>
</ThreeColumn>
</template>
@ -92,35 +92,21 @@ body {
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useReviewTaskStore } from "@/store/review-task";
import { useUserStore } from "@/store/user";
import { useSessionStore } from "@/store/session";
import maplibregl from "maplibre-gl";
import ThreeColumn from "@/components/layout/ThreeColumn.vue";
import ReviewTask from "@/types";
import ReviewPoolColumnAction from "@/components/ReviewPoolColumnAction.vue";
import ReviewPoolColumnDetail from "@/components/ReviewPoolColumnDetail.vue";
import ReviewPoolColumnList from "@/components/ReviewPoolColumnList.vue";
// TypeScript Interfaces
interface Address {
number: string;
street: string;
locality: string;
}
interface Location {
latitude: number;
longitude: number;
}
interface Task {
id: number;
location: Location;
condition: string;
ownerContact?: string;
residentContact?: string;
poolShape?: string;
address?: Address;
}
import {
Bounds,
Changes,
Contact,
Location,
MapClickEvent,
Marker,
ReviewTask,
} from "@/types";
interface FormData {
latitude: number;
@ -136,19 +122,6 @@ interface FieldConfig {
label: string;
}
interface Changes {
updated: string[];
unchanged: string[];
}
interface MapClickEvent {
detail: {
map: any;
lat: number;
lng: number;
};
}
// Props (you can pass these from parent component or environment)
interface Props {
tegolaUrl?: string;
@ -173,20 +146,20 @@ const props = withDefaults(defineProps<Props>(), {
});
// State
const selectedTaskChanges = ref<ReviewTask>({
location: {},
pool: {},
});
const newPoolCondition = ref<string>("");
const newPoolLocation = ref<Location>({ lat: 0, lng: 0 });
const newOwnerName = ref<string>("");
const newResidentName = ref<string>("");
const error = ref<string | null>(null);
const loading = ref<boolean>(true);
const mapBounds = ref<Bounds | null>(null);
const mapMarkers = ref<Marker[]>([]);
const selectedTaskID = ref<int | null>(null);
const selectedTaskID = ref<number | null>(null);
const submitting = ref<boolean>(false);
const totalPending = ref<number>(0);
const reviewTask = useReviewTaskStore();
const user = useUserStore();
const session = useSessionStore();
// Refs for map components
const mapMultipoint = ref<any>(null);
@ -194,7 +167,8 @@ const mapTile = ref<any>(null);
// Computed: track which fields have changed
const changes = computed<Changes>(() => {
if (!selectedTask.value) return { updated: [], unchanged: [] };
const pool = selectedTask.value?.pool;
if (!pool) return { updated: [], unchanged: [] };
const updated: string[] = [];
const unchanged: string[] = [];
@ -205,23 +179,40 @@ const changes = computed<Changes>(() => {
{ key: "condition", label: "Pool condition" },
{ key: "ownerContact", label: "Owner contact" },
{ key: "residentContact", label: "Resident contact" },
{ key: "poolShape", label: "Pool shape" },
];
fields.forEach((field) => {
if (selectedTaskChanges[field.key] !== selectedTask.value[field.key]) {
updated.push(field.label);
if (newPoolCondition.value != pool.condition) {
updated.push("condition");
} else {
unchanged.push(field.label);
unchanged.push("condition");
}
if (newPoolLocation.value.lat != pool.site.location.lat) {
updated.push("latitude");
} else {
unchanged.push("latitude");
}
if (newPoolLocation.value.lng != pool.site.location.lng) {
updated.push("longitude");
} else {
unchanged.push("longitude");
}
if (newOwnerName.value != pool.site.owner?.name) {
updated.push("ownerContact");
} else {
unchanged.push("ownerContact");
}
if (newResidentName.value != pool.site.resident?.name) {
updated.push("residentContact");
} else {
unchanged.push("residentContact");
}
});
return { updated, unchanged };
});
const selectedTask = computed<ReviewTask | null>(() => {
const selectedTask = computed<ReviewTask | undefined>(() => {
if (selectedTaskID.value == null) {
return null;
return undefined;
}
return reviewTask.byID(selectedTaskID.value);
});
@ -230,40 +221,53 @@ async function fetchTasks() {
}
// Helper Functions
// Task Selection
function selectTask(id: int): void {
function selectTask(id: number): void {
console.log("Selected task", id);
selectedTaskID.value = id;
selectedTaskChanges.value = {
location: {},
pool: {},
};
const task = reviewTask.byID(id);
if (!task) {
console.log("no task", id);
return;
}
const pool = task.pool;
if (!pool) {
console.log("no pool for selected task");
return;
}
newPoolCondition.value = pool.condition;
newPoolLocation.value = pool.location;
newOwnerName.value = pool.site.owner?.name ?? "";
newResidentName.value = pool.site.resident?.name ?? "";
// Update map
const task = reviewTask.byID(id);
updateMap(task);
}
// Map Update
function updateMap(task: Task): void {
function updateMap(task: ReviewTask): void {
console.log("Updating map for task:", task.id);
const map = mapMultipoint.value;
if (!map) return;
const loc = task.location;
const loc = task.pool?.location;
if (!loc) {
map.SetMarkers([]);
return;
}
const markers = [
new maplibregl.Marker({
color: "#FF0000",
draggable: false,
}).setLngLat([loc.longitude, loc.latitude]),
}).setLngLat([loc.lng, loc.lat]),
];
map.SetMarkers(markers);
const bounds = new maplibregl.LngLatBounds(
new maplibregl.LngLat(loc.longitude - 0.005, loc.latitude - 0.005),
new maplibregl.LngLat(loc.longitude + 0.005, loc.latitude + 0.005),
new maplibregl.LngLat(loc.lng - 0.005, loc.lat - 0.005),
new maplibregl.LngLat(loc.lng + 0.005, loc.lat + 0.005),
);
map.FitBounds(bounds, {
@ -273,23 +277,22 @@ function updateMap(task: Task): void {
// Map Click Handler
function updatePoolLocation(event: MapClickEvent): void {
console.log("map click", selectedTask.value?.id, event.detail);
console.log("map click", selectedTask.value?.id, event);
const map = event.detail.map;
const loc = {
latitude: event.detail.lat,
longitude: event.detail.lng,
};
/*
const map = event.map;
const loc = event.location;
map.SetMarkers([
new maplibregl.Marker({
color: "#FF0000",
draggable: false,
}).setLngLat([event.detail.lng, event.detail.lat]),
}).setLngLat([loc.lng, loc.lat]),
]);
selectedTaskChanges.latitude = event.detail.lat;
selectedTaskChanges.longitude = event.detail.lng;
*/
}
// Submit Review
@ -303,7 +306,7 @@ async function submitReview(action: "committed" | "discarded"): Promise<void> {
const payload: any = {
task_id: selectedTask.value.id,
status: action,
updates: selectedTaskChanges,
//updates: selectedTaskChanges,
};
const response = await fetch("/api/review/pool", {
@ -319,22 +322,7 @@ async function submitReview(action: "committed" | "discarded"): Promise<void> {
}
// Remove task from list
const taskIndex = reviewTask.all.value.findIndex(
(t) => t.id === selectedTask.value!.id,
);
if (taskIndex > -1) {
reviewTask.all.value.splice(taskIndex, 1);
totalPending.value = Math.max(0, totalPending.value - 1);
}
// Select next task or clear selection
if (reviewTask.all.length > 0) {
const nextIndex = Math.min(taskIndex, reviewTask.all.length - 1);
selectTask(reviewTask.all[nextIndex]);
} else {
selectedTask.value = null;
originalValues.value = {};
}
reviewTask.remove(selectedTask.value!.id);
// Update list of tasks
await fetchTasks();

View file

@ -1,9 +1,15 @@
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import checker from "vite-plugin-checker";
import path from "path";
export default defineConfig({
plugins: [vue()],
plugins: [
vue(),
checker({
vueTsc: true,
}),
],
resolve: {
alias: {