Get rows to show on individual upload page.
This commit is contained in:
parent
1ad3c5a5c8
commit
f60bde7fd9
6 changed files with 389 additions and 369 deletions
|
|
@ -40,6 +40,7 @@ func AddRoutes(r chi.Router) {
|
|||
r.Method("GET", "/tile/{z}/{y}/{x}", auth.NewEnsureAuth(getTile))
|
||||
r.Method("POST", "/upload/pool/flyover", authenticatedHandlerPostMultipart(postUploadPoolFlyoverCreate, file.CollectionCSV))
|
||||
r.Method("POST", "/upload/pool/custom", authenticatedHandlerPostMultipart(postUploadPoolCustomCreate, file.CollectionCSV))
|
||||
r.Method("GET", "/upload", authenticatedHandlerJSON(getUploadList))
|
||||
r.Method("GET", "/upload/{id}", authenticatedHandlerJSON(getUploadByID))
|
||||
r.Method("POST", "/upload/{id}/commit", authenticatedHandlerJSONPost(postUploadCommit))
|
||||
r.Method("POST", "/upload/{id}/discard", authenticatedHandlerJSONPost(postUploadDiscard))
|
||||
|
|
|
|||
|
|
@ -1,19 +1,21 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
nhttp "github.com/Gleipnir-Technology/nidus-sync/http"
|
||||
"github.com/Gleipnir-Technology/nidus-sync/db/enums"
|
||||
"github.com/Gleipnir-Technology/nidus-sync/html"
|
||||
nhttp "github.com/Gleipnir-Technology/nidus-sync/http"
|
||||
"github.com/Gleipnir-Technology/nidus-sync/platform"
|
||||
"github.com/Gleipnir-Technology/nidus-sync/platform/file"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
func getUploadByID(ctx context.Context, r *http.Request, u platform.User, query queryParams) (*platform.UploadPoolDetail, *nhttp.ErrorWithStatus) {
|
||||
|
||||
func getUploadByID(ctx context.Context, r *http.Request, u platform.User, query queryParams) (*platform.Upload, *nhttp.ErrorWithStatus) {
|
||||
file_id_str := chi.URLParam(r, "id")
|
||||
file_id_, err := strconv.ParseInt(file_id_str, 10, 32)
|
||||
if err != nil {
|
||||
|
|
@ -28,24 +30,27 @@ func getUploadByID(ctx context.Context, r *http.Request, u platform.User, query
|
|||
}
|
||||
|
||||
type contentUploadList struct {
|
||||
RecentUploads []platform.UploadSummary
|
||||
RecentUploads []platform.Upload
|
||||
}
|
||||
type contentUploadPlaceholder struct{}
|
||||
|
||||
func getUploadList(ctx context.Context, r *http.Request, user platform.User) (*html.Response[contentUploadList], *nhttp.ErrorWithStatus) {
|
||||
rows, err := platform.UploadSummaryList(ctx, user.Organization)
|
||||
return html.NewResponse("sync/upload-list.html", contentUploadList{
|
||||
RecentUploads: rows,
|
||||
}), nhttp.NewErrorMaybe("get upload list: %w", err)
|
||||
func getUploadList(ctx context.Context, r *http.Request, user platform.User, req queryParams) (*contentUploadPoolList, *nhttp.ErrorWithStatus) {
|
||||
rows, err := platform.UploadList(ctx, user.Organization)
|
||||
if err != nil {
|
||||
return nil, nhttp.NewError("Get upload list: %w", err)
|
||||
}
|
||||
return &contentUploadPoolList{
|
||||
Uploads: rows,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type contentUploadDetail struct {
|
||||
CSVFileID int32
|
||||
Organization platform.Organization
|
||||
Upload platform.UploadPoolDetail
|
||||
Upload platform.Upload
|
||||
}
|
||||
type contentUploadPoolList struct {
|
||||
Uploads []platform.Upload
|
||||
Uploads []platform.Upload `json:"uploads"`
|
||||
}
|
||||
type contentUploadPool struct{}
|
||||
|
||||
|
|
@ -115,7 +120,7 @@ func postUploadPoolFlyoverCreate(ctx context.Context, r *http.Request, u platfor
|
|||
if err != nil {
|
||||
return "", nhttp.NewError("Failed to create new pool: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("/configuration/upload/%d", saved_upload.ID), nil
|
||||
return fmt.Sprintf("/configuration/upload/%d", *saved_upload), nil
|
||||
}
|
||||
func postUploadPoolCustomCreate(ctx context.Context, r *http.Request, u platform.User, uploads []file.Upload) (string, *nhttp.ErrorWithStatus) {
|
||||
if len(uploads) == 0 {
|
||||
|
|
@ -129,5 +134,5 @@ func postUploadPoolCustomCreate(ctx context.Context, r *http.Request, u platform
|
|||
if err != nil {
|
||||
return "", nhttp.NewError("Failed to create new pool: %w", err)
|
||||
}
|
||||
return fmt.Sprintf("/configuration/upload/%d", pool_upload.ID), nil
|
||||
return fmt.Sprintf("/configuration/upload/%d", *pool_upload), nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import (
|
|||
"github.com/Gleipnir-Technology/bob/dialect/psql/sm"
|
||||
"github.com/Gleipnir-Technology/nidus-sync/db"
|
||||
"github.com/Gleipnir-Technology/nidus-sync/db/models"
|
||||
"github.com/Gleipnir-Technology/nidus-sync/platform/types"
|
||||
//"github.com/rs/zerolog/log"
|
||||
"github.com/stephenafamo/scan"
|
||||
)
|
||||
|
|
@ -23,13 +22,7 @@ type UploadPoolError struct {
|
|||
Line uint
|
||||
Message string
|
||||
}
|
||||
type UploadPoolRow struct {
|
||||
Address types.Address
|
||||
Condition string
|
||||
Errors []UploadPoolError
|
||||
Status string
|
||||
Tags map[string]string
|
||||
}
|
||||
|
||||
func errorsByLine(ctx context.Context, file *models.FileuploadFile) ([]UploadPoolError, map[int32][]UploadPoolError, error) {
|
||||
file_errors := make([]UploadPoolError, 0)
|
||||
errors_by_line := make(map[int32][]UploadPoolError, 0)
|
||||
|
|
|
|||
|
|
@ -35,35 +35,34 @@ const (
|
|||
)
|
||||
|
||||
type Upload struct {
|
||||
Created time.Time `db:"created"`
|
||||
ID int32 `db:"id"`
|
||||
Status string `db:"status"`
|
||||
Created time.Time `db:"created" json:"created"`
|
||||
Filename string `db:"filename" json:"filename"`
|
||||
ID int32 `db:"id" json:"id"`
|
||||
RecordCount int `db:"recordcount" json:"recordcount"`
|
||||
Status string `db:"status" json:"status"`
|
||||
Type string `db:"type" json:"type"`
|
||||
CSVPool *CSVPoolDetail `json:"csv_pool"`
|
||||
}
|
||||
|
||||
type UploadSummary struct {
|
||||
Created time.Time `db:"created"`
|
||||
Filename string `db:"filename"`
|
||||
ID int32 `db:"id"`
|
||||
RecordCount int `db:"recordcount"`
|
||||
Status string `db:"status"`
|
||||
Type string `db:"type"`
|
||||
}
|
||||
type UploadPoolDetailCount struct {
|
||||
type CSVPoolDetailCount struct {
|
||||
Existing int `json:"existing"`
|
||||
New int `json:"new"`
|
||||
Outside int `json:"outside"`
|
||||
}
|
||||
type UploadPoolDetail struct {
|
||||
Count UploadPoolDetailCount `json:"count"`
|
||||
Created time.Time `json:"created"`
|
||||
Errors []UploadPoolError `json:"errors"`
|
||||
ID int32 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Pools []UploadPoolRow `json:"pools"`
|
||||
Status string `json:"status"`
|
||||
type CSVPoolDetail struct {
|
||||
Count CSVPoolDetailCount `json:"count"`
|
||||
Errors []UploadPoolError `json:"errors"`
|
||||
Pools []UploadPoolRow `json:"pools"`
|
||||
}
|
||||
type UploadPoolRow struct {
|
||||
Address types.Address `json:"address"`
|
||||
Condition string `json:"condition"`
|
||||
Errors []UploadPoolError `json:"errors"`
|
||||
Status string `json:"status"`
|
||||
Tags map[string]string `json:"tags"`
|
||||
}
|
||||
|
||||
func GetUploadDetail(ctx context.Context, organization_id int32, file_id int32) (*UploadPoolDetail, error) {
|
||||
func GetUploadDetail(ctx context.Context, organization_id int32, file_id int32) (*Upload, error) {
|
||||
file, err := models.FindFileuploadFile(ctx, db.PGInstance.BobDB, file_id)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to lookup file %d: %w", file_id, err)
|
||||
|
|
@ -74,14 +73,14 @@ func GetUploadDetail(ctx context.Context, organization_id int32, file_id int32)
|
|||
}
|
||||
switch csv.Type {
|
||||
case enums.FileuploadCsvtypeFlyover:
|
||||
return getUploadPoollistDetail(ctx, file)
|
||||
return getUploadDetailPool(ctx, file)
|
||||
case enums.FileuploadCsvtypePoollist:
|
||||
return getUploadPoollistDetail(ctx, file)
|
||||
return getUploadDetailPool(ctx, file)
|
||||
}
|
||||
return nil, errors.New("No idea what to do with upload type")
|
||||
}
|
||||
|
||||
func NewUpload(ctx context.Context, u User, upload file.Upload, t enums.FileuploadCsvtype) (*Upload, error) {
|
||||
func NewUpload(ctx context.Context, u User, upload file.Upload, t enums.FileuploadCsvtype) (*int32, error) {
|
||||
txn, err := db.PGInstance.BobDB.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Failed to begin transaction: %w", err)
|
||||
|
|
@ -117,9 +116,7 @@ func NewUpload(ctx context.Context, u User, upload file.Upload, t enums.Fileuplo
|
|||
return nil, fmt.Errorf("background job create: %w", err)
|
||||
}
|
||||
txn.Commit(ctx)
|
||||
return &Upload{
|
||||
ID: file.ID,
|
||||
}, nil
|
||||
return &file.ID, nil
|
||||
}
|
||||
func UploadCommit(ctx context.Context, org Organization, file_id int32, committer User) error {
|
||||
txn, err := db.PGInstance.BobDB.BeginTx(ctx, nil)
|
||||
|
|
@ -155,12 +152,12 @@ func UploadDiscard(ctx context.Context, org Organization, file_id int32) error {
|
|||
).Exec(ctx, db.PGInstance.BobDB)
|
||||
return err
|
||||
}
|
||||
func UploadSummaryList(ctx context.Context, org Organization) ([]UploadSummary, error) {
|
||||
results := make([]UploadSummary, 0)
|
||||
func UploadList(ctx context.Context, org Organization) ([]Upload, error) {
|
||||
results := make([]Upload, 0)
|
||||
rows, err := bob.All(ctx, db.PGInstance.BobDB, psql.Select(
|
||||
sm.Columns(
|
||||
// fileupload.csv columns
|
||||
//"csv.file_id",
|
||||
//"csv.file_id AS file_id",
|
||||
//"csv.committed",
|
||||
"csv.rowcount AS recordcount",
|
||||
"csv.type_ AS type",
|
||||
|
|
@ -176,19 +173,19 @@ func UploadSummaryList(ctx context.Context, org Organization) ([]UploadSummary,
|
|||
"file.status AS status",
|
||||
//"file.size_bytes",
|
||||
//"file.file_uuid",
|
||||
|
||||
// Aggregate data
|
||||
),
|
||||
sm.From("fileupload.csv").As("csv"),
|
||||
sm.InnerJoin("fileupload.file").As("file").OnEQ(psql.Raw("csv.file_id"), psql.Raw("file.id")),
|
||||
sm.Where(psql.Quote("file", "organization_id").EQ(psql.Arg(org.ID))),
|
||||
sm.OrderBy("created").Desc(),
|
||||
), scan.StructMapper[UploadSummary]())
|
||||
), scan.StructMapper[Upload]())
|
||||
if err != nil {
|
||||
return results, fmt.Errorf("Failed to query pool upload rows: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
func getUploadPoollistDetail(ctx context.Context, file *models.FileuploadFile) (*UploadPoolDetail, error) {
|
||||
func getUploadDetailPool(ctx context.Context, file *models.FileuploadFile) (*Upload, error) {
|
||||
file_errors, errors_by_line, err := errorsByLine(ctx, file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get errors by line: %w", err)
|
||||
|
|
@ -239,16 +236,20 @@ func getUploadPoollistDetail(ctx context.Context, file *models.FileuploadFile) (
|
|||
})
|
||||
}
|
||||
log.Debug().Str("status", file.Status.String()).Int32("id", file.ID).Msg("returning")
|
||||
return &UploadPoolDetail{
|
||||
Count: UploadPoolDetailCount{
|
||||
Existing: count_existing,
|
||||
Outside: count_outside,
|
||||
New: count_new,
|
||||
return &Upload{
|
||||
Created: file.Created,
|
||||
Filename: file.Name,
|
||||
ID: file.ID,
|
||||
RecordCount: len(pool_rows),
|
||||
CSVPool: &CSVPoolDetail{
|
||||
Count: CSVPoolDetailCount{
|
||||
Existing: count_existing,
|
||||
Outside: count_outside,
|
||||
New: count_new,
|
||||
},
|
||||
Errors: file_errors,
|
||||
Pools: pools,
|
||||
},
|
||||
Errors: file_errors,
|
||||
ID: file.ID,
|
||||
Name: file.Name,
|
||||
Pools: pools,
|
||||
Status: file.Status.String(),
|
||||
Status: file.Status.String(),
|
||||
}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
|
||||
import { defineStore } from "pinia";
|
||||
import { ref } from "vue";
|
||||
import { Upload } from "../types";
|
||||
|
|
@ -7,6 +6,7 @@ import { useUserStore } from "./user";
|
|||
|
||||
export const useUploadStore = defineStore("upload", () => {
|
||||
// State
|
||||
const _byID = ref<Map<int, Upload>>(new Map());
|
||||
const all = ref<Upload[] | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref(null);
|
||||
|
|
@ -18,11 +18,8 @@ export const useUploadStore = defineStore("upload", () => {
|
|||
}
|
||||
});
|
||||
// Actions
|
||||
function byID(id: int): Upload? {
|
||||
if (all.value == null) {
|
||||
return null
|
||||
}
|
||||
return all.value.find((upload) => upload.id == id);
|
||||
function byID(id: int) {
|
||||
return _byID.value.get(id);
|
||||
}
|
||||
async function fetchAll() {
|
||||
const userStore = useUserStore();
|
||||
|
|
@ -44,6 +41,31 @@ export const useUploadStore = defineStore("upload", () => {
|
|||
}
|
||||
const data = await response.json();
|
||||
all.value = data.uploads;
|
||||
for (const u of data.uploads) {
|
||||
_byID.value.set(u.id, u);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error loading uploads:", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
async function fetchOne(id: int) {
|
||||
const userStore = useUserStore();
|
||||
if (userStore.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}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
_byID.value.set(data.id, data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error("Error loading uploads:", err);
|
||||
throw err;
|
||||
|
|
@ -56,5 +78,6 @@ export const useUploadStore = defineStore("upload", () => {
|
|||
// Actions
|
||||
byID,
|
||||
fetchAll,
|
||||
fetchOne,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,86 +1,86 @@
|
|||
<style scoped>
|
||||
.results-container {
|
||||
max-width: 1400px;
|
||||
max-width: 1400px;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
transition: transform 0.2s;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.summary-card:hover {
|
||||
transform: translateY(-5px);
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
.has-error {
|
||||
background-color: #fff3cd;
|
||||
background-color: #fff3cd;
|
||||
}
|
||||
|
||||
.badge.status,
|
||||
.badge.condition {
|
||||
font-size: 0.875rem;
|
||||
padding: 0.35em 0.65em;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.35em 0.65em;
|
||||
}
|
||||
|
||||
.table-responsive {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
thead tr.header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background-color: #f8f9fa;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
#map {
|
||||
height: 400px;
|
||||
width: 100%;
|
||||
height: 400px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="container mt-4 results-container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Upload Results: {{ upload?.name }}</h2>
|
||||
<span class="badge rounded-pill" :class="upload?.status">
|
||||
<i class="bi me-1" :class="getUploadStatusIcon(upload?.status)"></i>
|
||||
{{ getUploadStatusDisplay(upload?.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="container mt-4 results-container">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>Upload Results: {{ upload?.name }}</h2>
|
||||
<span class="badge rounded-pill" :class="upload?.status">
|
||||
<i class="bi me-1" :class="getUploadStatusIcon(upload?.status)"></i>
|
||||
{{ getUploadStatusDisplay(upload?.status) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card summary-card h-100 border-primary">
|
||||
<div class="card-body text-center">
|
||||
<h1 class="display-4 text-primary">
|
||||
{{ upload?.countExisting }}
|
||||
</h1>
|
||||
<h5>Existing Pools</h5>
|
||||
<p class="text-muted">Matches found in previous records</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card summary-card h-100 border-success">
|
||||
<div class="card-body text-center">
|
||||
<h1 class="display-4 text-success">{{ upload?.countNew }}</h1>
|
||||
<h5>New Pools</h5>
|
||||
<p class="text-muted">Not found in existing records</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card summary-card h-100 border-warning">
|
||||
<div class="card-body text-center">
|
||||
<h1 class="display-4 text-warning">{{ upload?.countOutside }}</h1>
|
||||
<h5>Outside District</h5>
|
||||
<p class="text-muted">Potential geocoding errors</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-4">
|
||||
<div class="card summary-card h-100 border-primary">
|
||||
<div class="card-body text-center">
|
||||
<h1 class="display-4 text-primary">
|
||||
{{ upload?.countExisting }}
|
||||
</h1>
|
||||
<h5>Existing Pools</h5>
|
||||
<p class="text-muted">Matches found in previous records</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card summary-card h-100 border-success">
|
||||
<div class="card-body text-center">
|
||||
<h1 class="display-4 text-success">{{ upload?.countNew }}</h1>
|
||||
<h5>New Pools</h5>
|
||||
<p class="text-muted">Not found in existing records</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<div class="card summary-card h-100 border-warning">
|
||||
<div class="card-body text-center">
|
||||
<h1 class="display-4 text-warning">{{ upload?.countOutside }}</h1>
|
||||
<h5>Outside District</h5>
|
||||
<p class="text-muted">Potential geocoding errors</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card mb-4">
|
||||
<div v-if="user == null">
|
||||
<p>loading</p>
|
||||
</div>
|
||||
|
|
@ -96,315 +96,312 @@ thead tr.header {
|
|||
:ymax="user?.organization?.serviceArea?.max.y ?? 0"
|
||||
></MapMultipoint>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">Data Preview</h5>
|
||||
<div class="form-check form-switch">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="showIssuesOnly"
|
||||
v-model="showIssuesOnly"
|
||||
@change="handleShowIssuesOnly"
|
||||
/>
|
||||
<label class="form-check-label" for="showIssuesOnly">
|
||||
Show issues only
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div
|
||||
v-for="error in upload?.errors"
|
||||
:key="error.message"
|
||||
class="alert alert-danger"
|
||||
role="alert"
|
||||
>
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong>Error:</strong> {{ error.message }}
|
||||
</div>
|
||||
<div class="card mb-4">
|
||||
<div
|
||||
class="card-header bg-light d-flex justify-content-between align-items-center"
|
||||
>
|
||||
<h5 class="mb-0">Data Preview</h5>
|
||||
<div class="form-check form-switch">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
id="showIssuesOnly"
|
||||
v-model="showIssuesOnly"
|
||||
@change="handleShowIssuesOnly"
|
||||
/>
|
||||
<label class="form-check-label" for="showIssuesOnly">
|
||||
Show issues only
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div
|
||||
v-for="error in upload?.errors"
|
||||
:key="error.message"
|
||||
class="alert alert-danger"
|
||||
role="alert"
|
||||
>
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong>Error:</strong> {{ error.message }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="upload?.status === 'uploaded' || upload?.status === 'parsing'"
|
||||
class="alert alert-info"
|
||||
role="alert"
|
||||
>
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong>Working:</strong> File is still processing... refresh this
|
||||
page in a bit to see updates.
|
||||
</div>
|
||||
<div v-if="upload == null">Loading...</div>
|
||||
<div
|
||||
v-else-if="
|
||||
upload.status === 'uploaded' || upload.status === 'parsing'
|
||||
"
|
||||
class="alert alert-info"
|
||||
role="alert"
|
||||
>
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong>Working:</strong> File is still processing... refresh this
|
||||
page in a bit to see updates.
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="!upload?.pools || upload.pools.length === 0"
|
||||
class="alert alert-warning"
|
||||
role="alert"
|
||||
>
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong>Warning:</strong> No pools could be understood from your file.
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-if="!upload?.csv_pool.pools || upload.csv_pool.pools.length === 0"
|
||||
class="alert alert-warning"
|
||||
role="alert"
|
||||
>
|
||||
<i class="bi bi-exclamation-triangle me-2"></i>
|
||||
<strong>Warning:</strong> No pools could be understood from your
|
||||
file.
|
||||
</div>
|
||||
|
||||
<div v-else class="table-responsive">
|
||||
<table class="table table-hover table-striped">
|
||||
<thead class="table-light">
|
||||
<tr class="header">
|
||||
<th></th>
|
||||
<th>Number</th>
|
||||
<th>Street</th>
|
||||
<th>City</th>
|
||||
<th>Post</th>
|
||||
<th>Status</th>
|
||||
<th>Condition</th>
|
||||
<th>Tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(pool, index) in upload.pools"
|
||||
:key="index"
|
||||
:class="{ 'has-error': pool.errors && pool.errors.length > 0 }"
|
||||
:style="getRowStyle(pool)"
|
||||
>
|
||||
<td>
|
||||
<i
|
||||
v-if="pool.errors && pool.errors.length > 0"
|
||||
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(', ')"
|
||||
></i>
|
||||
</td>
|
||||
<td>{{ pool.address.number }}</td>
|
||||
<td>{{ pool.address.street }}</td>
|
||||
<td>{{ pool.address.locality }}</td>
|
||||
<td>{{ pool.address.postalCode }}</td>
|
||||
<td>
|
||||
<span class="badge status" :class="pool.status">
|
||||
{{ titleCase(pool.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge condition" :class="pool.condition">
|
||||
{{ titleCase(pool.condition) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ pool.tags?.length || 0 }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="table-responsive">
|
||||
<table class="table table-hover table-striped">
|
||||
<thead class="table-light">
|
||||
<tr class="header">
|
||||
<th></th>
|
||||
<th>Number</th>
|
||||
<th>Street</th>
|
||||
<th>City</th>
|
||||
<th>Postal</th>
|
||||
<th>Status</th>
|
||||
<th>Condition</th>
|
||||
<th>Tags</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="(pool, index) in upload.csv_pool.pools"
|
||||
:key="index"
|
||||
:class="{
|
||||
'has-error': pool.errors && pool.errors.length > 0,
|
||||
}"
|
||||
:style="getRowStyle(pool)"
|
||||
>
|
||||
<td>
|
||||
<i
|
||||
v-if="pool.errors && pool.errors.length > 0"
|
||||
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(', ')"
|
||||
></i>
|
||||
</td>
|
||||
<td>{{ pool.address?.number }}</td>
|
||||
<td>{{ pool.address?.street }}</td>
|
||||
<td>{{ pool.address?.locality }}</td>
|
||||
<td>{{ pool.address?.postal_code }}</td>
|
||||
<td>
|
||||
<span class="badge status" :class="pool.status">
|
||||
{{ titleCase(pool.status) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge condition" :class="pool.condition">
|
||||
{{ titleCase(pool.condition) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ pool.tags?.length || 0 }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between mt-4 mb-5">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-danger"
|
||||
@click="handleDiscard"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
id="confirmUploadBtn"
|
||||
@click="handleConfirm"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
<i class="bi bi-check2 me-1"></i> Confirm and Submit Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between mt-4 mb-5">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline-danger"
|
||||
@click="handleDiscard"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
id="confirmUploadBtn"
|
||||
@click="handleConfirm"
|
||||
:disabled="isSubmitting"
|
||||
>
|
||||
<i class="bi bi-check2 me-1"></i> Confirm and Submit Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
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;
|
||||
postalCode: string;
|
||||
number: string;
|
||||
street: string;
|
||||
locality: string;
|
||||
postal_code: string;
|
||||
}
|
||||
|
||||
interface ErrorMessage {
|
||||
message: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface Pool {
|
||||
address: Address;
|
||||
status: string;
|
||||
condition: string;
|
||||
tags?: string[];
|
||||
errors?: ErrorMessage[];
|
||||
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[];
|
||||
pools?: Pool[];
|
||||
}
|
||||
|
||||
interface ServiceArea {
|
||||
min: { x: number; y: number };
|
||||
max: { x: number; y: number };
|
||||
name: string;
|
||||
status: string;
|
||||
countExisting: number;
|
||||
countNew: number;
|
||||
countOutside: number;
|
||||
errors?: ErrorMessage[];
|
||||
csv_pool?: CSVPool;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
id: int;
|
||||
id: int;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const router = useRouter();
|
||||
const showIssuesOnly = ref(false);
|
||||
const isSubmitting = ref(false);
|
||||
const uploadStore = useUploadStore();
|
||||
const user = useUserStore();
|
||||
|
||||
const upload = ref<Upload | null>(null);
|
||||
|
||||
const getUploadStatusIcon = (status?: string): string => {
|
||||
const icons: Record<string, string> = {
|
||||
uploaded: 'bi-cloud-upload',
|
||||
parsing: 'bi-hourglass-split',
|
||||
parsed: 'bi-check-circle',
|
||||
error: 'bi-x-circle',
|
||||
};
|
||||
return icons[status || ''] || 'bi-question-circle';
|
||||
const icons: Record<string, string> = {
|
||||
uploaded: "bi-cloud-upload",
|
||||
parsing: "bi-hourglass-split",
|
||||
parsed: "bi-check-circle",
|
||||
error: "bi-x-circle",
|
||||
};
|
||||
return icons[status || ""] || "bi-question-circle";
|
||||
};
|
||||
|
||||
const getUploadStatusDisplay = (status?: string): string => {
|
||||
const displays: Record<string, string> = {
|
||||
uploaded: 'Uploaded',
|
||||
parsing: 'Parsing',
|
||||
parsed: 'Parsed',
|
||||
error: 'Error',
|
||||
};
|
||||
return displays[status || ''] || 'Unknown';
|
||||
const displays: Record<string, string> = {
|
||||
uploaded: "Uploaded",
|
||||
parsing: "Parsing",
|
||||
parsed: "Parsed",
|
||||
error: "Error",
|
||||
};
|
||||
return displays[status || ""] || "Unknown";
|
||||
};
|
||||
|
||||
const titleCase = (str?: string): string => {
|
||||
if (!str) return '';
|
||||
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
|
||||
if (!str) return "";
|
||||
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
|
||||
};
|
||||
const upload = computed(() => {
|
||||
if (upload.all == null) {
|
||||
return null;
|
||||
}
|
||||
return upload.byID(props.id);
|
||||
});
|
||||
const getRowStyle = (pool: Pool) => {
|
||||
if (showIssuesOnly.value) {
|
||||
const hasError = pool.errors && pool.errors.length > 0;
|
||||
return { display: hasError ? 'table-row' : 'none' };
|
||||
}
|
||||
return { display: 'table-row' };
|
||||
if (showIssuesOnly.value) {
|
||||
const hasError = pool.errors && pool.errors.length > 0;
|
||||
return { display: hasError ? "table-row" : "none" };
|
||||
}
|
||||
return { display: "table-row" };
|
||||
};
|
||||
|
||||
const handleShowIssuesOnly = () => {
|
||||
// The reactive display is handled by getRowStyle
|
||||
// The reactive display is handled by getRowStyle
|
||||
};
|
||||
|
||||
const initializeMap = () => {
|
||||
if (!map) return;
|
||||
if (!map) return;
|
||||
|
||||
map.addEventListener('load', () => {
|
||||
map.addSource('tegola-nidus', {
|
||||
type: 'vector',
|
||||
tiles: [
|
||||
`${props.tegolaUrl}maps/nidus/{z}/{x}/{y}?csv_file=${props.id}&id=${user.organization.id}`,
|
||||
],
|
||||
});
|
||||
map.addEventListener("load", () => {
|
||||
map.addSource("tegola-nidus", {
|
||||
type: "vector",
|
||||
tiles: [
|
||||
`${props.tegolaUrl}maps/nidus/{z}/{x}/{y}?csv_file=${props.id}&id=${user.organization.id}`,
|
||||
],
|
||||
});
|
||||
|
||||
map.addLayer({
|
||||
id: 'pool',
|
||||
source: 'tegola-nidus',
|
||||
'source-layer': 'fileupload-pool',
|
||||
type: 'circle',
|
||||
paint: {
|
||||
'circle-color': '#91b979',
|
||||
'circle-radius': 7,
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#7aab5f',
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const fetchUploadData = async () => {
|
||||
try {
|
||||
const response = await fetch(`/api/upload/${props.id}`);
|
||||
if (!response.ok) throw new Error('Failed to fetch upload data');
|
||||
upload.value = await response.json();
|
||||
} catch (error) {
|
||||
console.error('Error fetching upload data:', error);
|
||||
}
|
||||
map.addLayer({
|
||||
id: "pool",
|
||||
source: "tegola-nidus",
|
||||
"source-layer": "fileupload-pool",
|
||||
type: "circle",
|
||||
paint: {
|
||||
"circle-color": "#91b979",
|
||||
"circle-radius": 7,
|
||||
"circle-stroke-width": 2,
|
||||
"circle-stroke-color": "#7aab5f",
|
||||
},
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const handleDiscard = async () => {
|
||||
if (!confirm('Are you sure you want to discard this upload?')) return;
|
||||
if (!confirm("Are you sure you want to discard this upload?")) return;
|
||||
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
const response = await fetch(`/api/upload/${props.id}/discard`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
const response = await fetch(`/api/upload/${props.id}/discard`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to discard upload');
|
||||
if (!response.ok) throw new Error("Failed to discard upload");
|
||||
|
||||
// Navigate to uploads list or appropriate page
|
||||
router.push('/uploads');
|
||||
} catch (error) {
|
||||
console.error('Error discarding upload:', error);
|
||||
alert('Failed to discard upload. Please try again.');
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
// Navigate to uploads list or appropriate page
|
||||
router.push("/uploads");
|
||||
} catch (error) {
|
||||
console.error("Error discarding upload:", error);
|
||||
alert("Failed to discard upload. Please try again.");
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
const response = await fetch(`/api/upload/${props.id}/commit`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
isSubmitting.value = true;
|
||||
try {
|
||||
const response = await fetch(`/api/upload/${props.id}/commit`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to confirm upload');
|
||||
if (!response.ok) throw new Error("Failed to confirm upload");
|
||||
|
||||
// Navigate to success page or appropriate page
|
||||
router.push('/uploads/success');
|
||||
} catch (error) {
|
||||
console.error('Error confirming upload:', error);
|
||||
alert('Failed to confirm upload. Please try again.');
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
// Navigate to success page or appropriate page
|
||||
router.push("/uploads/success");
|
||||
} catch (error) {
|
||||
console.error("Error confirming upload:", error);
|
||||
alert("Failed to confirm upload. Please try again.");
|
||||
} finally {
|
||||
isSubmitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
initializeMap();
|
||||
|
||||
// If upload data wasn't provided via props, fetch it
|
||||
if (!upload.value) {
|
||||
fetchUploadData();
|
||||
}
|
||||
initializeMap();
|
||||
uploadStore.fetchOne(props.id).then((u) => {
|
||||
console.log("got upload", u);
|
||||
upload.value = u;
|
||||
});
|
||||
|
||||
// Initialize Bootstrap tooltips
|
||||
const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||
// @ts-ignore - Bootstrap types
|
||||
[...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
|
||||
// Initialize Bootstrap tooltips
|
||||
const tooltipTriggerList = document.querySelectorAll(
|
||||
'[data-bs-toggle="tooltip"]',
|
||||
);
|
||||
// @ts-ignore - Bootstrap types
|
||||
[...tooltipTriggerList].map(
|
||||
(tooltipTriggerEl) => new bootstrap.Tooltip(tooltipTriggerEl),
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue