2026-03-25 21:45:13 -07:00
|
|
|
<style scoped>
|
|
|
|
|
.results-container {
|
2026-03-27 14:04:33 -07:00
|
|
|
max-width: 1400px;
|
2026-03-25 21:45:13 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.summary-card {
|
2026-03-27 14:04:33 -07:00
|
|
|
transition: transform 0.2s;
|
2026-03-25 21:45:13 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.summary-card:hover {
|
2026-03-27 14:04:33 -07:00
|
|
|
transform: translateY(-5px);
|
2026-03-25 21:45:13 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.has-error {
|
2026-03-27 14:04:33 -07:00
|
|
|
background-color: #fff3cd;
|
2026-03-25 21:45:13 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.badge.status,
|
|
|
|
|
.badge.condition {
|
2026-03-27 14:04:33 -07:00
|
|
|
font-size: 0.875rem;
|
|
|
|
|
padding: 0.35em 0.65em;
|
2026-03-25 21:45:13 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
.table-responsive {
|
2026-03-27 14:04:33 -07:00
|
|
|
max-height: 600px;
|
|
|
|
|
overflow-y: auto;
|
2026-03-25 21:45:13 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
thead tr.header {
|
2026-03-27 14:04:33 -07:00
|
|
|
position: sticky;
|
|
|
|
|
top: 0;
|
|
|
|
|
z-index: 10;
|
|
|
|
|
background-color: #f8f9fa;
|
2026-03-25 21:45:13 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#map {
|
2026-03-27 14:04:33 -07:00
|
|
|
height: 400px;
|
|
|
|
|
width: 100%;
|
2026-03-25 21:45:13 -07:00
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
<template>
|
2026-03-27 14:04:33 -07:00
|
|
|
<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="card mb-4">
|
2026-03-25 21:45:13 -07:00
|
|
|
<div v-if="user == null">
|
|
|
|
|
<p>loading</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div v-else>
|
|
|
|
|
<MapMultipoint
|
|
|
|
|
id="map"
|
|
|
|
|
: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"
|
|
|
|
|
></MapMultipoint>
|
|
|
|
|
</div>
|
2026-03-27 14:04:33 -07:00
|
|
|
</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 == 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?.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>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>
|
2026-03-25 21:45:13 -07:00
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
2026-03-27 14:04:33 -07:00
|
|
|
import { ref, onMounted, computed } from "vue";
|
|
|
|
|
import { useRouter } from "vue-router";
|
2026-03-25 21:45:13 -07:00
|
|
|
import MapMultipoint from "@/components/MapMultipoint.vue";
|
2026-03-27 14:04:33 -07:00
|
|
|
import { useUploadStore } from "@/store/upload";
|
2026-03-25 21:45:13 -07:00
|
|
|
import { useUserStore } from "@/store/user";
|
|
|
|
|
|
|
|
|
|
interface Address {
|
2026-03-27 14:04:33 -07:00
|
|
|
number: string;
|
|
|
|
|
street: string;
|
|
|
|
|
locality: string;
|
|
|
|
|
postal_code: string;
|
2026-03-25 21:45:13 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface ErrorMessage {
|
2026-03-27 14:04:33 -07:00
|
|
|
message: string;
|
2026-03-25 21:45:13 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Pool {
|
2026-03-27 14:04:33 -07:00
|
|
|
address?: Address;
|
|
|
|
|
status: string;
|
|
|
|
|
condition: string;
|
|
|
|
|
tags?: string[];
|
|
|
|
|
errors?: ErrorMessage[];
|
2026-03-25 21:45:13 -07:00
|
|
|
}
|
|
|
|
|
|
2026-03-27 14:04:33 -07:00
|
|
|
interface CSVPool {
|
|
|
|
|
pools: Pool[];
|
2026-03-25 21:45:13 -07:00
|
|
|
}
|
2026-03-27 14:04:33 -07:00
|
|
|
interface Upload {
|
|
|
|
|
name: string;
|
|
|
|
|
status: string;
|
|
|
|
|
countExisting: number;
|
|
|
|
|
countNew: number;
|
|
|
|
|
countOutside: number;
|
|
|
|
|
errors?: ErrorMessage[];
|
|
|
|
|
csv_pool?: CSVPool;
|
2026-03-25 21:45:13 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Props {
|
2026-03-27 14:04:33 -07:00
|
|
|
id: int;
|
2026-03-25 21:45:13 -07:00
|
|
|
}
|
|
|
|
|
|
2026-03-27 14:04:33 -07:00
|
|
|
const props = defineProps<Props>();
|
2026-03-25 21:45:13 -07:00
|
|
|
|
|
|
|
|
const router = useRouter();
|
|
|
|
|
const showIssuesOnly = ref(false);
|
|
|
|
|
const isSubmitting = ref(false);
|
2026-03-27 14:04:33 -07:00
|
|
|
const uploadStore = useUploadStore();
|
2026-03-25 21:45:13 -07:00
|
|
|
const user = useUserStore();
|
|
|
|
|
|
2026-03-27 14:04:33 -07:00
|
|
|
const upload = ref<Upload | null>(null);
|
|
|
|
|
|
2026-03-25 21:45:13 -07:00
|
|
|
const getUploadStatusIcon = (status?: string): string => {
|
2026-03-27 14:04:33 -07:00
|
|
|
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";
|
2026-03-25 21:45:13 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getUploadStatusDisplay = (status?: string): string => {
|
2026-03-27 14:04:33 -07:00
|
|
|
const displays: Record<string, string> = {
|
|
|
|
|
uploaded: "Uploaded",
|
|
|
|
|
parsing: "Parsing",
|
|
|
|
|
parsed: "Parsed",
|
|
|
|
|
error: "Error",
|
|
|
|
|
};
|
|
|
|
|
return displays[status || ""] || "Unknown";
|
2026-03-25 21:45:13 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const titleCase = (str?: string): string => {
|
2026-03-27 14:04:33 -07:00
|
|
|
if (!str) return "";
|
|
|
|
|
return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase();
|
2026-03-25 21:45:13 -07:00
|
|
|
};
|
|
|
|
|
const getRowStyle = (pool: Pool) => {
|
2026-03-27 14:04:33 -07:00
|
|
|
if (showIssuesOnly.value) {
|
|
|
|
|
const hasError = pool.errors && pool.errors.length > 0;
|
|
|
|
|
return { display: hasError ? "table-row" : "none" };
|
|
|
|
|
}
|
|
|
|
|
return { display: "table-row" };
|
2026-03-25 21:45:13 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleShowIssuesOnly = () => {
|
2026-03-27 14:04:33 -07:00
|
|
|
// The reactive display is handled by getRowStyle
|
2026-03-25 21:45:13 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const initializeMap = () => {
|
2026-03-27 14:04:33 -07:00
|
|
|
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.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",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
});
|
2026-03-25 21:45:13 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleDiscard = async () => {
|
2026-03-27 14:04:33 -07:00
|
|
|
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",
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-03-25 21:45:13 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleConfirm = async () => {
|
2026-03-27 14:04:33 -07:00
|
|
|
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");
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
}
|
2026-03-25 21:45:13 -07:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
2026-03-27 14:04:33 -07:00
|
|
|
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),
|
|
|
|
|
);
|
2026-03-25 21:45:13 -07:00
|
|
|
});
|
|
|
|
|
</script>
|