Create sign-in and sign-out workflow in SPA

This commit is contained in:
Eli Ribble 2026-04-16 17:14:57 +00:00
parent 08a1b5b81d
commit b6d1bd9ee2
No known key found for this signature in database
15 changed files with 761 additions and 460 deletions

View file

@ -1,34 +1,24 @@
<template>
<div class="app-container">
<Sidebar v-if="$route.meta.showSidebar" />
<MainContent>
<div v-if="session.loading">Loading...</div>
<div v-else-if="session.error">Error: {{ session.error }}</div>
<router-view v-else />
</MainContent>
</div>
<router-view />
</template>
<script setup lang="ts">
import { onMounted } from "vue";
import { useSessionStore } from "@/store/session";
import { Session } from "@/type/api";
import Sidebar from "./components/layout/Sidebar.vue";
import MainContent from "./components/layout/MainContent.vue";
import NavigationLink from "@/components/common/NavigationLink.vue";
import { router } from "@/router";
const session = useSessionStore();
onMounted(() => {
session.get().then((session: Session) => {
console.log("session loaded", session);
});
session
.get()
.then((session: Session) => {
console.log("session loaded", session);
router.push("/_/dash");
})
.catch((e) => {
console.log("root session not loaded", e);
});
console.log("home mounted");
});
</script>
<style scoped>
.app-container {
display: flex;
height: 100vh;
}
</style>

View file

@ -1,31 +1,104 @@
// src/api/axios.ts
import axios, { AxiosInstance } from "axios";
import axios, { AxiosInstance, AxiosRequestConfig } from "axios";
import router from "@/router";
// Extend the AxiosInstance interface
declare module "axios" {
interface AxiosInstance {
isAuthenticated(): boolean;
class ApiClient {
private client: AxiosInstance;
private _isAuthenticated: boolean = false;
constructor() {
this.client = axios.create({
timeout: 10000,
withCredentials: true,
});
// Request interceptor for auth headers, content-type, etc.
this.client.interceptors.request.use((config) => {
// Content-type negotiation
config.headers["Accept"] = "application/json";
config.headers["X-Requested-With"] = "nidus-web 0.1";
// Add auth token if logged in
const token = localStorage.getItem("authToken");
if (token) {
config.headers["Authorization"] = `Bearer ${token}`;
}
return config;
});
// Response interceptor for handling auth errors
this.client.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
this._isAuthenticated = false;
// Could emit event or redirect here
}
return Promise.reject(error);
},
);
}
get isAuthenticated(): boolean {
return this._isAuthenticated;
}
setLoggedIn(value: boolean): void {
this._isAuthenticated = value;
}
async JSONGet<T = any>(url: string, config?: AxiosRequestConfig): Promise<T> {
const response = await this.client.get<T>(url, {
...config,
headers: {
Accept: "application/json",
...config?.headers,
},
});
return response.data;
}
async JSONPost<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig,
): Promise<T> {
const response = await this.client.post<T>(url, data, {
...config,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...config?.headers,
},
});
return response.data;
}
async JSONPut<T = any>(
url: string,
data?: any,
config?: AxiosRequestConfig,
): Promise<T> {
const response = await this.client.put<T>(url, data, {
...config,
headers: {
"Content-Type": "application/json",
Accept: "application/json",
...config?.headers,
},
});
return response.data;
}
async JSONDelete<T = any>(
url: string,
config?: AxiosRequestConfig,
): Promise<T> {
const response = await this.client.delete<T>(url, config);
return response.data;
}
}
const apiClient = axios.create({
baseURL: "/api",
withCredentials: true,
});
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response && error.response.status === 401) {
router.push("/login");
}
return Promise.reject(error);
},
);
apiClient.isAuthenticated = () => {
return true;
};
export default apiClient;
// Single instance export - this IS the singleton
export const apiClient = new ApiClient();

View file

@ -0,0 +1,123 @@
<style scoped>
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
}
.btn:disabled {
cursor: not-allowed;
}
</style>
<template>
<button
:class="buttonClasses"
:disabled="disabled || loading"
@click="handleClick"
>
<!-- Loading Spinner -->
<span
v-if="loading"
class="spinner-border spinner-border-sm me-2"
role="status"
aria-hidden="true"
></span>
<!-- Icon (only show when not loading) -->
<i v-if="icon && !loading" :class="iconClasses"></i>
<!-- Button Text -->
<span v-if="text">{{ text }}</span>
<!-- Slot for additional content -->
<slot></slot>
</button>
</template>
<script setup>
import { computed } from "vue";
// Define props
const props = defineProps({
text: {
type: String,
default: "",
},
icon: {
type: String,
default: "",
},
variant: {
type: String,
default: "primary",
validator: (value) =>
[
"primary",
"secondary",
"success",
"danger",
"warning",
"info",
"light",
"dark",
"link",
"outline-primary",
"outline-secondary",
"outline-success",
"outline-danger",
"outline-warning",
"outline-info",
"outline-light",
"outline-dark",
].includes(value),
},
size: {
type: String,
default: "",
validator: (value) => ["", "sm", "lg"].includes(value),
},
loading: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
block: {
type: Boolean,
default: false,
},
});
// Define emits
const emit = defineEmits(["click"]);
// Computed classes for button
const buttonClasses = computed(() => {
return [
"btn",
`btn-${props.variant}`,
{
[`btn-${props.size}`]: props.size,
"w-100": props.block,
disabled: props.loading,
},
];
});
// Computed classes for icon
const iconClasses = computed(() => {
return [
props.icon,
{ "me-2": props.text }, // Add margin if there's text
];
});
// Handle click event
const handleClick = (event) => {
if (!props.loading && !props.disabled) {
emit("click", event);
}
};
</script>

View file

@ -1,11 +1,15 @@
<template>
<div id="content">
<div v-if="session.loading">Loading...</div>
<div v-else-if="session.error">Error: {{ session.error }}</div>
<slot />
</div>
</template>
<script setup lang="ts">
// No imports needed for this simple component
import { useSessionStore } from "@/store/session";
const session = useSessionStore();
</script>
<style scoped></style>

View file

@ -197,6 +197,9 @@
label="Configuration"
/>
</li>
<li>
<NavigationLink to="/signout" icon="door-open" label="Signout" />
</li>
<li>
<NavigationLink to="/_/sudo" icon="god" label="Sudo" />
</li>

View file

@ -1,190 +1,207 @@
import { createRouter, createWebHistory } from "vue-router";
import type { RouteRecordRaw } from "vue-router";
import Home from "./view/Home.vue";
import About from "./view/About.vue";
import Communication from "./view/Communication.vue";
import ConfigurationIntegration from "./view/configuration/Integration.vue";
import ConfigurationIntegrationArcgis from "./view/configuration/IntegrationArcgis.vue";
import ConfigurationOrganization from "./view/configuration/Organization.vue";
import ConfigurationPesticide from "./view/configuration/Pesticide.vue";
import ConfigurationPesticideAdd from "./view/configuration/PesticideAdd.vue";
import ConfigurationRoot from "./view/configuration/Root.vue";
import ConfigurationUpload from "./view/configuration/Upload.vue";
import ConfigurationUploadDetail from "./view/configuration/UploadDetail.vue";
import ConfigurationUploadPool from "./view/configuration/UploadPool.vue";
import ConfigurationUploadPoolCustom from "./view/configuration/UploadPoolCustom.vue";
import ConfigurationUploadPoolFlyover from "./view/configuration/UploadPoolFlyover.vue";
import ConfigurationUser from "./view/configuration/User.vue";
import ConfigurationUserAdd from "./view/configuration/UserAdd.vue";
import ConfigurationUserEdit from "./view/configuration/UserEdit.vue";
import Intelligence from "./view/Intelligence.vue";
import NotFound from "./view/NotFound.vue";
import OAuthRefreshArcgis from "./view/OAuthRefreshArcgis.vue";
import Operations from "./view/Operations.vue";
import Planning from "./view/Planning.vue";
import ReviewPool from "./view/review/Pool.vue";
import ReviewRoot from "./view/review/Root.vue";
import ReviewSite from "./view/review/Site.vue";
import Signin from "./view/Signin.vue";
import Sudo from "./view/Sudo.vue";
import apiClient from "@/client";
import Home from "@/view/Home.vue";
import Authenticated from "@/view/Authenticated.vue";
import Communication from "@/view/Communication.vue";
import ConfigurationIntegration from "@/view/configuration/Integration.vue";
import ConfigurationIntegrationArcgis from "@/view/configuration/IntegrationArcgis.vue";
import ConfigurationOrganization from "@/view/configuration/Organization.vue";
import ConfigurationPesticide from "@/view/configuration/Pesticide.vue";
import ConfigurationPesticideAdd from "@/view/configuration/PesticideAdd.vue";
import ConfigurationRoot from "@/view/configuration/Root.vue";
import ConfigurationUpload from "@/view/configuration/Upload.vue";
import ConfigurationUploadDetail from "@/view/configuration/UploadDetail.vue";
import ConfigurationUploadPool from "@/view/configuration/UploadPool.vue";
import ConfigurationUploadPoolCustom from "@/view/configuration/UploadPoolCustom.vue";
import ConfigurationUploadPoolFlyover from "@/view/configuration/UploadPoolFlyover.vue";
import ConfigurationUser from "@/view/configuration/User.vue";
import ConfigurationUserAdd from "@/view/configuration/UserAdd.vue";
import ConfigurationUserEdit from "@/view/configuration/UserEdit.vue";
import Dash from "@/view/Dash.vue";
import Intelligence from "@/view/Intelligence.vue";
import NotFound from "@/view/NotFound.vue";
import OAuthRefreshArcgis from "@/view/OAuthRefreshArcgis.vue";
import Operations from "@/view/Operations.vue";
import Planning from "@/view/Planning.vue";
import ReviewPool from "@/view/review/Pool.vue";
import ReviewRoot from "@/view/review/Root.vue";
import ReviewSite from "@/view/review/Site.vue";
import Signin from "@/view/Signin.vue";
import Signout from "@/view/Signout.vue";
import Sudo from "@/view/Sudo.vue";
import { apiClient } from "@/client";
const routes: RouteRecordRaw[] = [
{
path: "/",
name: "Home",
component: Home,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/communication",
name: "Communication",
component: Communication,
meta: { requiresAuth: true, showSidebar: true },
children: [
{
path: "/_/communication",
name: "Communication",
component: Communication,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/configuration",
name: "Configuration",
component: ConfigurationRoot,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/configuration/integration",
name: "Integration Configuration",
component: ConfigurationIntegration,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/configuration/integration/arcgis",
name: "Arcgis Integration Configuration",
component: ConfigurationIntegrationArcgis,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/configuration/organization",
name: "Organization Configuration",
component: ConfigurationOrganization,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/configuration/pesticide",
name: "Pesticide Configuration",
component: ConfigurationPesticide,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/configuration/pesticide/add",
name: "Pesticide Add",
component: ConfigurationPesticideAdd,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/configuration/upload",
name: "Upload Configuration",
component: ConfigurationUpload,
meta: { requiresAuth: true, showSidebar: true },
},
{
component: ConfigurationUploadDetail,
meta: { requiresAuth: true, showSidebar: true },
name: "Upload Detail",
path: "/_/configuration/upload/:id",
props: (route) => ({
id: parseInt(route.params.id as string, 10),
}),
},
{
path: "/_/configuration/upload/pool",
name: "Pool Upload",
component: ConfigurationUploadPool,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/configuration/upload/pool/custom",
name: "Custom Pool Upload",
component: ConfigurationUploadPoolCustom,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/configuration/upload/pool/flyover",
name: "Flyover Upload",
component: ConfigurationUploadPoolFlyover,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/configuration/user",
name: "User Configuration",
component: ConfigurationUser,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/configuration/user/add",
name: "User Add Configuration",
component: ConfigurationUserAdd,
meta: { requiresAuth: true, showSidebar: true },
},
{
component: ConfigurationUserEdit,
meta: { requiresAuth: true, showSidebar: true },
name: "User Edit",
path: "/_/configuration/user/:id",
props: (route) => ({
id: parseInt(route.params.id as string, 10),
}),
},
{
path: "/_/dash",
name: "Dash",
component: Dash,
},
{
path: "/_/intelligence",
name: "Intelligence",
component: Intelligence,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/oauth/refresh/arcgis",
name: "Arcgis OAuth Refresh",
component: OAuthRefreshArcgis,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/operations",
name: "Operations",
component: Operations,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/planning",
name: "Planning",
component: Planning,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/review",
name: "Review",
component: ReviewRoot,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/review/pool",
name: "Pool Review",
component: ReviewPool,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/review/site",
name: "Site Review",
component: ReviewSite,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/sudo",
name: "Sudo",
component: Sudo,
meta: { requiresAuth: true, showSidebar: true },
},
],
component: Authenticated,
path: "/_",
name: "Authenticated",
},
{
path: "/_/configuration",
name: "Configuration",
component: ConfigurationRoot,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/configuration/integration",
name: "Integration Configuration",
component: ConfigurationIntegration,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/configuration/integration/arcgis",
name: "Arcgis Integration Configuration",
component: ConfigurationIntegrationArcgis,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/configuration/organization",
name: "Organization Configuration",
component: ConfigurationOrganization,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/configuration/pesticide",
name: "Pesticide Configuration",
component: ConfigurationPesticide,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/configuration/pesticide/add",
name: "Pesticide Add",
component: ConfigurationPesticideAdd,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/configuration/upload",
name: "Upload Configuration",
component: ConfigurationUpload,
meta: { requiresAuth: true, showSidebar: true },
},
{
component: ConfigurationUploadDetail,
meta: { requiresAuth: true, showSidebar: true },
name: "Upload Detail",
path: "/_/configuration/upload/:id",
props: (route) => ({
id: parseInt(route.params.id as string, 10),
}),
},
{
path: "/_/configuration/upload/pool",
name: "Pool Upload",
component: ConfigurationUploadPool,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/configuration/upload/pool/custom",
name: "Custom Pool Upload",
component: ConfigurationUploadPoolCustom,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/configuration/upload/pool/flyover",
name: "Flyover Upload",
component: ConfigurationUploadPoolFlyover,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/configuration/user",
name: "User Configuration",
component: ConfigurationUser,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/configuration/user/add",
name: "User Add Configuration",
component: ConfigurationUserAdd,
meta: { requiresAuth: true, showSidebar: true },
},
{
component: ConfigurationUserEdit,
meta: { requiresAuth: true, showSidebar: true },
name: "User Edit",
path: "/_/configuration/user/:id",
props: (route) => ({
id: parseInt(route.params.id as string, 10),
}),
},
{
path: "/_/intelligence",
name: "Intelligence",
component: Intelligence,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/oauth/refresh/arcgis",
name: "Arcgis OAuth Refresh",
component: OAuthRefreshArcgis,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/operations",
name: "Operations",
component: Operations,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/planning",
name: "Planning",
component: Planning,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/review",
name: "Review",
component: ReviewRoot,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/review/pool",
name: "Pool Review",
component: ReviewPool,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/review/site",
name: "Site Review",
component: ReviewSite,
meta: { requiresAuth: true, showSidebar: true },
},
{
path: "/_/signin",
name: "Signin",
component: Signin,
meta: { requiresAuth: false, showSidebar: false },
name: "Signin",
path: "/signin",
},
{
path: "/_/sudo",
name: "Sudo",
component: Sudo,
meta: { requiresAuth: true, showSidebar: true },
component: Signout,
name: "Signout",
path: "/signout",
},
// Catch-all route - must be last
{
@ -206,15 +223,14 @@ router.beforeEach(async (to, from) => {
if (requiresAuth) {
try {
// Check if user is authenticated (could be an API call)
const isAuthenticated = await apiClient.isAuthenticated();
if (!isAuthenticated) {
return "/signin";
if (!apiClient.isAuthenticated) {
return "/_/signin";
} else {
return;
}
} catch (error) {
console.log("check auth failed");
return "/signin";
return "/_/signin";
}
}
});

View file

@ -8,12 +8,13 @@ import {
URLs,
User,
} from "@/type/api";
import { apiClient } from "@/client";
export const useSessionStore = defineStore("session", () => {
// State
const impersonating = ref<string | null>(null);
const error = ref<string | null>(null);
const loading = ref(false);
const loading = ref(true);
const current = ref<Session | null>(null);
const notification_counts = ref<SessionNotificationCounts | null>(null);
const ongoingFetch = ref<Promise<Session> | null>(null);
@ -34,10 +35,7 @@ export const useSessionStore = defineStore("session", () => {
error.value = null;
try {
const response = await fetch("/api/session");
if (!response.ok) throw new Error("Failed to fetch user");
const data: Session = await response.json();
const data: Session = await apiClient.JSONGet("/api/session");
impersonating.value = data.impersonating || null;
notification_counts.value = data.notification_counts;
organization.value = data.organization;
@ -50,6 +48,7 @@ export const useSessionStore = defineStore("session", () => {
throw new Error(error.value);
} finally {
loading.value = false;
console.log("no longer loading session");
}
}
@ -72,6 +71,9 @@ export const useSessionStore = defineStore("session", () => {
ongoingFetch.value = null;
return s;
}
async function signout(): Promise<void> {
apiClient.JSONPost("/api/signout", {});
}
return {
// State
error,
@ -85,5 +87,6 @@ export const useSessionStore = defineStore("session", () => {
fetchSession,
get,
isAuthenticated,
signout,
};
});

27
ts/view/Authenticated.vue Normal file
View file

@ -0,0 +1,27 @@
<style scoped>
.app-container {
display: flex;
height: 100vh;
}
</style>
<template>
<div class="app-container">
<template v-if="session.loading">Loading...</template>
<template v-else-if="session.error">Error: {{ session.error }}</template>
<template v-else>
<Sidebar />
<MainContent>
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
</MainContent>
</template>
</div>
</template>
<script setup lang="ts">
import { useSessionStore } from "@/store/session";
const session = useSessionStore();
import Sidebar from "@/components/layout/Sidebar.vue";
import MainContent from "@/components/layout/MainContent.vue";
</script>

202
ts/view/Dash.vue Normal file
View file

@ -0,0 +1,202 @@
<template>
<!-- Dashboard Header -->
<div class="row mb-4">
<div class="col-md-6">
<h1>{{ dashboard.organization.name }} Dashboard</h1>
<p class="text-muted">
Overview of mosquito control activities in your district
</p>
</div>
<div
class="col-md-6 text-md-end d-flex align-items-center justify-content-md-end"
>
<p v-if="dashboard.isSyncOngoing" class="last-refreshed mb-0">
<i class="fas fa-sync-alt me-2 syncing"></i>Syncing now...
</p>
<p v-else class="last-refreshed mb-0">
<i class="fas fa-sync-alt me-2"></i>Last updated:
<span id="last-refreshed-time">{{
formatTimeRelative(dashboard.lastSync)
}}</span>
<button
class="btn btn-sm btn-outline-primary ms-3"
@click="refreshData"
>
Refresh Data
</button>
</p>
</div>
</div>
<!-- Key Metrics -->
<div class="row g-4">
<!-- Last Refreshed -->
<div class="col-md-3">
<div class="card stats-card h-100">
<div class="card-body text-center">
<div class="metric-icon bg-success text-white">
<i class="fas fa-clock"></i>
</div>
<h5 class="card-title">Last Data Refresh</h5>
<p class="metric-value">
{{ formatTimeRelative(dashboard.lastSync) }}
</p>
<!-- <p class="card-text text-muted">Last sync: 12:45 PM</p> -->
</div>
</div>
</div>
<!-- Service Requests -->
<div class="col-md-3">
<div class="card stats-card h-100">
<div class="card-body text-center">
<div class="metric-icon bg-warning text-white">
<i class="fas fa-ticket-alt"></i>
</div>
<h5 class="card-title">Service Requests</h5>
<p v-if="dashboard.isSyncOngoing" class="metric-value">
{{ formatBigNumber(dashboard.counts.service_requests) }}...?
</p>
<p v-else class="metric-value">
{{ formatBigNumber(dashboard.counts.service_requests) }}
</p>
<!--<p class="card-text text-muted">
<span class="text-success">
<i class="fas fa-arrow-up"></i> 12%
</span> since last week
</p>-->
</div>
</div>
</div>
<!-- Mosquito Sources -->
<div class="col-md-3">
<div class="card stats-card h-100">
<div class="card-body text-center">
<div class="metric-icon bg-danger text-white">
<i class="fas fa-bug"></i>
</div>
<h5 class="card-title">Mosquito Sources</h5>
<p v-if="dashboard.isSyncOngoing" class="metric-value">
{{ formatBigNumber(dashboard.counts.mosquito_sources) }}..?
</p>
<p v-else class="metric-value">
{{ formatBigNumber(dashboard.counts.mosquito_sources) }}
</p>
<!-- <p class="card-text text-muted">
<span class="text-danger">
<i class="fas fa-arrow-up"></i> 8%
</span> since last month
</p> -->
</div>
</div>
</div>
<!-- Inspections -->
<div class="col-md-3">
<div class="card stats-card h-100">
<div class="card-body text-center">
<div class="metric-icon bg-info text-white">
<i class="fas fa-clipboard-check"></i>
</div>
<h5 class="card-title">Traps</h5>
<p v-if="dashboard.isSyncOngoing" class="metric-value">
{{ formatBigNumber(dashboard.counts.traps) }}...?
</p>
<p v-else class="metric-value">
{{ formatBigNumber(dashboard.counts.traps) }}
</p>
<!-- <p class="card-text text-muted">
<span class="text-success">
<i class="fas fa-arrow-up"></i> 15%
</span> since last week
</p> -->
</div>
</div>
</div>
</div>
<!-- Map Section -->
<h3 class="section-title">Mosquito Activity Heatmap</h3>
<div class="row">
<div class="col-12">
<MapAggregate
:bounds="mapBounds()"
:markers="[]"
:organizationId="dashboard.organization.id"
:tegola="session.urls?.tegola ?? ''"
/>
</div>
</div>
<!-- Recent Activity Section -->
<h3 class="section-title">Recent Activity</h3>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Date</th>
<th>Type</th>
<th>Location</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
</table>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive } from "vue";
import MapAggregate from "@/components/MapAggregate.vue";
import { formatBigNumber, formatTimeRelative } from "@/format";
import { useSessionStore } from "@/store/session";
import { useStoreServiceRequest } from "@/store/service_request";
import { useStoreSync } from "@/store/sync";
import type { Bounds } from "@/type/api";
const dashboard = reactive({
counts: {
service_requests: 0,
mosquito_sources: 0,
traps: 0,
},
organization: {
name: "",
id: 0,
},
isSyncOngoing: false,
lastSync: new Date(),
tegolaUrl: "",
serviceArea: {
min: { x: 0, y: 0 },
max: { x: 0, y: 0 },
},
recentRequests: [],
});
const storeServiceRequest = useStoreServiceRequest();
const storeSync = useStoreSync();
const session = useSessionStore();
onMounted(async () => {
const service_requests = await storeServiceRequest.fetchAll();
const syncs = await storeSync.fetchAll();
console.log("syncs", syncs);
});
function mapBounds(): Bounds | undefined {
if (session.organization?.service_area) {
return session.organization?.service_area;
}
return undefined;
}
function refreshData() {
console.log("fake refresh");
}
</script>

View file

@ -1,202 +1,23 @@
<template>
<!-- Dashboard Header -->
<div class="row mb-4">
<div class="col-md-6">
<h1>{{ dashboard.organization.name }} Dashboard</h1>
<p class="text-muted">
Overview of mosquito control activities in your district
</p>
</div>
<div
class="col-md-6 text-md-end d-flex align-items-center justify-content-md-end"
>
<p v-if="dashboard.isSyncOngoing" class="last-refreshed mb-0">
<i class="fas fa-sync-alt me-2 syncing"></i>Syncing now...
</p>
<p v-else class="last-refreshed mb-0">
<i class="fas fa-sync-alt me-2"></i>Last updated:
<span id="last-refreshed-time">{{
formatTimeRelative(dashboard.lastSync)
}}</span>
<button
class="btn btn-sm btn-outline-primary ms-3"
@click="refreshData"
>
Refresh Data
</button>
</p>
</div>
</div>
<!-- Key Metrics -->
<div class="row g-4">
<!-- Last Refreshed -->
<div class="col-md-3">
<div class="card stats-card h-100">
<div class="card-body text-center">
<div class="metric-icon bg-success text-white">
<i class="fas fa-clock"></i>
</div>
<h5 class="card-title">Last Data Refresh</h5>
<p class="metric-value">
{{ formatTimeRelative(dashboard.lastSync) }}
</p>
<!-- <p class="card-text text-muted">Last sync: 12:45 PM</p> -->
</div>
</div>
</div>
<!-- Service Requests -->
<div class="col-md-3">
<div class="card stats-card h-100">
<div class="card-body text-center">
<div class="metric-icon bg-warning text-white">
<i class="fas fa-ticket-alt"></i>
</div>
<h5 class="card-title">Service Requests</h5>
<p v-if="dashboard.isSyncOngoing" class="metric-value">
{{ formatBigNumber(dashboard.counts.service_requests) }}...?
</p>
<p v-else class="metric-value">
{{ formatBigNumber(dashboard.counts.service_requests) }}
</p>
<!--<p class="card-text text-muted">
<span class="text-success">
<i class="fas fa-arrow-up"></i> 12%
</span> since last week
</p>-->
</div>
</div>
</div>
<!-- Mosquito Sources -->
<div class="col-md-3">
<div class="card stats-card h-100">
<div class="card-body text-center">
<div class="metric-icon bg-danger text-white">
<i class="fas fa-bug"></i>
</div>
<h5 class="card-title">Mosquito Sources</h5>
<p v-if="dashboard.isSyncOngoing" class="metric-value">
{{ formatBigNumber(dashboard.counts.mosquito_sources) }}..?
</p>
<p v-else class="metric-value">
{{ formatBigNumber(dashboard.counts.mosquito_sources) }}
</p>
<!-- <p class="card-text text-muted">
<span class="text-danger">
<i class="fas fa-arrow-up"></i> 8%
</span> since last month
</p> -->
</div>
</div>
</div>
<!-- Inspections -->
<div class="col-md-3">
<div class="card stats-card h-100">
<div class="card-body text-center">
<div class="metric-icon bg-info text-white">
<i class="fas fa-clipboard-check"></i>
</div>
<h5 class="card-title">Traps</h5>
<p v-if="dashboard.isSyncOngoing" class="metric-value">
{{ formatBigNumber(dashboard.counts.traps) }}...?
</p>
<p v-else class="metric-value">
{{ formatBigNumber(dashboard.counts.traps) }}
</p>
<!-- <p class="card-text text-muted">
<span class="text-success">
<i class="fas fa-arrow-up"></i> 15%
</span> since last week
</p> -->
</div>
</div>
</div>
</div>
<!-- Map Section -->
<h3 class="section-title">Mosquito Activity Heatmap</h3>
<div class="row">
<div class="col-12">
<MapAggregate
:bounds="mapBounds()"
:markers="[]"
:organizationId="dashboard.organization.id"
:tegola="session.urls?.tegola ?? ''"
/>
</div>
</div>
<!-- Recent Activity Section -->
<h3 class="section-title">Recent Activity</h3>
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-body">
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Date</th>
<th>Type</th>
<th>Location</th>
<th>Status</th>
<th>Action</th>
</tr>
</thead>
</table>
</div>
</div>
</div>
</div>
</div>
<p>loading home...</p>
</template>
<script setup lang="ts">
import { onMounted, reactive } from "vue";
import MapAggregate from "@/components/MapAggregate.vue";
import { formatBigNumber, formatTimeRelative } from "@/format";
import { onMounted } from "vue";
import { useSessionStore } from "@/store/session";
import { useStoreServiceRequest } from "@/store/service_request";
import { useStoreSync } from "@/store/sync";
import type { Bounds } from "@/type/api";
import { Session } from "@/type/api";
import { router } from "@/router";
const dashboard = reactive({
counts: {
service_requests: 0,
mosquito_sources: 0,
traps: 0,
},
organization: {
name: "",
id: 0,
},
isSyncOngoing: false,
lastSync: new Date(),
tegolaUrl: "",
serviceArea: {
min: { x: 0, y: 0 },
max: { x: 0, y: 0 },
},
recentRequests: [],
});
const storeServiceRequest = useStoreServiceRequest();
const storeSync = useStoreSync();
const session = useSessionStore();
onMounted(async () => {
const service_requests = await storeServiceRequest.fetchAll();
const syncs = await storeSync.fetchAll();
console.log("syncs", syncs);
onMounted(() => {
session
.get()
.then((session: Session) => {
console.log("session loaded", session);
router.push("/_/dash");
})
.catch((e) => {
console.log("root session not loaded", e);
});
console.log("home mounted");
});
function mapBounds(): Bounds | undefined {
if (session.organization?.service_area) {
return session.organization?.service_area;
}
return undefined;
}
function refreshData() {
console.log("fake refresh");
}
</script>

View file

@ -33,43 +33,48 @@
<p class="text-muted">Please enter your credentials</p>
</div>
<form method="POST" action="/signin">
<input type="hidden" name="next" value="none" />
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input
type="text"
class="form-control"
name="username"
required
/>
</div>
<input type="hidden" name="next" value="none" />
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input
type="text"
class="form-control"
name="username"
required
v-model="username"
/>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input
type="password"
class="form-control"
name="password"
required
/>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input
type="password"
class="form-control"
name="password"
v-model="password"
required
/>
</div>
<!--
<!--
<div class="alert alert-danger" role="alert">
The credentials you provided weren't recognized.
</div>
-->
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary">Login</button>
</div>
<div class="d-grid gap-2">
<ButtonLoading
@click="doLogin()"
:loading="loading"
text="Login"
variant="primary"
/>
</div>
<div class="mt-3 text-center">
<p>Don't have an account? <a href="/signup">Sign up</a></p>
<a href="forgot-password.html">Forgot password?</a>
</div>
</form>
<div class="mt-3 text-center">
<p>Don't have an account? <a href="/signup">Sign up</a></p>
<a href="forgot-password.html">Forgot password?</a>
</div>
</div>
<!-- Right side: Product Information -->
@ -99,3 +104,27 @@
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { apiClient } from "@/client";
import ButtonLoading from "@/components/common/ButtonLoading.vue";
import { router } from "@/router";
const loading = ref<boolean>(false);
const password = ref<string>("");
const username = ref<string>("");
async function doLogin() {
loading.value = true;
try {
const resp = await apiClient.JSONPost("/api/signin", {
password: password.value,
username: username.value,
});
router.push("/");
} catch (e) {
console.log("login failed", e);
} finally {
loading.value = false;
}
}
</script>