used indexdb and not cache
This commit is contained in:
parent
b0aa2020c4
commit
74b76ee053
24 changed files with 2417 additions and 966 deletions
3
bun.lock
3
bun.lock
|
@ -18,6 +18,7 @@
|
|||
"astro-expressive-code": "^0.40.2",
|
||||
"astro-icon": "^1.1.5",
|
||||
"chart.js": "^4.4.7",
|
||||
"dexie": "^4.0.11",
|
||||
"framer-motion": "^12.4.4",
|
||||
"highlight.js": "^11.11.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
|
@ -582,6 +583,8 @@
|
|||
|
||||
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
|
||||
|
||||
"dexie": ["dexie@4.0.11", "", {}, "sha512-SOKO002EqlvBYYKQSew3iymBoN2EQ4BDw/3yprjh7kAfFzjBYkaMNa/pZvcA7HSWlcKSQb9XhPe3wKyQ0x4A8A=="],
|
||||
|
||||
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
|
||||
|
||||
"diff": ["diff@5.2.0", "", {}, "sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A=="],
|
||||
|
|
|
@ -25,6 +25,7 @@
|
|||
"astro-expressive-code": "^0.40.2",
|
||||
"astro-icon": "^1.1.5",
|
||||
"chart.js": "^4.4.7",
|
||||
"dexie": "^4.0.11",
|
||||
"framer-motion": "^12.4.4",
|
||||
"highlight.js": "^11.11.1",
|
||||
"js-yaml": "^4.1.0",
|
||||
|
|
|
@ -3,6 +3,8 @@ import { Get } from "../../../scripts/pocketbase/Get";
|
|||
import { Authentication } from "../../../scripts/pocketbase/Authentication";
|
||||
import { Update } from "../../../scripts/pocketbase/Update";
|
||||
import { SendLog } from "../../../scripts/pocketbase/SendLog";
|
||||
import { DataSyncService } from "../../../scripts/database/DataSyncService";
|
||||
import { Collections } from "../../../schemas/pocketbase/schema";
|
||||
import { Icon } from "@iconify/react";
|
||||
import type { Event, AttendeeEntry } from "../../../schemas/pocketbase";
|
||||
|
||||
|
@ -95,6 +97,7 @@ const EventCheckIn = () => {
|
|||
try {
|
||||
const get = Get.getInstance();
|
||||
const auth = Authentication.getInstance();
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
|
||||
const currentUser = auth.getCurrentUser();
|
||||
if (!currentUser) {
|
||||
|
@ -102,11 +105,19 @@ const EventCheckIn = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Find the event with the given code
|
||||
const event = await get.getFirst<Event>(
|
||||
"events",
|
||||
// Find the event with the given code using IndexedDB
|
||||
// Force sync to ensure we have the latest data
|
||||
await dataSync.syncCollection(Collections.EVENTS, `event_code = "${eventCode}"`);
|
||||
|
||||
// Get the event from IndexedDB
|
||||
const events = await dataSync.getData<Event>(
|
||||
Collections.EVENTS,
|
||||
false, // Don't force sync again
|
||||
`event_code = "${eventCode}"`
|
||||
);
|
||||
|
||||
const event = events.length > 0 ? events[0] : null;
|
||||
|
||||
if (!event) {
|
||||
throw new Error("Invalid event code");
|
||||
}
|
||||
|
@ -149,6 +160,7 @@ const EventCheckIn = () => {
|
|||
const auth = Authentication.getInstance();
|
||||
const update = Update.getInstance();
|
||||
const logger = SendLog.getInstance();
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
|
||||
const currentUser = auth.getCurrentUser();
|
||||
if (!currentUser) {
|
||||
|
@ -197,6 +209,9 @@ const EventCheckIn = () => {
|
|||
// Update attendees array with the new entry
|
||||
await update.updateField("events", event.id, "attendees", updatedAttendees);
|
||||
|
||||
// Force sync the events collection to update IndexedDB
|
||||
await dataSync.syncCollection(Collections.EVENTS);
|
||||
|
||||
// If food selection was made, log it
|
||||
if (foodSelection) {
|
||||
await logger.send(
|
||||
|
@ -216,6 +231,9 @@ const EventCheckIn = () => {
|
|||
userPoints + event.points_to_reward
|
||||
);
|
||||
|
||||
// Force sync the users collection to update IndexedDB
|
||||
await dataSync.syncCollection(Collections.USERS);
|
||||
|
||||
// Log the points award
|
||||
await logger.send(
|
||||
"update",
|
||||
|
|
|
@ -2,6 +2,8 @@ import { useEffect, useState } from "react";
|
|||
import { Icon } from "@iconify/react";
|
||||
import { Get } from "../../../scripts/pocketbase/Get";
|
||||
import { Authentication } from "../../../scripts/pocketbase/Authentication";
|
||||
import { DataSyncService } from "../../../scripts/database/DataSyncService";
|
||||
import { Collections } from "../../../schemas/pocketbase/schema";
|
||||
import type { Event, AttendeeEntry } from "../../../schemas/pocketbase";
|
||||
|
||||
// Extended Event interface with additional properties needed for this component
|
||||
|
@ -139,14 +141,17 @@ const EventLoad = () => {
|
|||
const loadEvents = async () => {
|
||||
try {
|
||||
const get = Get.getInstance();
|
||||
const allEvents = await get.getAll<Event>(
|
||||
"events",
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
|
||||
// Force sync to ensure we have the latest data
|
||||
await dataSync.syncCollection(Collections.EVENTS, "published = true", "-start_date");
|
||||
|
||||
// Get events from IndexedDB
|
||||
const allEvents = await dataSync.getData<Event>(
|
||||
Collections.EVENTS,
|
||||
false, // Don't force sync again
|
||||
"published = true",
|
||||
"-start_date",
|
||||
{
|
||||
fields: ["*"],
|
||||
disableAutoCancellation: true
|
||||
}
|
||||
"-start_date"
|
||||
);
|
||||
|
||||
// Split events into upcoming, ongoing, and past based on start and end dates
|
||||
|
|
|
@ -2,6 +2,8 @@ import { useEffect, useState, useMemo, useCallback } from 'react';
|
|||
import { Get } from '../../../scripts/pocketbase/Get';
|
||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||
import { SendLog } from '../../../scripts/pocketbase/SendLog';
|
||||
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||
import { Icon } from "@iconify/react";
|
||||
import type { Event, AttendeeEntry, User as SchemaUser } from "../../../schemas/pocketbase";
|
||||
|
||||
|
@ -160,10 +162,25 @@ export default function Attendees() {
|
|||
|
||||
// Fetch uncached users
|
||||
try {
|
||||
const users = await get.getMany<User>('users', uncachedIds, {
|
||||
fields: USER_FIELDS,
|
||||
disableAutoCancellation: false
|
||||
});
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
|
||||
// Sync users collection for the uncached IDs
|
||||
if (uncachedIds.length > 0) {
|
||||
const idFilter = uncachedIds.map(id => `id = "${id}"`).join(' || ');
|
||||
await dataSync.syncCollection(Collections.USERS, idFilter);
|
||||
}
|
||||
|
||||
// Get users from IndexedDB
|
||||
const users = await Promise.all(
|
||||
uncachedIds.map(async id => {
|
||||
try {
|
||||
return await dataSync.getItem<User>(Collections.USERS, id);
|
||||
} catch (error) {
|
||||
console.error(`Failed to fetch user ${id}:`, error);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Update cache and merge with cached users
|
||||
users.forEach(user => {
|
||||
|
@ -177,7 +194,7 @@ export default function Attendees() {
|
|||
}
|
||||
|
||||
return cachedUsers;
|
||||
}, [get]);
|
||||
}, []);
|
||||
|
||||
// Listen for the custom event
|
||||
useEffect(() => {
|
||||
|
@ -226,13 +243,23 @@ export default function Attendees() {
|
|||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const event = await get.getOne<Event>('events', eventId, {
|
||||
fields: EVENT_FIELDS,
|
||||
disableAutoCancellation: false
|
||||
});
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
|
||||
// Sync the event data
|
||||
await dataSync.syncCollection(Collections.EVENTS, `id = "${eventId}"`);
|
||||
|
||||
// Get the event from IndexedDB
|
||||
const event = await dataSync.getItem<Event>(Collections.EVENTS, eventId);
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
if (!event) {
|
||||
setError('Event not found');
|
||||
setAttendeesList([]);
|
||||
setUsers(new Map());
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.attendees?.length) {
|
||||
setAttendeesList([]);
|
||||
setUsers(new Map());
|
||||
|
@ -263,7 +290,7 @@ export default function Attendees() {
|
|||
|
||||
fetchEventData();
|
||||
return () => { isMounted = false; };
|
||||
}, [eventId, auth, get, fetchUserData]);
|
||||
}, [eventId, auth, fetchUserData]);
|
||||
|
||||
// Reset state when modal is closed
|
||||
useEffect(() => {
|
||||
|
|
|
@ -7,6 +7,8 @@ import { FileManager } from "../../../scripts/pocketbase/FileManager";
|
|||
import { SendLog } from "../../../scripts/pocketbase/SendLog";
|
||||
import FilePreview from "../universal/FilePreview";
|
||||
import type { Event as SchemaEvent, AttendeeEntry } from "../../../schemas/pocketbase";
|
||||
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||
|
||||
// Note: Date conversion is now handled automatically by the Get and Update classes.
|
||||
// When fetching events, UTC dates are converted to local time by the Get class.
|
||||
|
@ -714,6 +716,10 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
|||
await pb.collection("events").update(event.id, {
|
||||
files: remainingFiles
|
||||
});
|
||||
|
||||
// Sync the events collection to update IndexedDB
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
await dataSync.syncCollection(Collections.EVENTS);
|
||||
}
|
||||
|
||||
// Handle file additions
|
||||
|
@ -725,6 +731,10 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
|||
|
||||
// Use appendFiles to preserve existing files
|
||||
await services.fileManager.appendFiles("events", event.id, "files", filesToUpload);
|
||||
|
||||
// Sync the events collection to update IndexedDB
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
await dataSync.syncCollection(Collections.EVENTS);
|
||||
} catch (error: any) {
|
||||
if (error.status === 413) {
|
||||
throw new Error("Files are too large. Please try uploading smaller files or fewer files at once.");
|
||||
|
@ -738,6 +748,10 @@ export default function EventEditor({ onEventSaved }: EventEditorProps) {
|
|||
const newEvent = await pb.collection("events").create(eventData);
|
||||
console.log('New event created:', newEvent);
|
||||
|
||||
// Sync the events collection to update IndexedDB
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
await dataSync.syncCollection(Collections.EVENTS);
|
||||
|
||||
// Upload files if any
|
||||
if (selectedFiles.size > 0) {
|
||||
try {
|
||||
|
|
|
@ -3,6 +3,7 @@ import { Authentication } from "../../scripts/pocketbase/Authentication";
|
|||
import { Get } from "../../scripts/pocketbase/Get";
|
||||
import EventRequestForm from "./Officer_EventRequestForm/EventRequestForm";
|
||||
import UserEventRequests from "./Officer_EventRequestForm/UserEventRequests";
|
||||
import { Collections } from "../../schemas/pocketbase/schema";
|
||||
|
||||
// Import the EventRequest type from UserEventRequests to ensure consistency
|
||||
import type { EventRequest as UserEventRequest } from "./Officer_EventRequestForm/UserEventRequests";
|
||||
|
@ -19,14 +20,16 @@ let userEventRequests: EventRequest[] = [];
|
|||
let error: string | null = null;
|
||||
|
||||
// Fetch user's event request submissions if authenticated
|
||||
// This provides initial data for server-side rendering
|
||||
// Client-side will use IndexedDB for data management
|
||||
if (auth.isAuthenticated()) {
|
||||
try {
|
||||
const userId = auth.getUserId();
|
||||
if (userId) {
|
||||
userEventRequests = await get.getAll<EventRequest>(
|
||||
"event_request",
|
||||
Collections.EVENT_REQUESTS,
|
||||
`requested_user="${userId}"`,
|
||||
"-created",
|
||||
"-created"
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
|
@ -41,8 +44,8 @@ if (auth.isAuthenticated()) {
|
|||
<h1 class="text-3xl font-bold text-white mb-2">Event Request Form</h1>
|
||||
<p class="text-gray-300 mb-4">
|
||||
Submit your event request at least 6 weeks before your event. After
|
||||
submitting, please notify PR and/or Coordinators in the #-events Slack
|
||||
channel.
|
||||
submitting, please notify PR and/or Coordinators in the #-events
|
||||
Slack channel.
|
||||
</p>
|
||||
<div class="bg-base-300/50 p-4 rounded-lg text-sm text-gray-300">
|
||||
<p class="font-medium mb-2">This form includes sections for:</p>
|
||||
|
@ -104,7 +107,10 @@ if (auth.isAuthenticated()) {
|
|||
|
||||
{
|
||||
!error && (
|
||||
<UserEventRequests client:load eventRequests={userEventRequests} />
|
||||
<UserEventRequests
|
||||
client:load
|
||||
eventRequests={userEventRequests}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
@ -112,19 +118,50 @@ if (auth.isAuthenticated()) {
|
|||
</div>
|
||||
|
||||
<script>
|
||||
// Import the DataSyncService for client-side use
|
||||
import { DataSyncService } from "../../scripts/database/DataSyncService";
|
||||
import { Collections } from "../../schemas/pocketbase/schema";
|
||||
import { Authentication } from "../../scripts/pocketbase/Authentication";
|
||||
|
||||
// Tab switching logic
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
// Initialize DataSyncService for client-side
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
const auth = Authentication.getInstance();
|
||||
|
||||
// Prefetch data into IndexedDB if authenticated
|
||||
if (auth.isAuthenticated()) {
|
||||
try {
|
||||
const userId = auth.getUserId();
|
||||
if (userId) {
|
||||
// Force sync to ensure we have the latest data
|
||||
await dataSync.syncCollection(
|
||||
Collections.EVENT_REQUESTS,
|
||||
`requested_user="${userId}"`,
|
||||
"-created"
|
||||
);
|
||||
console.log(
|
||||
"Initial data sync complete for user event requests"
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error during initial data sync:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const formTab = document.getElementById("form-tab");
|
||||
const submissionsTab = document.getElementById("submissions-tab");
|
||||
const formContent = document.getElementById("form-content");
|
||||
const submissionsContent = document.getElementById("submissions-content");
|
||||
const submissionsContent = document.getElementById(
|
||||
"submissions-content"
|
||||
);
|
||||
|
||||
// Function to switch tabs
|
||||
const switchTab = (
|
||||
activeTab: HTMLElement,
|
||||
activeContent: HTMLElement,
|
||||
inactiveTab: HTMLElement,
|
||||
inactiveContent: HTMLElement,
|
||||
inactiveContent: HTMLElement
|
||||
) => {
|
||||
// Update tab classes
|
||||
activeTab.classList.add("tab-active");
|
||||
|
@ -136,8 +173,8 @@ if (auth.isAuthenticated()) {
|
|||
|
||||
// Dispatch event to refresh submissions when switching to submissions tab
|
||||
if (activeTab.id === "submissions-tab") {
|
||||
const refreshEvent = new CustomEvent("refreshSubmissions");
|
||||
document.dispatchEvent(refreshEvent);
|
||||
// Dispatch a custom event that the UserEventRequests component listens for
|
||||
document.dispatchEvent(new CustomEvent("dashboardTabVisible"));
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -145,14 +182,32 @@ if (auth.isAuthenticated()) {
|
|||
formTab?.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
if (formContent && submissionsContent && submissionsTab) {
|
||||
switchTab(formTab, formContent, submissionsTab, submissionsContent);
|
||||
switchTab(
|
||||
formTab,
|
||||
formContent,
|
||||
submissionsTab,
|
||||
submissionsContent
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
submissionsTab?.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
if (formContent && submissionsContent && formTab) {
|
||||
switchTab(submissionsTab, submissionsContent, formTab, formContent);
|
||||
switchTab(
|
||||
submissionsTab,
|
||||
submissionsContent,
|
||||
formTab,
|
||||
formContent
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for visibility changes
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
// Dispatch custom event that components can listen for
|
||||
document.dispatchEvent(new CustomEvent("dashboardTabVisible"));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,6 +5,8 @@ import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
|||
import { Update } from '../../../scripts/pocketbase/Update';
|
||||
import { FileManager } from '../../../scripts/pocketbase/FileManager';
|
||||
import { Get } from '../../../scripts/pocketbase/Get';
|
||||
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||
import type { EventRequest } from '../../../schemas/pocketbase';
|
||||
import { EventRequestStatus } from '../../../schemas/pocketbase';
|
||||
|
||||
|
@ -191,6 +193,7 @@ const EventRequestForm: React.FC = () => {
|
|||
const auth = Authentication.getInstance();
|
||||
const update = Update.getInstance();
|
||||
const fileManager = FileManager.getInstance();
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
|
||||
if (!auth.isAuthenticated()) {
|
||||
toast.error('You must be logged in to submit an event request', { id: submittingToast });
|
||||
|
@ -245,9 +248,13 @@ const EventRequestForm: React.FC = () => {
|
|||
toast.loading('Creating event request record...', { id: submittingToast });
|
||||
|
||||
try {
|
||||
// Create the record
|
||||
// Create the record using the Update service
|
||||
// This will send the data to the server
|
||||
const record = await update.create('event_request', submissionData);
|
||||
|
||||
// Force sync the event requests collection to update IndexedDB
|
||||
await dataSync.syncCollection(Collections.EVENT_REQUESTS);
|
||||
|
||||
// Upload files if they exist
|
||||
if (formData.other_logos.length > 0) {
|
||||
toast.loading('Uploading logo files...', { id: submittingToast });
|
||||
|
|
|
@ -2,6 +2,8 @@ import React, { useState, useEffect } from 'react';
|
|||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Get } from '../../../scripts/pocketbase/Get';
|
||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||
import toast from 'react-hot-toast';
|
||||
import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase';
|
||||
|
||||
|
@ -20,6 +22,7 @@ const UserEventRequests: React.FC<UserEventRequestsProps> = ({ eventRequests: in
|
|||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
|
||||
const [viewMode, setViewMode] = useState<'table' | 'cards'>('table');
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
|
||||
// Refresh event requests
|
||||
const refreshEventRequests = async () => {
|
||||
|
@ -27,7 +30,6 @@ const UserEventRequests: React.FC<UserEventRequestsProps> = ({ eventRequests: in
|
|||
const refreshToast = toast.loading('Refreshing submissions...');
|
||||
|
||||
try {
|
||||
const get = Get.getInstance();
|
||||
const auth = Authentication.getInstance();
|
||||
|
||||
if (!auth.isAuthenticated()) {
|
||||
|
@ -41,8 +43,10 @@ const UserEventRequests: React.FC<UserEventRequestsProps> = ({ eventRequests: in
|
|||
return;
|
||||
}
|
||||
|
||||
const updatedRequests = await get.getAll<EventRequest>(
|
||||
'event_request',
|
||||
// Use DataSyncService to get data from IndexedDB with forced sync
|
||||
const updatedRequests = await dataSync.getData<EventRequest>(
|
||||
Collections.EVENT_REQUESTS,
|
||||
true, // Force sync
|
||||
`requested_user="${userId}"`,
|
||||
'-created'
|
||||
);
|
||||
|
@ -62,6 +66,22 @@ const UserEventRequests: React.FC<UserEventRequestsProps> = ({ eventRequests: in
|
|||
refreshEventRequests();
|
||||
}, []);
|
||||
|
||||
// Listen for tab visibility changes and refresh data when tab becomes visible
|
||||
useEffect(() => {
|
||||
const handleTabVisible = () => {
|
||||
console.log("Tab became visible, refreshing event requests...");
|
||||
refreshEventRequests();
|
||||
};
|
||||
|
||||
// Add event listener for custom dashboardTabVisible event
|
||||
document.addEventListener("dashboardTabVisible", handleTabVisible);
|
||||
|
||||
// Clean up event listener on component unmount
|
||||
return () => {
|
||||
document.removeEventListener("dashboardTabVisible", handleTabVisible);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Format date for display
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString) return 'Not specified';
|
||||
|
|
|
@ -4,6 +4,7 @@ import { Get } from "../../scripts/pocketbase/Get";
|
|||
import { Toaster } from "react-hot-toast";
|
||||
import EventRequestManagementTable from "./Officer_EventRequestManagement/EventRequestManagementTable";
|
||||
import type { EventRequest } from "../../schemas/pocketbase";
|
||||
import { Collections } from "../../schemas/pocketbase/schema";
|
||||
|
||||
// Get instances
|
||||
const get = Get.getInstance();
|
||||
|
@ -39,9 +40,14 @@ try {
|
|||
console.log("Fetching event requests in Astro component...");
|
||||
// Expand the requested_user field to get user details
|
||||
allEventRequests = await get
|
||||
.getAll<ExtendedEventRequest>("event_request", "", "-created", {
|
||||
.getAll<ExtendedEventRequest>(
|
||||
Collections.EVENT_REQUESTS,
|
||||
"",
|
||||
"-created",
|
||||
{
|
||||
expand: ["requested_user"],
|
||||
})
|
||||
}
|
||||
)
|
||||
.catch((err) => {
|
||||
console.error("Error in get.getAll:", err);
|
||||
// Return empty array instead of throwing
|
||||
|
@ -152,6 +158,10 @@ try {
|
|||
</div>
|
||||
|
||||
<script>
|
||||
// Import the DataSyncService for client-side use
|
||||
import { DataSyncService } from "../../scripts/database/DataSyncService";
|
||||
import { Collections } from "../../schemas/pocketbase/schema";
|
||||
|
||||
// Remove the visibilitychange event listener that causes full page refresh
|
||||
// Instead, we'll use a more efficient approach to refresh data only when needed
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
|
@ -163,7 +173,23 @@ try {
|
|||
});
|
||||
|
||||
// Handle authentication errors
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
// Initialize DataSyncService for client-side
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
|
||||
// Prefetch data into IndexedDB
|
||||
try {
|
||||
await dataSync.syncCollection(
|
||||
Collections.EVENT_REQUESTS,
|
||||
"",
|
||||
"-created",
|
||||
{ expand: "requested_user" }
|
||||
);
|
||||
console.log("Initial data sync complete");
|
||||
} catch (err) {
|
||||
console.error("Error during initial data sync:", err);
|
||||
}
|
||||
|
||||
// Check for error message in the UI
|
||||
const errorElement = document.querySelector(".alert-error span");
|
||||
if (
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import toast from 'react-hot-toast';
|
||||
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||
import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase/schema';
|
||||
|
||||
// Extended EventRequest interface with additional properties needed for this component
|
||||
|
|
|
@ -3,9 +3,11 @@ import { motion, AnimatePresence } from 'framer-motion';
|
|||
import { Get } from '../../../scripts/pocketbase/Get';
|
||||
import { Update } from '../../../scripts/pocketbase/Update';
|
||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
||||
import toast from 'react-hot-toast';
|
||||
import EventRequestDetails from './EventRequestDetails';
|
||||
import type { EventRequest as SchemaEventRequest } from '../../../schemas/pocketbase/schema';
|
||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||
|
||||
// Extended EventRequest interface with additional properties needed for this component
|
||||
interface ExtendedEventRequest extends SchemaEventRequest {
|
||||
|
@ -41,6 +43,7 @@ const EventRequestManagementTable = ({ eventRequests: initialEventRequests }: Ev
|
|||
const [searchTerm, setSearchTerm] = useState<string>('');
|
||||
const [sortField, setSortField] = useState<string>('created');
|
||||
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
|
||||
// Refresh event requests
|
||||
const refreshEventRequests = async () => {
|
||||
|
@ -48,22 +51,22 @@ const EventRequestManagementTable = ({ eventRequests: initialEventRequests }: Ev
|
|||
const refreshToast = toast.loading('Refreshing event requests...');
|
||||
|
||||
try {
|
||||
const get = Get.getInstance();
|
||||
const auth = Authentication.getInstance();
|
||||
|
||||
// Don't check authentication here - try to fetch anyway
|
||||
// The token might be valid for the API even if isAuthenticated() returns false
|
||||
|
||||
console.log("Fetching event requests...");
|
||||
const updatedRequests = await get.getAll<ExtendedEventRequest>(
|
||||
'event_request',
|
||||
'',
|
||||
|
||||
// Use DataSyncService to get data from IndexedDB with forced sync
|
||||
const updatedRequests = await dataSync.getData<ExtendedEventRequest>(
|
||||
Collections.EVENT_REQUESTS,
|
||||
true, // Force sync
|
||||
'', // No filter
|
||||
'-created',
|
||||
{
|
||||
fields: ['*'],
|
||||
expand: ['requested_user']
|
||||
}
|
||||
'requested_user'
|
||||
);
|
||||
|
||||
console.log(`Fetched ${updatedRequests.length} event requests`);
|
||||
|
||||
setEventRequests(updatedRequests);
|
||||
|
@ -167,6 +170,9 @@ const EventRequestManagementTable = ({ eventRequests: initialEventRequests }: Ev
|
|||
setSelectedRequest({ ...selectedRequest, status });
|
||||
}
|
||||
|
||||
// Force sync to update IndexedDB
|
||||
await dataSync.syncCollection<ExtendedEventRequest>(Collections.EVENT_REQUESTS);
|
||||
|
||||
toast.success(`Status updated to ${status}`, { id: updateToast });
|
||||
} catch (err) {
|
||||
console.error('Failed to update event request status:', err);
|
||||
|
@ -195,6 +201,9 @@ const EventRequestManagementTable = ({ eventRequests: initialEventRequests }: Ev
|
|||
)
|
||||
);
|
||||
|
||||
// Force sync to update IndexedDB
|
||||
await dataSync.syncCollection<ExtendedEventRequest>(Collections.EVENT_REQUESTS);
|
||||
|
||||
toast.success('Feedback saved successfully', { id: feedbackToast });
|
||||
return true;
|
||||
} catch (err) {
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import { useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { Get } from "../../../scripts/pocketbase/Get";
|
||||
import { Authentication } from "../../../scripts/pocketbase/Authentication";
|
||||
import { DataSyncService } from "../../../scripts/database/DataSyncService";
|
||||
import { Collections } from "../../../schemas/pocketbase/schema";
|
||||
import debounce from 'lodash/debounce';
|
||||
import type { Log } from "../../../schemas/pocketbase";
|
||||
|
||||
|
@ -65,29 +67,25 @@ export default function ShowProfileLogs() {
|
|||
};
|
||||
|
||||
const fetchAllLogs = async (userId: string): Promise<Log[]> => {
|
||||
const get = Get.getInstance();
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
let allLogs: Log[] = [];
|
||||
let page = 1;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
try {
|
||||
const response = await get.getList<Log>(
|
||||
"logs",
|
||||
page,
|
||||
BATCH_SIZE,
|
||||
// First, sync all logs for this user
|
||||
await dataSync.syncCollection(
|
||||
Collections.LOGS,
|
||||
`user_id = "${userId}"`,
|
||||
"-created"
|
||||
);
|
||||
|
||||
allLogs = [...allLogs, ...response.items];
|
||||
hasMore = page * BATCH_SIZE < response.totalItems;
|
||||
page++;
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch logs batch:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
// Then get all logs from IndexedDB
|
||||
allLogs = await dataSync.getData<Log>(
|
||||
Collections.LOGS,
|
||||
false, // Don't force sync again
|
||||
`user_id = "${userId}"`,
|
||||
"-created"
|
||||
);
|
||||
|
||||
return allLogs;
|
||||
};
|
||||
|
|
|
@ -1,7 +1,14 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Get } from "../../../scripts/pocketbase/Get";
|
||||
import { Authentication } from "../../../scripts/pocketbase/Authentication";
|
||||
import type { Event, Log } from "../../../schemas/pocketbase";
|
||||
import { DataSyncService } from "../../../scripts/database/DataSyncService";
|
||||
import { Collections } from "../../../schemas/pocketbase/schema";
|
||||
import type { Event, Log, User } from "../../../schemas/pocketbase";
|
||||
|
||||
// Extended User interface with points property
|
||||
interface ExtendedUser extends User {
|
||||
points?: number;
|
||||
}
|
||||
|
||||
export function Stats() {
|
||||
const [eventsAttended, setEventsAttended] = useState(0);
|
||||
|
@ -17,6 +24,7 @@ export function Stats() {
|
|||
setIsLoading(true);
|
||||
const get = Get.getInstance();
|
||||
const auth = Authentication.getInstance();
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
const userId = auth.getCurrentUser()?.id;
|
||||
|
||||
if (!userId) return;
|
||||
|
@ -43,18 +51,30 @@ export function Stats() {
|
|||
quarterStart = new Date(now.getFullYear(), 6, 1); // Jul 1
|
||||
}
|
||||
|
||||
// Get user's total points
|
||||
const user = await get.getOne("users", userId);
|
||||
const totalPoints = user.points || 0;
|
||||
// Sync user data to ensure we have the latest
|
||||
await dataSync.syncCollection(Collections.USERS, `id = "${userId}"`);
|
||||
|
||||
// Fetch quarterly points from logs
|
||||
const logs = await get.getList<Log>("logs", 1, 50,
|
||||
// Get user from IndexedDB
|
||||
const user = await dataSync.getItem<ExtendedUser>(Collections.USERS, userId);
|
||||
const totalPoints = user?.points || 0;
|
||||
|
||||
// Sync logs for the current quarter
|
||||
await dataSync.syncCollection(
|
||||
Collections.LOGS,
|
||||
`user_id = "${userId}" && created >= "${quarterStart.toISOString()}" && type = "update" && part = "event check-in"`,
|
||||
"-created"
|
||||
);
|
||||
|
||||
// Get logs from IndexedDB
|
||||
const logs = await dataSync.getData<Log>(
|
||||
Collections.LOGS,
|
||||
false, // Don't force sync again
|
||||
`user_id = "${userId}" && created >= "${quarterStart.toISOString()}" && type = "update" && part = "event check-in"`,
|
||||
"-created"
|
||||
);
|
||||
|
||||
// Calculate quarterly points
|
||||
const quarterlyPoints = logs.items.reduce((total, log) => {
|
||||
const quarterlyPoints = logs.reduce((total, log) => {
|
||||
const pointsMatch = log.message.match(/Awarded (\d+) points/);
|
||||
if (pointsMatch) {
|
||||
return total + parseInt(pointsMatch[1]);
|
||||
|
@ -62,8 +82,12 @@ export function Stats() {
|
|||
return total;
|
||||
}, 0);
|
||||
|
||||
// Fetch all events for total count
|
||||
const events = await get.getAll<Event>("events");
|
||||
// Sync events collection
|
||||
await dataSync.syncCollection(Collections.EVENTS);
|
||||
|
||||
// Get events from IndexedDB
|
||||
const events = await dataSync.getData<Event>(Collections.EVENTS);
|
||||
|
||||
const attendedEvents = events.filter(event =>
|
||||
event.attendees?.some(attendee => attendee.user_id === userId)
|
||||
);
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { Authentication } from '../../../scripts/pocketbase/Authentication';
|
||||
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||
import ReceiptForm from './ReceiptForm';
|
||||
import { toast } from 'react-hot-toast';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import FilePreview from '../universal/FilePreview';
|
||||
import ToastProvider from './ToastProvider';
|
||||
import type { ItemizedExpense, Reimbursement } from '../../../schemas/pocketbase';
|
||||
import type { ItemizedExpense, Reimbursement, Receipt } from '../../../schemas/pocketbase';
|
||||
|
||||
interface ReceiptFormData {
|
||||
field: File;
|
||||
|
@ -194,6 +196,10 @@ export default function ReimbursementForm() {
|
|||
|
||||
const response = await pb.collection('receipts').create(formData);
|
||||
|
||||
// Sync the receipts collection to update IndexedDB
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
await dataSync.syncCollection(Collections.RECEIPTS);
|
||||
|
||||
// Add receipt to state
|
||||
setReceipts(prev => [...prev, { ...receiptData, id: response.id }]);
|
||||
|
||||
|
@ -263,6 +269,10 @@ export default function ReimbursementForm() {
|
|||
|
||||
await pb.collection('reimbursement').create(formData);
|
||||
|
||||
// Sync the reimbursements collection to update IndexedDB
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
await dataSync.syncCollection(Collections.REIMBURSEMENTS);
|
||||
|
||||
// Reset form
|
||||
setRequest({
|
||||
title: '',
|
||||
|
|
|
@ -8,6 +8,8 @@ import { toast } from 'react-hot-toast';
|
|||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import ToastProvider from './ToastProvider';
|
||||
import type { ItemizedExpense, Reimbursement, Receipt } from '../../../schemas/pocketbase';
|
||||
import { DataSyncService } from '../../../scripts/database/DataSyncService';
|
||||
import { Collections } from '../../../schemas/pocketbase/schema';
|
||||
|
||||
interface AuditNote {
|
||||
note: string;
|
||||
|
@ -97,12 +99,13 @@ const itemVariants = {
|
|||
export default function ReimbursementList() {
|
||||
const [requests, setRequests] = useState<ReimbursementRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [error, setError] = useState('');
|
||||
const [selectedRequest, setSelectedRequest] = useState<ReimbursementRequest | null>(null);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [previewUrl, setPreviewUrl] = useState('');
|
||||
const [previewFilename, setPreviewFilename] = useState('');
|
||||
const [selectedReceipt, setSelectedReceipt] = useState<ReceiptDetails | null>(null);
|
||||
const [receiptDetailsMap, setReceiptDetailsMap] = useState<Record<string, ReceiptDetails>>({});
|
||||
|
||||
const get = Get.getInstance();
|
||||
const auth = Authentication.getInstance();
|
||||
|
@ -120,100 +123,122 @@ export default function ReimbursementList() {
|
|||
}, [requests]);
|
||||
|
||||
const fetchReimbursements = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const pb = auth.getPocketBase();
|
||||
const userId = pb.authStore.model?.id;
|
||||
|
||||
console.log('Current user ID:', userId);
|
||||
console.log('Auth store state:', {
|
||||
isValid: pb.authStore.isValid,
|
||||
token: pb.authStore.token,
|
||||
model: pb.authStore.model
|
||||
});
|
||||
|
||||
if (!userId) {
|
||||
toast.error('User not authenticated');
|
||||
throw new Error('User not authenticated');
|
||||
}
|
||||
|
||||
const loadingToast = toast.loading('Loading reimbursements...');
|
||||
|
||||
// First try to get all reimbursements to see if the collection is accessible
|
||||
// Use DataSyncService to get data from IndexedDB with forced sync
|
||||
const dataSync = DataSyncService.getInstance();
|
||||
|
||||
// Sync reimbursements collection
|
||||
await dataSync.syncCollection(
|
||||
Collections.REIMBURSEMENTS,
|
||||
`submitted_by="${userId}"`,
|
||||
'-created',
|
||||
'audit_notes'
|
||||
);
|
||||
|
||||
// Get reimbursements from IndexedDB
|
||||
const reimbursementRecords = await dataSync.getData<ReimbursementRequest>(
|
||||
Collections.REIMBURSEMENTS,
|
||||
false, // Don't force sync again
|
||||
`submitted_by="${userId}"`,
|
||||
'-created'
|
||||
);
|
||||
|
||||
console.log('Reimbursement records from IndexedDB:', reimbursementRecords);
|
||||
|
||||
// Process the records
|
||||
const processedRecords = reimbursementRecords.map(record => {
|
||||
// Process audit notes if they exist
|
||||
let auditNotes = null;
|
||||
if (record.audit_notes) {
|
||||
try {
|
||||
const allRecords = await pb.collection('reimbursement').getList(1, 50);
|
||||
console.log('All reimbursements (no filter):', allRecords);
|
||||
// If it's a string, parse it
|
||||
if (typeof record.audit_notes === 'string') {
|
||||
auditNotes = JSON.parse(record.audit_notes);
|
||||
} else {
|
||||
// Otherwise use it directly
|
||||
auditNotes = record.audit_notes;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error getting all reimbursements:', e);
|
||||
console.error('Error parsing audit notes:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Now try with the filter
|
||||
console.log('Attempting to fetch with filter:', `submitted_by = "${userId}"`);
|
||||
|
||||
try {
|
||||
const records = await pb.collection('reimbursement').getList(1, 50, {
|
||||
filter: `submitted_by = "${userId}"`,
|
||||
sort: '-created',
|
||||
expand: 'audit_notes'
|
||||
});
|
||||
|
||||
console.log('Filtered records response:', records);
|
||||
console.log('Total items:', records.totalItems);
|
||||
console.log('Items:', records.items);
|
||||
|
||||
if (records.items.length === 0) {
|
||||
console.log('No records found for user');
|
||||
setRequests([]);
|
||||
toast.dismiss(loadingToast);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert PocketBase records to ReimbursementRequest type
|
||||
const reimbursements: ReimbursementRequest[] = records.items.map(record => {
|
||||
console.log('Processing record:', record);
|
||||
const processedRequest = {
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
total_amount: record.total_amount,
|
||||
date_of_purchase: record.date_of_purchase,
|
||||
payment_method: record.payment_method,
|
||||
status: record.status,
|
||||
submitted_by: record.submitted_by,
|
||||
additional_info: record.additional_info || '',
|
||||
receipts: record.receipts || [],
|
||||
department: record.department,
|
||||
created: record.created,
|
||||
updated: record.updated,
|
||||
audit_notes: record.audit_notes || null
|
||||
return {
|
||||
...record,
|
||||
audit_notes: auditNotes
|
||||
};
|
||||
console.log('Processed request:', processedRequest);
|
||||
return processedRequest;
|
||||
});
|
||||
|
||||
console.log('All processed reimbursements:', reimbursements);
|
||||
console.log('Setting requests state with:', reimbursements.length, 'items');
|
||||
setRequests(processedRecords);
|
||||
toast.success('Reimbursements loaded successfully', { id: loadingToast });
|
||||
|
||||
// Update state with the new reimbursements
|
||||
setRequests(reimbursements);
|
||||
console.log('State updated with reimbursements');
|
||||
// Fetch receipt details for each reimbursement
|
||||
for (const record of processedRecords) {
|
||||
if (record.receipts && record.receipts.length > 0) {
|
||||
for (const receiptId of record.receipts) {
|
||||
try {
|
||||
// Get receipt from IndexedDB
|
||||
const receiptRecord = await dataSync.getItem<ReceiptDetails>(
|
||||
Collections.RECEIPTS,
|
||||
receiptId
|
||||
);
|
||||
|
||||
toast.dismiss(loadingToast);
|
||||
toast.success(`Loaded ${reimbursements.length} reimbursement${reimbursements.length === 1 ? '' : 's'}`);
|
||||
} catch (e) {
|
||||
console.error('Error with filtered query:', e);
|
||||
throw e;
|
||||
if (receiptRecord) {
|
||||
// Process itemized expenses
|
||||
let itemizedExpenses: ItemizedExpense[] = [];
|
||||
if (receiptRecord.itemized_expenses) {
|
||||
try {
|
||||
if (typeof receiptRecord.itemized_expenses === 'string') {
|
||||
itemizedExpenses = JSON.parse(receiptRecord.itemized_expenses);
|
||||
} else {
|
||||
itemizedExpenses = receiptRecord.itemized_expenses as ItemizedExpense[];
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching reimbursements:', error);
|
||||
console.error('Error details:', {
|
||||
message: error?.message,
|
||||
data: error?.data,
|
||||
url: error?.url,
|
||||
status: error?.status
|
||||
});
|
||||
toast.error('Failed to load reimbursement requests');
|
||||
setError('Failed to load reimbursement requests. ' + (error?.message || ''));
|
||||
} catch (e) {
|
||||
console.error('Error parsing itemized expenses:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Add receipt to state
|
||||
setReceiptDetailsMap(prevMap => ({
|
||||
...prevMap,
|
||||
[receiptId]: {
|
||||
id: receiptRecord.id,
|
||||
field: receiptRecord.field,
|
||||
created_by: receiptRecord.created_by,
|
||||
date: receiptRecord.date,
|
||||
location_name: receiptRecord.location_name,
|
||||
location_address: receiptRecord.location_address,
|
||||
notes: receiptRecord.notes,
|
||||
tax: receiptRecord.tax,
|
||||
created: receiptRecord.created,
|
||||
updated: receiptRecord.updated,
|
||||
itemized_expenses: itemizedExpenses,
|
||||
audited_by: receiptRecord.audited_by || []
|
||||
}
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`Error fetching receipt ${receiptId}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching reimbursements:', err);
|
||||
setError('Failed to load reimbursements. Please try again.');
|
||||
toast.error('Failed to load reimbursements');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
@ -224,7 +249,22 @@ export default function ReimbursementList() {
|
|||
const loadingToast = toast.loading('Loading receipt...');
|
||||
const pb = auth.getPocketBase();
|
||||
|
||||
// Get the receipt record using its ID
|
||||
// Check if we already have the receipt details in our map
|
||||
if (receiptDetailsMap[receiptId]) {
|
||||
// Use the cached receipt details
|
||||
setSelectedReceipt(receiptDetailsMap[receiptId]);
|
||||
|
||||
// Get the file URL using the PocketBase URL and collection info
|
||||
const url = `${pb.baseUrl}/api/files/receipts/${receiptId}/${receiptDetailsMap[receiptId].field}`;
|
||||
setPreviewUrl(url);
|
||||
setPreviewFilename(receiptDetailsMap[receiptId].field);
|
||||
setShowPreview(true);
|
||||
toast.dismiss(loadingToast);
|
||||
toast.success('Receipt loaded successfully');
|
||||
return;
|
||||
}
|
||||
|
||||
// If not in the map, get the receipt record using its ID
|
||||
const receiptRecord = await pb.collection('receipts').getOne(receiptId, {
|
||||
$autoCancel: false
|
||||
});
|
||||
|
@ -235,7 +275,7 @@ export default function ReimbursementList() {
|
|||
? JSON.parse(receiptRecord.itemized_expenses)
|
||||
: receiptRecord.itemized_expenses;
|
||||
|
||||
setSelectedReceipt({
|
||||
const receiptDetails: ReceiptDetails = {
|
||||
id: receiptRecord.id,
|
||||
field: receiptRecord.field,
|
||||
created_by: receiptRecord.created_by,
|
||||
|
@ -248,7 +288,15 @@ export default function ReimbursementList() {
|
|||
audited_by: receiptRecord.audited_by || [],
|
||||
created: receiptRecord.created,
|
||||
updated: receiptRecord.updated
|
||||
});
|
||||
};
|
||||
|
||||
// Add to the map for future use
|
||||
setReceiptDetailsMap(prevMap => ({
|
||||
...prevMap,
|
||||
[receiptId]: receiptDetails
|
||||
}));
|
||||
|
||||
setSelectedReceipt(receiptDetails);
|
||||
|
||||
// Get the file URL using the PocketBase URL and collection info
|
||||
const url = `${pb.baseUrl}/api/files/receipts/${receiptRecord.id}/${receiptRecord.field}`;
|
||||
|
|
|
@ -8,6 +8,7 @@ import { Get } from "../scripts/pocketbase/Get";
|
|||
import { SendLog } from "../scripts/pocketbase/SendLog";
|
||||
import { hasAccess, type OfficerStatus } from "../utils/roleAccess";
|
||||
import { OfficerTypes } from "../schemas/pocketbase/schema";
|
||||
import { initAuthSync } from "../scripts/database/initAuthSync";
|
||||
|
||||
const title = "Dashboard";
|
||||
|
||||
|
@ -26,8 +27,8 @@ const components = Object.fromEntries(
|
|||
);
|
||||
console.log(`Loaded component: ${section.component}`); // Debug log
|
||||
return [section.component, component.default];
|
||||
}),
|
||||
),
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
console.log("Available components:", Object.keys(components)); // Debug log
|
||||
|
@ -63,19 +64,33 @@ console.log("Available components:", Object.keys(components)); // Debug log
|
|||
<!-- User Profile -->
|
||||
<div class="p-6 border-b border-base-200">
|
||||
<!-- Loading State -->
|
||||
<div id="userProfileSkeleton" class="flex items-center gap-4">
|
||||
<div
|
||||
id="userProfileSkeleton"
|
||||
class="flex items-center gap-4"
|
||||
>
|
||||
<div class="avatar flex items-center justify-center">
|
||||
<div class="w-12 h-12 rounded-xl bg-base-300 animate-pulse"></div>
|
||||
<div
|
||||
class="w-12 h-12 rounded-xl bg-base-300 animate-pulse"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<div class="h-6 w-32 bg-base-300 animate-pulse rounded mb-2">
|
||||
<div
|
||||
class="h-6 w-32 bg-base-300 animate-pulse rounded mb-2"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="h-5 w-20 bg-base-300 animate-pulse rounded"
|
||||
>
|
||||
</div>
|
||||
<div class="h-5 w-20 bg-base-300 animate-pulse rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Signed Out State -->
|
||||
<div id="userProfileSignedOut" class="flex items-center gap-4 hidden">
|
||||
<div
|
||||
id="userProfileSignedOut"
|
||||
class="flex items-center gap-4 hidden"
|
||||
>
|
||||
<div class="avatar flex items-center justify-center">
|
||||
<div
|
||||
class="w-12 h-12 rounded-xl bg-base-300 text-base-content/30 flex items-center justify-center"
|
||||
|
@ -84,15 +99,22 @@ console.log("Available components:", Object.keys(components)); // Debug log
|
|||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-lg text-base-content/70">
|
||||
<h3
|
||||
class="font-medium text-lg text-base-content/70"
|
||||
>
|
||||
Signed Out
|
||||
</h3>
|
||||
<div class="badge badge-outline mt-1 opacity-50">Guest</div>
|
||||
<div class="badge badge-outline mt-1 opacity-50">
|
||||
Guest
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actual Profile -->
|
||||
<div id="userProfileSummary" class="flex items-center gap-4 hidden">
|
||||
<div
|
||||
id="userProfileSummary"
|
||||
class="flex items-center gap-4 hidden"
|
||||
>
|
||||
<div class="avatar flex items-center justify-center">
|
||||
<div
|
||||
class="w-12 h-12 rounded-xl bg-[#06659d] text-white ring ring-base-200 ring-offset-base-100 ring-offset-2 inline-flex items-center justify-center"
|
||||
|
@ -104,7 +126,9 @@ console.log("Available components:", Object.keys(components)); // Debug log
|
|||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-lg" id="userName">Loading...</h3>
|
||||
<h3 class="font-medium text-lg" id="userName">
|
||||
Loading...
|
||||
</h3>
|
||||
<div
|
||||
class="badge badge-outline mt-1 border-[#06659d] text-[#06659d]"
|
||||
id="userRole"
|
||||
|
@ -143,41 +167,64 @@ console.log("Available components:", Object.keys(components)); // Debug log
|
|||
<div id="actualMenu" class="hidden">
|
||||
{
|
||||
Object.entries(dashboardConfig.categories).map(
|
||||
([categoryKey, category]: [string, any]) => (
|
||||
([categoryKey, category]: [
|
||||
string,
|
||||
any,
|
||||
]) => (
|
||||
<>
|
||||
<li
|
||||
class={`menu-title font-medium opacity-70 ${
|
||||
category.role && category.role !== "none"
|
||||
category.role &&
|
||||
category.role !== "none"
|
||||
? "hidden"
|
||||
: ""
|
||||
}`}
|
||||
data-role-required={category.role || "none"}
|
||||
data-role-required={
|
||||
category.role || "none"
|
||||
}
|
||||
>
|
||||
<span>{category.title}</span>
|
||||
</li>
|
||||
{category.sections.map((sectionKey: string) => {
|
||||
const section = dashboardConfig.sections[sectionKey];
|
||||
{category.sections.map(
|
||||
(sectionKey: string) => {
|
||||
const section =
|
||||
dashboardConfig
|
||||
.sections[
|
||||
sectionKey
|
||||
];
|
||||
return (
|
||||
<li
|
||||
class={
|
||||
section.role && section.role !== "none"
|
||||
section.role &&
|
||||
section.role !==
|
||||
"none"
|
||||
? "hidden"
|
||||
: ""
|
||||
}
|
||||
data-role-required={section.role}
|
||||
data-role-required={
|
||||
section.role
|
||||
}
|
||||
>
|
||||
<button
|
||||
class={`dashboard-nav-btn gap-4 transition-all duration-200 outline-none focus:outline-none hover:bg-opacity-5 ${section.class || ""}`}
|
||||
data-section={sectionKey}
|
||||
data-section={
|
||||
sectionKey
|
||||
}
|
||||
>
|
||||
<Icon name={section.icon} class="h-5 w-5" />
|
||||
<Icon
|
||||
name={
|
||||
section.icon
|
||||
}
|
||||
class="h-5 w-5"
|
||||
/>
|
||||
{section.title}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
}
|
||||
)}
|
||||
</>
|
||||
),
|
||||
)
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
@ -190,10 +237,15 @@ console.log("Available components:", Object.keys(components)); // Debug log
|
|||
class="flex-1 overflow-x-hidden overflow-y-auto bg-base-200 w-full xl:w-[calc(100%-20rem)]"
|
||||
>
|
||||
<!-- Mobile Header -->
|
||||
<header class="bg-base-100 p-4 shadow-md xl:hidden sticky top-0 z-40">
|
||||
<header
|
||||
class="bg-base-100 p-4 shadow-md xl:hidden sticky top-0 z-40"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<button id="mobileSidebarToggle" class="btn btn-square btn-ghost">
|
||||
<button
|
||||
id="mobileSidebarToggle"
|
||||
class="btn btn-square btn-ghost"
|
||||
>
|
||||
<Icon name="heroicons:bars-3" class="h-6 w-6" />
|
||||
</button>
|
||||
<h1 class="text-xl font-bold">IEEE UCSD</h1>
|
||||
|
@ -205,8 +257,11 @@ console.log("Available components:", Object.keys(components)); // Debug log
|
|||
<div class="p-4 md:p-6 max-w-[1600px] mx-auto">
|
||||
<!-- Loading State -->
|
||||
<div id="pageLoadingState" class="w-full">
|
||||
<div class="flex flex-col items-center justify-center p-4 sm:p-8">
|
||||
<div class="loading loading-spinner loading-lg"></div>
|
||||
<div
|
||||
class="flex flex-col items-center justify-center p-4 sm:p-8"
|
||||
>
|
||||
<div class="loading loading-spinner loading-lg">
|
||||
</div>
|
||||
<p class="mt-4 opacity-70">Loading dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -232,7 +287,9 @@ console.log("Available components:", Object.keys(components)); // Debug log
|
|||
<!-- Not Authenticated State -->
|
||||
<div id="notAuthenticatedState" class="hidden w-full">
|
||||
<div class="card bg-base-100 shadow-xl mx-2 sm:mx-0">
|
||||
<div class="card-body items-center text-center p-4 sm:p-8">
|
||||
<div
|
||||
class="card-body items-center text-center p-4 sm:p-8"
|
||||
>
|
||||
<div class="mb-4 sm:mb-6">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
|
@ -249,9 +306,11 @@ console.log("Available components:", Object.keys(components)); // Debug log
|
|||
<h2 class="card-title text-xl sm:text-2xl mb-2">
|
||||
Sign in to Access Dashboard
|
||||
</h2>
|
||||
<p class="opacity-70 mb-4 sm:mb-6 text-sm sm:text-base">
|
||||
Please sign in with your IEEE UCSD account to access the
|
||||
dashboard.
|
||||
<p
|
||||
class="opacity-70 mb-4 sm:mb-6 text-sm sm:text-base"
|
||||
>
|
||||
Please sign in with your IEEE UCSD account
|
||||
to access the dashboard.
|
||||
</p>
|
||||
<button
|
||||
class="login-button btn btn-primary btn-lg gap-2 w-full sm:w-auto"
|
||||
|
@ -283,12 +342,14 @@ console.log("Available components:", Object.keys(components)); // Debug log
|
|||
// Skip if no component is defined
|
||||
if (!section.component) return null;
|
||||
|
||||
const Component = components[section.component];
|
||||
const Component =
|
||||
components[section.component];
|
||||
return (
|
||||
<div
|
||||
id={`${sectionKey}Section`}
|
||||
class={`dashboard-section hidden ${
|
||||
section.role && section.role !== "none"
|
||||
section.role &&
|
||||
section.role !== "none"
|
||||
? "role-restricted"
|
||||
: ""
|
||||
}`}
|
||||
|
@ -297,7 +358,7 @@ console.log("Available components:", Object.keys(components)); // Debug log
|
|||
<Component />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
@ -313,6 +374,7 @@ console.log("Available components:", Object.keys(components)); // Debug log
|
|||
import { SendLog } from "../scripts/pocketbase/SendLog";
|
||||
import { hasAccess, type OfficerStatus } from "../utils/roleAccess";
|
||||
import { OfficerTypes } from "../schemas/pocketbase/schema";
|
||||
import { initAuthSync } from "../scripts/database/initAuthSync";
|
||||
|
||||
const auth = Authentication.getInstance();
|
||||
const get = Get.getInstance();
|
||||
|
@ -322,7 +384,7 @@ console.log("Available components:", Object.keys(components)); // Debug log
|
|||
const pageLoadingState = document.getElementById("pageLoadingState");
|
||||
const pageErrorState = document.getElementById("pageErrorState");
|
||||
const notAuthenticatedState = document.getElementById(
|
||||
"notAuthenticatedState",
|
||||
"notAuthenticatedState"
|
||||
);
|
||||
const mainContent = document.getElementById("mainContent");
|
||||
const sidebar = document.querySelector("aside");
|
||||
|
@ -337,7 +399,9 @@ console.log("Available components:", Object.keys(components)); // Debug log
|
|||
// Special handling for sponsor role
|
||||
if (officerStatus === "sponsor") {
|
||||
// Hide all sections first
|
||||
document.querySelectorAll("[data-role-required]").forEach((element) => {
|
||||
document
|
||||
.querySelectorAll("[data-role-required]")
|
||||
.forEach((element) => {
|
||||
element.classList.add("hidden");
|
||||
});
|
||||
|
||||
|
@ -353,7 +417,7 @@ console.log("Available components:", Object.keys(components)); // Debug log
|
|||
// For non-sponsor roles, handle normally
|
||||
document.querySelectorAll("[data-role-required]").forEach((element) => {
|
||||
const requiredRole = element.getAttribute(
|
||||
"data-role-required",
|
||||
"data-role-required"
|
||||
) as OfficerStatus;
|
||||
|
||||
// Skip elements that don't have a role requirement
|
||||
|
@ -454,7 +518,7 @@ console.log("Available components:", Object.keys(components)); // Debug log
|
|||
"",
|
||||
{
|
||||
fields: ["id", "type"],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (officerRecords && officerRecords.items.length > 0) {
|
||||
|
@ -509,7 +573,8 @@ console.log("Available components:", Object.keys(components)); // Debug log
|
|||
|
||||
if (userName) userName.textContent = fallbackValues.name;
|
||||
if (userRole) userRole.textContent = fallbackValues.role;
|
||||
if (userInitials) userInitials.textContent = fallbackValues.initials;
|
||||
if (userInitials)
|
||||
userInitials.textContent = fallbackValues.initials;
|
||||
|
||||
updateSectionVisibility("" as OfficerStatus);
|
||||
}
|
||||
|
@ -541,52 +606,62 @@ console.log("Available components:", Object.keys(components)); // Debug log
|
|||
mobileSidebarToggle.addEventListener("click", toggleSidebar);
|
||||
}
|
||||
|
||||
// Initialize page
|
||||
// Function to initialize the page
|
||||
const initializePage = async () => {
|
||||
try {
|
||||
if (pageLoadingState) pageLoadingState.classList.remove("hidden");
|
||||
if (pageErrorState) pageErrorState.classList.add("hidden");
|
||||
if (notAuthenticatedState) notAuthenticatedState.classList.add("hidden");
|
||||
// Initialize auth sync for IndexedDB
|
||||
await initAuthSync();
|
||||
|
||||
// Show loading states
|
||||
const userProfileSkeleton = document.getElementById(
|
||||
"userProfileSkeleton",
|
||||
);
|
||||
const userProfileSignedOut = document.getElementById(
|
||||
"userProfileSignedOut",
|
||||
);
|
||||
const userProfileSummary = document.getElementById("userProfileSummary");
|
||||
const menuLoadingSkeleton = document.getElementById(
|
||||
"menuLoadingSkeleton",
|
||||
);
|
||||
const actualMenu = document.getElementById("actualMenu");
|
||||
|
||||
if (userProfileSkeleton) userProfileSkeleton.classList.remove("hidden");
|
||||
if (userProfileSummary) userProfileSummary.classList.add("hidden");
|
||||
if (userProfileSignedOut) userProfileSignedOut.classList.add("hidden");
|
||||
if (menuLoadingSkeleton) menuLoadingSkeleton.classList.remove("hidden");
|
||||
if (actualMenu) actualMenu.classList.add("hidden");
|
||||
|
||||
// Check authentication
|
||||
// Check if user is authenticated
|
||||
if (!auth.isAuthenticated()) {
|
||||
console.log("User not authenticated");
|
||||
if (pageLoadingState) pageLoadingState.classList.add("hidden");
|
||||
if (notAuthenticatedState)
|
||||
notAuthenticatedState.classList.remove("hidden");
|
||||
if (userProfileSkeleton) userProfileSkeleton.classList.add("hidden");
|
||||
if (userProfileSignedOut)
|
||||
userProfileSignedOut.classList.remove("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
if (pageLoadingState) pageLoadingState.classList.remove("hidden");
|
||||
if (pageErrorState) pageErrorState.classList.add("hidden");
|
||||
if (notAuthenticatedState)
|
||||
notAuthenticatedState.classList.add("hidden");
|
||||
|
||||
// Show loading states
|
||||
const userProfileSkeleton = document.getElementById(
|
||||
"userProfileSkeleton"
|
||||
);
|
||||
const userProfileSignedOut = document.getElementById(
|
||||
"userProfileSignedOut"
|
||||
);
|
||||
const userProfileSummary =
|
||||
document.getElementById("userProfileSummary");
|
||||
const menuLoadingSkeleton = document.getElementById(
|
||||
"menuLoadingSkeleton"
|
||||
);
|
||||
const actualMenu = document.getElementById("actualMenu");
|
||||
|
||||
if (userProfileSkeleton)
|
||||
userProfileSkeleton.classList.remove("hidden");
|
||||
if (userProfileSummary) userProfileSummary.classList.add("hidden");
|
||||
if (userProfileSignedOut)
|
||||
userProfileSignedOut.classList.add("hidden");
|
||||
if (menuLoadingSkeleton)
|
||||
menuLoadingSkeleton.classList.remove("hidden");
|
||||
if (actualMenu) actualMenu.classList.add("hidden");
|
||||
|
||||
const user = auth.getCurrentUser();
|
||||
await updateUserProfile(user);
|
||||
|
||||
// Show actual profile and hide skeleton
|
||||
if (userProfileSkeleton) userProfileSkeleton.classList.add("hidden");
|
||||
if (userProfileSummary) userProfileSummary.classList.remove("hidden");
|
||||
if (userProfileSkeleton)
|
||||
userProfileSkeleton.classList.add("hidden");
|
||||
if (userProfileSummary)
|
||||
userProfileSummary.classList.remove("hidden");
|
||||
|
||||
// Hide all sections first
|
||||
document.querySelectorAll(".dashboard-section").forEach((section) => {
|
||||
document
|
||||
.querySelectorAll(".dashboard-section")
|
||||
.forEach((section) => {
|
||||
section.classList.add("hidden");
|
||||
});
|
||||
|
||||
|
@ -603,7 +678,7 @@ console.log("Available components:", Object.keys(components)); // Debug log
|
|||
"",
|
||||
{
|
||||
fields: ["id", "type"],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (officerRecords && officerRecords.items.length > 0) {
|
||||
|
@ -647,18 +722,24 @@ console.log("Available components:", Object.keys(components)); // Debug log
|
|||
let defaultButton;
|
||||
|
||||
if (officerStatus === "sponsor") {
|
||||
defaultSection = document.getElementById("sponsorDashboardSection");
|
||||
defaultSection = document.getElementById(
|
||||
"sponsorDashboardSection"
|
||||
);
|
||||
defaultButton = document.querySelector(
|
||||
'[data-section="sponsorDashboard"]',
|
||||
'[data-section="sponsorDashboard"]'
|
||||
);
|
||||
} else if (officerStatus === "administrator") {
|
||||
defaultSection = document.getElementById("adminDashboardSection");
|
||||
defaultSection = document.getElementById(
|
||||
"adminDashboardSection"
|
||||
);
|
||||
defaultButton = document.querySelector(
|
||||
'[data-section="adminDashboard"]',
|
||||
'[data-section="adminDashboard"]'
|
||||
);
|
||||
} else {
|
||||
defaultSection = document.getElementById("profileSection");
|
||||
defaultButton = document.querySelector('[data-section="profile"]');
|
||||
defaultButton = document.querySelector(
|
||||
'[data-section="profile"]'
|
||||
);
|
||||
}
|
||||
|
||||
if (defaultSection) {
|
||||
|
@ -672,7 +753,8 @@ console.log("Available components:", Object.keys(components)); // Debug log
|
|||
handleNavigation();
|
||||
|
||||
// Show actual menu and hide skeleton
|
||||
if (menuLoadingSkeleton) menuLoadingSkeleton.classList.add("hidden");
|
||||
if (menuLoadingSkeleton)
|
||||
menuLoadingSkeleton.classList.add("hidden");
|
||||
if (actualMenu) actualMenu.classList.remove("hidden");
|
||||
|
||||
// Show main content and hide loading
|
||||
|
@ -693,7 +775,8 @@ console.log("Available components:", Object.keys(components)); // Debug log
|
|||
.querySelector(".login-button")
|
||||
?.addEventListener("click", async () => {
|
||||
try {
|
||||
if (pageLoadingState) pageLoadingState.classList.remove("hidden");
|
||||
if (pageLoadingState)
|
||||
pageLoadingState.classList.remove("hidden");
|
||||
if (notAuthenticatedState)
|
||||
notAuthenticatedState.classList.add("hidden");
|
||||
await auth.login();
|
||||
|
|
|
@ -63,7 +63,7 @@ export class RedirectHandler {
|
|||
console.log("Auth successful:", authData);
|
||||
this.contentEl.innerHTML = `
|
||||
<p class="text-3xl font-bold text-green-500 mb-4">Authentication Successful!</p>
|
||||
<p class="text-2xl font-medium">Redirecting to store...</p>
|
||||
<p class="text-2xl font-medium">Initializing your data...</p>
|
||||
<div class="mt-4">
|
||||
<div class="loading loading-spinner loading-lg"></div>
|
||||
</div>
|
||||
|
@ -75,11 +75,14 @@ export class RedirectHandler {
|
|||
last_login: new Date().toISOString(),
|
||||
});
|
||||
|
||||
// Initialize data sync
|
||||
await this.initializeDataSync();
|
||||
|
||||
// Clean up and redirect
|
||||
localStorage.removeItem("provider");
|
||||
window.location.href = "/dashboard";
|
||||
} catch (err) {
|
||||
console.error("Failed to update last login:", err);
|
||||
console.error("Failed to update last login or sync data:", err);
|
||||
// Still redirect even if last_login update fails
|
||||
localStorage.removeItem("provider");
|
||||
window.location.href = "/dashboard";
|
||||
|
@ -89,4 +92,27 @@ export class RedirectHandler {
|
|||
this.showError(`Failed to complete authentication: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize data synchronization after successful authentication
|
||||
*/
|
||||
private async initializeDataSync(): Promise<void> {
|
||||
try {
|
||||
// Dynamically import the AuthSyncService to avoid circular dependencies
|
||||
const { AuthSyncService } = await import('../database/AuthSyncService');
|
||||
|
||||
// Get the instance and trigger a full sync
|
||||
const authSync = AuthSyncService.getInstance();
|
||||
const syncResult = await authSync.handleLogin();
|
||||
|
||||
if (syncResult) {
|
||||
console.log('Initial data sync completed successfully');
|
||||
} else {
|
||||
console.warn('Initial data sync completed with issues');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize data sync:', error);
|
||||
// Continue with login process even if sync fails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
284
src/scripts/database/AuthSyncService.ts
Normal file
284
src/scripts/database/AuthSyncService.ts
Normal file
|
@ -0,0 +1,284 @@
|
|||
import { Authentication } from '../pocketbase/Authentication';
|
||||
import { DataSyncService } from './DataSyncService';
|
||||
import { DexieService } from './DexieService';
|
||||
import { Collections } from '../../schemas/pocketbase/schema';
|
||||
import { SendLog } from '../pocketbase/SendLog';
|
||||
|
||||
/**
|
||||
* Service to handle data synchronization during authentication flows
|
||||
*/
|
||||
export class AuthSyncService {
|
||||
private static instance: AuthSyncService;
|
||||
private auth: Authentication;
|
||||
private dataSync: DataSyncService;
|
||||
private dexieService: DexieService;
|
||||
private logger: SendLog;
|
||||
private isSyncing: boolean = false;
|
||||
private syncErrors: Record<string, Error> = {};
|
||||
private syncQueue: string[] = [];
|
||||
private syncPromise: Promise<void> | null = null;
|
||||
|
||||
// Collections to sync on login
|
||||
private readonly collectionsToSync = [
|
||||
Collections.USERS,
|
||||
Collections.EVENTS,
|
||||
Collections.EVENT_REQUESTS,
|
||||
Collections.LOGS,
|
||||
Collections.OFFICERS,
|
||||
Collections.REIMBURSEMENTS,
|
||||
Collections.RECEIPTS,
|
||||
Collections.SPONSORS
|
||||
];
|
||||
|
||||
private constructor() {
|
||||
this.auth = Authentication.getInstance();
|
||||
this.dataSync = DataSyncService.getInstance();
|
||||
this.dexieService = DexieService.getInstance();
|
||||
this.logger = SendLog.getInstance();
|
||||
|
||||
// Listen for auth state changes
|
||||
this.auth.onAuthStateChange(this.handleAuthStateChange.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the singleton instance of AuthSyncService
|
||||
*/
|
||||
public static getInstance(): AuthSyncService {
|
||||
if (!AuthSyncService.instance) {
|
||||
AuthSyncService.instance = new AuthSyncService();
|
||||
}
|
||||
return AuthSyncService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle authentication state changes
|
||||
*/
|
||||
private async handleAuthStateChange(isAuthenticated: boolean): Promise<void> {
|
||||
if (isAuthenticated) {
|
||||
// User just logged in
|
||||
await this.handleLogin();
|
||||
} else {
|
||||
// User just logged out
|
||||
await this.handleLogout();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle login by syncing user data
|
||||
*/
|
||||
public async handleLogin(): Promise<boolean> {
|
||||
if (this.isSyncing) {
|
||||
console.log('Sync already in progress, queueing login sync');
|
||||
if (this.syncPromise) {
|
||||
this.syncPromise = this.syncPromise.then(() => this.performLoginSync());
|
||||
} else {
|
||||
this.syncPromise = this.performLoginSync();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
this.syncPromise = this.performLoginSync();
|
||||
return this.syncPromise.then(() => Object.keys(this.syncErrors).length === 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform the actual login sync
|
||||
*/
|
||||
private async performLoginSync(): Promise<void> {
|
||||
if (!this.auth.isAuthenticated()) {
|
||||
console.log('Not authenticated, skipping login sync');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSyncing = true;
|
||||
this.syncErrors = {};
|
||||
|
||||
try {
|
||||
console.log('Starting login sync process...');
|
||||
|
||||
// Display sync notification if in browser environment
|
||||
this.showSyncNotification('Syncing your data...');
|
||||
|
||||
// Sync user-specific data first
|
||||
const userId = this.auth.getUserId();
|
||||
if (userId) {
|
||||
// First sync the current user's data
|
||||
await this.dataSync.syncCollection(Collections.USERS, `id = "${userId}"`);
|
||||
|
||||
// Log the sync operation
|
||||
await this.logger.send('login', 'auth', 'User data synchronized on login');
|
||||
}
|
||||
|
||||
// Sync all collections in parallel with conflict resolution
|
||||
await Promise.all(
|
||||
this.collectionsToSync.map(async (collection) => {
|
||||
try {
|
||||
await this.dataSync.syncCollection(collection);
|
||||
console.log(`Successfully synced ${collection}`);
|
||||
} catch (error) {
|
||||
console.error(`Error syncing ${collection}:`, error);
|
||||
this.syncErrors[collection] = error as Error;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Verify sync was successful
|
||||
const syncVerification = await this.verifySyncSuccess();
|
||||
|
||||
if (syncVerification.success) {
|
||||
console.log('Login sync completed successfully');
|
||||
this.showSyncNotification('Data sync complete!', 'success');
|
||||
} else {
|
||||
console.warn('Login sync completed with issues:', syncVerification.errors);
|
||||
this.showSyncNotification('Some data could not be synced', 'warning');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error during login sync:', error);
|
||||
this.showSyncNotification('Failed to sync data', 'error');
|
||||
} finally {
|
||||
this.isSyncing = false;
|
||||
|
||||
// Process any queued sync operations
|
||||
if (this.syncQueue.length > 0) {
|
||||
const nextSync = this.syncQueue.shift();
|
||||
if (nextSync === 'login') {
|
||||
this.handleLogin();
|
||||
} else if (nextSync === 'logout') {
|
||||
this.handleLogout();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle logout by clearing user data
|
||||
*/
|
||||
public async handleLogout(): Promise<boolean> {
|
||||
if (this.isSyncing) {
|
||||
console.log('Sync already in progress, queueing logout cleanup');
|
||||
this.syncQueue.push('logout');
|
||||
return true;
|
||||
}
|
||||
|
||||
this.isSyncing = true;
|
||||
|
||||
try {
|
||||
console.log('Starting logout cleanup process...');
|
||||
|
||||
// Ensure any pending changes are synced before logout
|
||||
await this.syncPendingChanges();
|
||||
|
||||
// Clear all data from IndexedDB
|
||||
await this.dexieService.clearAllData();
|
||||
|
||||
console.log('Logout cleanup completed successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error during logout cleanup:', error);
|
||||
return false;
|
||||
} finally {
|
||||
this.isSyncing = false;
|
||||
|
||||
// Process any queued sync operations
|
||||
if (this.syncQueue.length > 0) {
|
||||
const nextSync = this.syncQueue.shift();
|
||||
if (nextSync === 'login') {
|
||||
this.handleLogin();
|
||||
} else if (nextSync === 'logout') {
|
||||
this.handleLogout();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync any pending changes before logout
|
||||
*/
|
||||
private async syncPendingChanges(): Promise<void> {
|
||||
// This would be implemented if we had offline capabilities
|
||||
// For now, we just log that we would sync pending changes
|
||||
console.log('Checking for pending changes to sync before logout...');
|
||||
// In a real implementation, this would sync any offline changes
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that sync was successful by checking data in IndexedDB
|
||||
*/
|
||||
private async verifySyncSuccess(): Promise<{ success: boolean; errors: Record<string, string> }> {
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
// Check each collection that had errors
|
||||
for (const [collection, error] of Object.entries(this.syncErrors)) {
|
||||
errors[collection] = error.message;
|
||||
}
|
||||
|
||||
// Check if user data was synced properly
|
||||
const userId = this.auth.getUserId();
|
||||
if (userId) {
|
||||
try {
|
||||
const user = await this.dataSync.getItem(Collections.USERS, userId, false);
|
||||
if (!user) {
|
||||
errors['user_verification'] = 'User data not found in IndexedDB after sync';
|
||||
}
|
||||
} catch (error) {
|
||||
errors['user_verification'] = `Error verifying user data: ${(error as Error).message}`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: Object.keys(errors).length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Show a notification to the user about sync status
|
||||
*/
|
||||
private showSyncNotification(message: string, type: 'info' | 'success' | 'warning' | 'error' = 'info'): void {
|
||||
// Only run in browser environment
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
// Check if toast function exists (from react-hot-toast or similar)
|
||||
if (typeof window.toast === 'function') {
|
||||
window.toast(message, { type });
|
||||
} else {
|
||||
// Fallback to console
|
||||
console.log(`[${type.toUpperCase()}] ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force a sync of all collections
|
||||
*/
|
||||
public async forceSyncAll(): Promise<boolean> {
|
||||
if (this.isSyncing) {
|
||||
console.log('Sync already in progress, queueing full sync');
|
||||
this.syncQueue.push('login'); // Reuse login sync logic
|
||||
return true;
|
||||
}
|
||||
|
||||
return this.handleLogin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a sync is currently in progress
|
||||
*/
|
||||
public isSyncInProgress(): boolean {
|
||||
return this.isSyncing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get any errors from the last sync operation
|
||||
*/
|
||||
public getSyncErrors(): Record<string, Error> {
|
||||
return { ...this.syncErrors };
|
||||
}
|
||||
}
|
||||
|
||||
// Add toast type to window for TypeScript
|
||||
declare global {
|
||||
interface Window {
|
||||
toast?: (message: string, options?: { type: 'info' | 'success' | 'warning' | 'error' }) => void;
|
||||
}
|
||||
}
|
531
src/scripts/database/DataSyncService.ts
Normal file
531
src/scripts/database/DataSyncService.ts
Normal file
|
@ -0,0 +1,531 @@
|
|||
import { DexieService } from './DexieService';
|
||||
import { Get } from '../pocketbase/Get';
|
||||
import { Update } from '../pocketbase/Update';
|
||||
import { Authentication } from '../pocketbase/Authentication';
|
||||
import { Collections, type BaseRecord } from '../../schemas/pocketbase/schema';
|
||||
import type Dexie from 'dexie';
|
||||
|
||||
// Interface for tracking offline changes
|
||||
interface OfflineChange {
|
||||
id: string;
|
||||
collection: string;
|
||||
recordId: string;
|
||||
operation: 'create' | 'update' | 'delete';
|
||||
data?: any;
|
||||
timestamp: number;
|
||||
synced: boolean;
|
||||
syncAttempts: number;
|
||||
}
|
||||
|
||||
export class DataSyncService {
|
||||
private static instance: DataSyncService;
|
||||
private dexieService: DexieService;
|
||||
private get: Get;
|
||||
private update: Update;
|
||||
private auth: Authentication;
|
||||
private syncInProgress: Record<string, boolean> = {};
|
||||
private offlineMode: boolean = false;
|
||||
private offlineChangesTable: Dexie.Table<OfflineChange, string> | null = null;
|
||||
|
||||
private constructor() {
|
||||
this.dexieService = DexieService.getInstance();
|
||||
this.get = Get.getInstance();
|
||||
this.update = Update.getInstance();
|
||||
this.auth = Authentication.getInstance();
|
||||
|
||||
// Initialize offline changes table
|
||||
this.initOfflineChangesTable();
|
||||
|
||||
// Check for network status
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('online', this.handleOnline.bind(this));
|
||||
window.addEventListener('offline', this.handleOffline.bind(this));
|
||||
this.offlineMode = !navigator.onLine;
|
||||
}
|
||||
}
|
||||
|
||||
public static getInstance(): DataSyncService {
|
||||
if (!DataSyncService.instance) {
|
||||
DataSyncService.instance = new DataSyncService();
|
||||
}
|
||||
return DataSyncService.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the offline changes table
|
||||
*/
|
||||
private initOfflineChangesTable(): void {
|
||||
try {
|
||||
const db = this.dexieService.getDB();
|
||||
// Check if the table exists in the schema
|
||||
if ('offlineChanges' in db) {
|
||||
this.offlineChangesTable = db.offlineChanges as Dexie.Table<OfflineChange, string>;
|
||||
} else {
|
||||
console.warn('Offline changes table not found in schema');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error initializing offline changes table:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle device coming online
|
||||
*/
|
||||
private async handleOnline(): Promise<void> {
|
||||
console.log('Device is online, syncing pending changes...');
|
||||
this.offlineMode = false;
|
||||
await this.syncOfflineChanges();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle device going offline
|
||||
*/
|
||||
private handleOffline(): void {
|
||||
console.log('Device is offline, enabling offline mode...');
|
||||
this.offlineMode = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a specific collection from PocketBase to IndexedDB
|
||||
*/
|
||||
public async syncCollection<T extends BaseRecord>(
|
||||
collection: string,
|
||||
filter: string = '',
|
||||
sort: string = '-created',
|
||||
expand: Record<string, any> | string[] | string = {}
|
||||
): Promise<T[]> {
|
||||
// Prevent multiple syncs of the same collection at the same time
|
||||
if (this.syncInProgress[collection]) {
|
||||
console.log(`Sync already in progress for ${collection}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
this.syncInProgress[collection] = true;
|
||||
|
||||
try {
|
||||
// Check if we're authenticated
|
||||
if (!this.auth.isAuthenticated()) {
|
||||
console.log(`Not authenticated, skipping sync for ${collection}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check if we're offline
|
||||
if (this.offlineMode) {
|
||||
console.log(`Device is offline, using cached data for ${collection}`);
|
||||
const db = this.dexieService.getDB();
|
||||
const table = this.getTableForCollection(collection);
|
||||
return table ? (await table.toArray() as T[]) : [];
|
||||
}
|
||||
|
||||
console.log(`Syncing ${collection}...`);
|
||||
|
||||
// Normalize expand parameter to be an array of strings
|
||||
let normalizedExpand: string[] | undefined;
|
||||
|
||||
if (expand) {
|
||||
if (typeof expand === 'string') {
|
||||
// If expand is a string, convert it to an array
|
||||
normalizedExpand = [expand];
|
||||
} else if (Array.isArray(expand)) {
|
||||
// If expand is already an array, use it as is
|
||||
normalizedExpand = expand;
|
||||
} else if (typeof expand === 'object') {
|
||||
// If expand is an object, extract the keys
|
||||
normalizedExpand = Object.keys(expand);
|
||||
}
|
||||
}
|
||||
|
||||
// Get data from PocketBase
|
||||
const items = await this.get.getAll<T>(collection, filter, sort, {
|
||||
expand: normalizedExpand
|
||||
});
|
||||
console.log(`Fetched ${items.length} items from ${collection}`);
|
||||
|
||||
// Get the database table
|
||||
const db = this.dexieService.getDB();
|
||||
const table = this.getTableForCollection(collection);
|
||||
|
||||
if (!table) {
|
||||
console.error(`No table found for collection ${collection}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get existing items to handle conflicts
|
||||
const existingItems = await table.toArray();
|
||||
const existingItemsMap = new Map(existingItems.map(item => [item.id, item]));
|
||||
|
||||
// Handle conflicts and merge changes
|
||||
const itemsToStore = await Promise.all(items.map(async (item) => {
|
||||
const existingItem = existingItemsMap.get(item.id);
|
||||
|
||||
if (existingItem) {
|
||||
// Check for conflicts (local changes vs server changes)
|
||||
const resolvedItem = await this.resolveConflict(collection, existingItem, item);
|
||||
return resolvedItem;
|
||||
}
|
||||
|
||||
return item;
|
||||
}));
|
||||
|
||||
// Store in IndexedDB
|
||||
await table.bulkPut(itemsToStore);
|
||||
|
||||
// Update last sync timestamp
|
||||
await this.dexieService.updateLastSync(collection);
|
||||
|
||||
return itemsToStore as T[];
|
||||
} catch (error) {
|
||||
console.error(`Error syncing ${collection}:`, error);
|
||||
throw error;
|
||||
} finally {
|
||||
this.syncInProgress[collection] = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve conflicts between local and server data
|
||||
*/
|
||||
private async resolveConflict<T extends BaseRecord>(
|
||||
collection: string,
|
||||
localItem: T,
|
||||
serverItem: T
|
||||
): Promise<T> {
|
||||
// Check if there are pending offline changes for this item
|
||||
const pendingChanges = await this.getPendingChangesForRecord(collection, localItem.id);
|
||||
|
||||
if (pendingChanges.length > 0) {
|
||||
console.log(`Found ${pendingChanges.length} pending changes for ${collection}:${localItem.id}`);
|
||||
|
||||
// Server-wins strategy by default, but preserve local changes that haven't been synced
|
||||
const mergedItem = { ...serverItem };
|
||||
|
||||
// Apply pending changes on top of server data
|
||||
for (const change of pendingChanges) {
|
||||
if (change.operation === 'update' && change.data) {
|
||||
// Apply each field change individually
|
||||
Object.entries(change.data).forEach(([key, value]) => {
|
||||
(mergedItem as any)[key] = value;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return mergedItem;
|
||||
}
|
||||
|
||||
// No pending changes, use server data
|
||||
return serverItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pending changes for a specific record
|
||||
*/
|
||||
private async getPendingChangesForRecord(collection: string, recordId: string): Promise<OfflineChange[]> {
|
||||
if (!this.offlineChangesTable) return [];
|
||||
|
||||
try {
|
||||
return await this.offlineChangesTable
|
||||
.where('collection')
|
||||
.equals(collection)
|
||||
.and(item => item.recordId === recordId && !item.synced)
|
||||
.toArray();
|
||||
} catch (error) {
|
||||
console.error(`Error getting pending changes for ${collection}:${recordId}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync all pending offline changes
|
||||
*/
|
||||
public async syncOfflineChanges(): Promise<boolean> {
|
||||
if (!this.offlineChangesTable || this.offlineMode) return false;
|
||||
|
||||
try {
|
||||
// Get all unsynced changes
|
||||
const pendingChanges = await this.offlineChangesTable
|
||||
.where('synced')
|
||||
.equals(0) // Use 0 instead of false for indexable type
|
||||
.toArray();
|
||||
|
||||
if (pendingChanges.length === 0) {
|
||||
console.log('No pending offline changes to sync');
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log(`Syncing ${pendingChanges.length} offline changes...`);
|
||||
|
||||
// Group changes by collection for more efficient processing
|
||||
const changesByCollection = pendingChanges.reduce((groups, change) => {
|
||||
const key = change.collection;
|
||||
if (!groups[key]) {
|
||||
groups[key] = [];
|
||||
}
|
||||
groups[key].push(change);
|
||||
return groups;
|
||||
}, {} as Record<string, OfflineChange[]>);
|
||||
|
||||
// Process each collection's changes
|
||||
for (const [collection, changes] of Object.entries(changesByCollection)) {
|
||||
// First sync the collection to get latest data
|
||||
await this.syncCollection(collection);
|
||||
|
||||
// Then apply each change
|
||||
for (const change of changes) {
|
||||
try {
|
||||
if (change.operation === 'update' && change.data) {
|
||||
await this.update.updateFields(collection, change.recordId, change.data);
|
||||
|
||||
// Mark as synced
|
||||
await this.offlineChangesTable.update(change.id, {
|
||||
synced: true,
|
||||
syncAttempts: change.syncAttempts + 1
|
||||
});
|
||||
}
|
||||
// Add support for create and delete operations as needed
|
||||
} catch (error) {
|
||||
console.error(`Error syncing change ${change.id}:`, error);
|
||||
|
||||
// Increment sync attempts
|
||||
await this.offlineChangesTable.update(change.id, {
|
||||
syncAttempts: change.syncAttempts + 1
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sync again to ensure we have the latest data
|
||||
await this.syncCollection(collection);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error syncing offline changes:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record an offline change
|
||||
*/
|
||||
public async recordOfflineChange(
|
||||
collection: string,
|
||||
recordId: string,
|
||||
operation: 'create' | 'update' | 'delete',
|
||||
data?: any
|
||||
): Promise<string | null> {
|
||||
if (!this.offlineChangesTable) return null;
|
||||
|
||||
try {
|
||||
const change: Omit<OfflineChange, 'id'> = {
|
||||
collection,
|
||||
recordId,
|
||||
operation,
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
synced: false,
|
||||
syncAttempts: 0
|
||||
};
|
||||
|
||||
const id = await this.offlineChangesTable.add(change as OfflineChange);
|
||||
console.log(`Recorded offline change: ${operation} on ${collection}:${recordId}`);
|
||||
|
||||
// Try to sync immediately if we're online
|
||||
if (!this.offlineMode) {
|
||||
this.syncOfflineChanges().catch(err => {
|
||||
console.error('Error syncing after recording change:', err);
|
||||
});
|
||||
}
|
||||
|
||||
return id;
|
||||
} catch (error) {
|
||||
console.error(`Error recording offline change for ${collection}:${recordId}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get data from IndexedDB, syncing from PocketBase if needed
|
||||
*/
|
||||
public async getData<T extends BaseRecord>(
|
||||
collection: string,
|
||||
forceSync: boolean = false,
|
||||
filter: string = '',
|
||||
sort: string = '-created',
|
||||
expand: Record<string, any> | string[] | string = {}
|
||||
): Promise<T[]> {
|
||||
const db = this.dexieService.getDB();
|
||||
const table = this.getTableForCollection(collection);
|
||||
|
||||
if (!table) {
|
||||
console.error(`No table found for collection ${collection}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Check if we need to sync
|
||||
const lastSync = await this.dexieService.getLastSync(collection);
|
||||
const now = Date.now();
|
||||
const syncThreshold = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
if (!this.offlineMode && (forceSync || (now - lastSync > syncThreshold))) {
|
||||
try {
|
||||
await this.syncCollection<T>(collection, filter, sort, expand);
|
||||
} catch (error) {
|
||||
console.error(`Error syncing ${collection}, using cached data:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Get data from IndexedDB
|
||||
let data = await table.toArray();
|
||||
|
||||
// Apply filter if provided
|
||||
if (filter) {
|
||||
// This is a simple implementation - in a real app, you'd want to parse the filter string
|
||||
// and apply it properly. This is just a basic example.
|
||||
data = data.filter((item: any) => {
|
||||
// Split filter by logical operators
|
||||
const conditions = filter.split(' && ');
|
||||
return conditions.every(condition => {
|
||||
// Parse condition (very basic implementation)
|
||||
if (condition.includes('=')) {
|
||||
const [field, value] = condition.split('=');
|
||||
const cleanValue = value.replace(/"/g, '');
|
||||
return item[field] === cleanValue;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Apply sort if provided
|
||||
if (sort) {
|
||||
const isDesc = sort.startsWith('-');
|
||||
const field = isDesc ? sort.substring(1) : sort;
|
||||
|
||||
data.sort((a: any, b: any) => {
|
||||
if (a[field] < b[field]) return isDesc ? 1 : -1;
|
||||
if (a[field] > b[field]) return isDesc ? -1 : 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
return data as T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single item by ID
|
||||
*/
|
||||
public async getItem<T extends BaseRecord>(collection: string, id: string, forceSync: boolean = false): Promise<T | undefined> {
|
||||
const db = this.dexieService.getDB();
|
||||
const table = this.getTableForCollection(collection);
|
||||
|
||||
if (!table) {
|
||||
console.error(`No table found for collection ${collection}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Try to get from IndexedDB first
|
||||
let item = await table.get(id) as T | undefined;
|
||||
|
||||
// If not found or force sync, try to get from PocketBase
|
||||
if ((!item || forceSync) && !this.offlineMode) {
|
||||
try {
|
||||
const pbItem = await this.get.getOne<T>(collection, id);
|
||||
if (pbItem) {
|
||||
await table.put(pbItem);
|
||||
item = pbItem;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error fetching ${collection} item ${id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an item and handle offline changes
|
||||
*/
|
||||
public async updateItem<T extends BaseRecord>(
|
||||
collection: string,
|
||||
id: string,
|
||||
data: Partial<T>
|
||||
): Promise<T | undefined> {
|
||||
const table = this.getTableForCollection(collection);
|
||||
|
||||
if (!table) {
|
||||
console.error(`No table found for collection ${collection}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Get the current item
|
||||
const currentItem = await table.get(id) as T | undefined;
|
||||
if (!currentItem) {
|
||||
console.error(`Item ${id} not found in ${collection}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Update the item in IndexedDB
|
||||
const updatedItem = { ...currentItem, ...data, updated: new Date().toISOString() };
|
||||
await table.put(updatedItem);
|
||||
|
||||
// If offline, record the change for later sync
|
||||
if (this.offlineMode) {
|
||||
await this.recordOfflineChange(collection, id, 'update', data);
|
||||
return updatedItem;
|
||||
}
|
||||
|
||||
// If online, update in PocketBase
|
||||
try {
|
||||
const result = await this.update.updateFields(collection, id, data);
|
||||
return result as T;
|
||||
} catch (error) {
|
||||
console.error(`Error updating ${collection} item ${id}:`, error);
|
||||
|
||||
// Record as offline change to retry later
|
||||
await this.recordOfflineChange(collection, id, 'update', data);
|
||||
return updatedItem;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all cached data
|
||||
*/
|
||||
public async clearCache(): Promise<void> {
|
||||
await this.dexieService.clearAllData();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if device is in offline mode
|
||||
*/
|
||||
public isOffline(): boolean {
|
||||
return this.offlineMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate Dexie table for a collection
|
||||
*/
|
||||
private getTableForCollection(collection: string): Dexie.Table<any, string> | null {
|
||||
const db = this.dexieService.getDB();
|
||||
|
||||
switch (collection) {
|
||||
case Collections.USERS:
|
||||
return db.users;
|
||||
case Collections.EVENTS:
|
||||
return db.events;
|
||||
case Collections.EVENT_REQUESTS:
|
||||
return db.eventRequests;
|
||||
case Collections.LOGS:
|
||||
return db.logs;
|
||||
case Collections.OFFICERS:
|
||||
return db.officers;
|
||||
case Collections.REIMBURSEMENTS:
|
||||
return db.reimbursements;
|
||||
case Collections.RECEIPTS:
|
||||
return db.receipts;
|
||||
case Collections.SPONSORS:
|
||||
return db.sponsors;
|
||||
default:
|
||||
console.error(`Unknown collection: ${collection}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
135
src/scripts/database/DexieService.ts
Normal file
135
src/scripts/database/DexieService.ts
Normal file
|
@ -0,0 +1,135 @@
|
|||
import Dexie from 'dexie';
|
||||
import type {
|
||||
User,
|
||||
Event,
|
||||
EventRequest,
|
||||
Log,
|
||||
Officer,
|
||||
Reimbursement,
|
||||
Receipt,
|
||||
Sponsor
|
||||
} from '../../schemas/pocketbase/schema';
|
||||
|
||||
// Interface for tracking offline changes
|
||||
interface OfflineChange {
|
||||
id: string;
|
||||
collection: string;
|
||||
recordId: string;
|
||||
operation: 'create' | 'update' | 'delete';
|
||||
data?: any;
|
||||
timestamp: number;
|
||||
synced: boolean;
|
||||
syncAttempts: number;
|
||||
}
|
||||
|
||||
export class DashboardDatabase extends Dexie {
|
||||
users!: Dexie.Table<User, string>;
|
||||
events!: Dexie.Table<Event, string>;
|
||||
eventRequests!: Dexie.Table<EventRequest, string>;
|
||||
logs!: Dexie.Table<Log, string>;
|
||||
officers!: Dexie.Table<Officer, string>;
|
||||
reimbursements!: Dexie.Table<Reimbursement, string>;
|
||||
receipts!: Dexie.Table<Receipt, string>;
|
||||
sponsors!: Dexie.Table<Sponsor, string>;
|
||||
offlineChanges!: Dexie.Table<OfflineChange, string>;
|
||||
|
||||
// Store last sync timestamps
|
||||
syncInfo!: Dexie.Table<{id: string, collection: string, lastSync: number}, string>;
|
||||
|
||||
constructor() {
|
||||
super('IEEEDashboardDB');
|
||||
|
||||
this.version(1).stores({
|
||||
users: 'id, email, name',
|
||||
events: 'id, event_name, event_code, start_date, end_date, published',
|
||||
eventRequests: 'id, name, status, requested_user, created, updated',
|
||||
logs: 'id, user, type, created',
|
||||
officers: 'id, user, role, type',
|
||||
reimbursements: 'id, title, status, submitted_by, created',
|
||||
receipts: 'id, created_by, date',
|
||||
sponsors: 'id, user, company',
|
||||
syncInfo: 'id, collection, lastSync'
|
||||
});
|
||||
|
||||
// Add version 2 with offlineChanges table
|
||||
this.version(2).stores({
|
||||
offlineChanges: 'id, collection, recordId, operation, timestamp, synced, syncAttempts'
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize the database with default values
|
||||
async initialize() {
|
||||
const collections = [
|
||||
'users', 'events', 'event_request', 'logs',
|
||||
'officers', 'reimbursement', 'receipts', 'sponsors'
|
||||
];
|
||||
|
||||
for (const collection of collections) {
|
||||
const exists = await this.syncInfo.get(collection);
|
||||
if (!exists) {
|
||||
await this.syncInfo.put({
|
||||
id: collection,
|
||||
collection,
|
||||
lastSync: 0
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton pattern
|
||||
export class DexieService {
|
||||
private static instance: DexieService;
|
||||
private db: DashboardDatabase;
|
||||
|
||||
private constructor() {
|
||||
this.db = new DashboardDatabase();
|
||||
this.db.initialize();
|
||||
}
|
||||
|
||||
public static getInstance(): DexieService {
|
||||
if (!DexieService.instance) {
|
||||
DexieService.instance = new DexieService();
|
||||
}
|
||||
return DexieService.instance;
|
||||
}
|
||||
|
||||
// Get the database instance
|
||||
public getDB(): DashboardDatabase {
|
||||
return this.db;
|
||||
}
|
||||
|
||||
// Update the last sync timestamp for a collection
|
||||
public async updateLastSync(collection: string): Promise<void> {
|
||||
await this.db.syncInfo.update(collection, { lastSync: Date.now() });
|
||||
}
|
||||
|
||||
// Get the last sync timestamp for a collection
|
||||
public async getLastSync(collection: string): Promise<number> {
|
||||
const info = await this.db.syncInfo.get(collection);
|
||||
return info?.lastSync || 0;
|
||||
}
|
||||
|
||||
// Clear all data (useful for logout)
|
||||
public async clearAllData(): Promise<void> {
|
||||
await this.db.users.clear();
|
||||
await this.db.events.clear();
|
||||
await this.db.eventRequests.clear();
|
||||
await this.db.logs.clear();
|
||||
await this.db.officers.clear();
|
||||
await this.db.reimbursements.clear();
|
||||
await this.db.receipts.clear();
|
||||
await this.db.sponsors.clear();
|
||||
await this.db.offlineChanges.clear();
|
||||
|
||||
// Reset sync timestamps
|
||||
const collections = [
|
||||
'users', 'events', 'event_request', 'logs',
|
||||
'officers', 'reimbursement', 'receipts', 'sponsors'
|
||||
];
|
||||
|
||||
for (const collection of collections) {
|
||||
await this.db.syncInfo.update(collection, { lastSync: 0 });
|
||||
}
|
||||
}
|
||||
}
|
35
src/scripts/database/initAuthSync.ts
Normal file
35
src/scripts/database/initAuthSync.ts
Normal file
|
@ -0,0 +1,35 @@
|
|||
import { Authentication } from '../pocketbase/Authentication';
|
||||
|
||||
/**
|
||||
* Initialize authentication synchronization
|
||||
* This function should be called when the application starts
|
||||
* to ensure proper data synchronization during authentication flows
|
||||
*/
|
||||
export async function initAuthSync(): Promise<void> {
|
||||
try {
|
||||
// Get Authentication instance
|
||||
const auth = Authentication.getInstance();
|
||||
|
||||
// This will trigger the lazy loading of AuthSyncService
|
||||
// through the onAuthStateChange mechanism
|
||||
auth.onAuthStateChange(() => {
|
||||
console.log('Auth sync initialized and listening for auth state changes');
|
||||
});
|
||||
|
||||
console.log('Auth sync initialization complete');
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize auth sync:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Export a function to manually trigger a full sync
|
||||
export async function forceFullSync(): Promise<boolean> {
|
||||
try {
|
||||
const { AuthSyncService } = await import('./AuthSyncService');
|
||||
const authSync = AuthSyncService.getInstance();
|
||||
return await authSync.forceSyncAll();
|
||||
} catch (error) {
|
||||
console.error('Failed to force full sync:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
|
@ -21,6 +21,7 @@ export class Authentication {
|
|||
private static instance: Authentication;
|
||||
private authChangeCallbacks: ((isValid: boolean) => void)[] = [];
|
||||
private isUpdating: boolean = false;
|
||||
private authSyncServiceInitialized: boolean = false;
|
||||
|
||||
private constructor() {
|
||||
// Use the baseUrl from the config file
|
||||
|
@ -82,8 +83,27 @@ export class Authentication {
|
|||
/**
|
||||
* Handle user logout
|
||||
*/
|
||||
public logout(): void {
|
||||
public async logout(): Promise<void> {
|
||||
try {
|
||||
// Initialize AuthSyncService if needed (lazy loading)
|
||||
await this.initAuthSyncService();
|
||||
|
||||
// Get AuthSyncService instance
|
||||
const { AuthSyncService } = await import('../database/AuthSyncService');
|
||||
const authSync = AuthSyncService.getInstance();
|
||||
|
||||
// Handle data cleanup before actual logout
|
||||
await authSync.handleLogout();
|
||||
|
||||
// Clear auth store
|
||||
this.pb.authStore.clear();
|
||||
|
||||
console.log('Logout completed successfully with data cleanup');
|
||||
} catch (error) {
|
||||
console.error('Error during logout:', error);
|
||||
// Fallback to basic logout if sync fails
|
||||
this.pb.authStore.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -113,6 +133,11 @@ export class Authentication {
|
|||
*/
|
||||
public onAuthStateChange(callback: (isValid: boolean) => void): void {
|
||||
this.authChangeCallbacks.push(callback);
|
||||
|
||||
// Initialize AuthSyncService when first callback is registered
|
||||
if (!this.authSyncServiceInitialized && this.authChangeCallbacks.length === 1) {
|
||||
this.initAuthSyncService();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -139,4 +164,32 @@ export class Authentication {
|
|||
const isValid = this.pb.authStore.isValid;
|
||||
this.authChangeCallbacks.forEach((callback) => callback(isValid));
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the AuthSyncService (lazy loading)
|
||||
*/
|
||||
private async initAuthSyncService(): Promise<void> {
|
||||
if (this.authSyncServiceInitialized) return;
|
||||
|
||||
try {
|
||||
// Dynamically import AuthSyncService to avoid circular dependencies
|
||||
const { AuthSyncService } = await import('../database/AuthSyncService');
|
||||
|
||||
// Initialize the service
|
||||
AuthSyncService.getInstance();
|
||||
|
||||
this.authSyncServiceInitialized = true;
|
||||
console.log('AuthSyncService initialized successfully');
|
||||
|
||||
// If user is already authenticated, trigger initial sync
|
||||
if (this.isAuthenticated()) {
|
||||
const authSync = AuthSyncService.getInstance();
|
||||
authSync.handleLogin().catch(err => {
|
||||
console.error('Error during initial data sync:', err);
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize AuthSyncService:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ interface BaseRecord {
|
|||
interface RequestOptions {
|
||||
fields?: string[];
|
||||
disableAutoCancellation?: boolean;
|
||||
expand?: string[];
|
||||
expand?: string[] | string;
|
||||
}
|
||||
|
||||
// Utility function to check if a value is a UTC date string
|
||||
|
@ -130,21 +130,39 @@ export class Get {
|
|||
options?: RequestOptions,
|
||||
): Promise<T> {
|
||||
if (!this.auth.isAuthenticated()) {
|
||||
throw new Error("User must be authenticated to retrieve records");
|
||||
console.warn(
|
||||
`User not authenticated, but attempting to get record from ${collectionName} anyway`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const pb = this.auth.getPocketBase();
|
||||
|
||||
// Handle expand parameter
|
||||
let expandString: string | undefined;
|
||||
if (options?.expand) {
|
||||
if (Array.isArray(options.expand)) {
|
||||
expandString = options.expand.join(",");
|
||||
} else if (typeof options.expand === 'string') {
|
||||
expandString = options.expand;
|
||||
}
|
||||
}
|
||||
|
||||
const requestOptions = {
|
||||
...(options?.fields && { fields: options.fields.join(",") }),
|
||||
...(expandString && { expand: expandString }),
|
||||
...(options?.disableAutoCancellation && { requestKey: null }),
|
||||
};
|
||||
|
||||
const result = await pb
|
||||
.collection(collectionName)
|
||||
.getOne<T>(recordId, requestOptions);
|
||||
return convertUTCToLocal(result);
|
||||
} catch (err) {
|
||||
console.error(`Failed to get record from ${collectionName}:`, err);
|
||||
console.error(
|
||||
`Failed to get record ${recordId} from ${collectionName}:`,
|
||||
err,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
@ -162,29 +180,43 @@ export class Get {
|
|||
options?: RequestOptions,
|
||||
): Promise<T[]> {
|
||||
if (!this.auth.isAuthenticated()) {
|
||||
throw new Error("User must be authenticated to retrieve records");
|
||||
console.warn(
|
||||
`User not authenticated, but attempting to get records from ${collectionName} anyway`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
// Build filter for multiple IDs
|
||||
const filter = recordIds.map((id) => `id="${id}"`).join(" || ");
|
||||
|
||||
const pb = this.auth.getPocketBase();
|
||||
const filter = `id ?~ "${recordIds.join("|")}"`;
|
||||
|
||||
// Handle expand parameter
|
||||
let expandString: string | undefined;
|
||||
if (options?.expand) {
|
||||
if (Array.isArray(options.expand)) {
|
||||
expandString = options.expand.join(",");
|
||||
} else if (typeof options.expand === 'string') {
|
||||
expandString = options.expand;
|
||||
}
|
||||
}
|
||||
|
||||
const requestOptions = {
|
||||
filter,
|
||||
...(options?.fields && { fields: options.fields.join(",") }),
|
||||
...(expandString && { expand: expandString }),
|
||||
...(options?.disableAutoCancellation && { requestKey: null }),
|
||||
};
|
||||
|
||||
const result = await pb
|
||||
.collection(collectionName)
|
||||
.getFullList<T>(requestOptions);
|
||||
|
||||
// Sort results to match the order of requested IDs and convert times
|
||||
const recordMap = new Map(
|
||||
result.map((record) => [record.id, convertUTCToLocal(record)]),
|
||||
);
|
||||
return recordIds.map((id) => recordMap.get(id)).filter(Boolean) as T[];
|
||||
.getList<T>(1, recordIds.length, requestOptions);
|
||||
return result.items.map((item) => convertUTCToLocal(item));
|
||||
} catch (err) {
|
||||
console.error(`Failed to get records from ${collectionName}:`, err);
|
||||
console.error(
|
||||
`Failed to get records ${recordIds.join(", ")} from ${collectionName}:`,
|
||||
err,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
@ -257,16 +289,33 @@ export class Get {
|
|||
sort?: string,
|
||||
options?: RequestOptions,
|
||||
): Promise<T[]> {
|
||||
if (!this.auth.isAuthenticated()) {
|
||||
console.warn(
|
||||
`User not authenticated, but attempting to get records from ${collectionName} anyway`,
|
||||
);
|
||||
}
|
||||
|
||||
// Try to get records even if authentication check fails
|
||||
// This is a workaround for cases where isAuthenticated() returns false
|
||||
// but the token is still valid for API requests
|
||||
try {
|
||||
const pb = this.auth.getPocketBase();
|
||||
|
||||
// Handle expand parameter
|
||||
let expandString: string | undefined;
|
||||
if (options?.expand) {
|
||||
if (Array.isArray(options.expand)) {
|
||||
expandString = options.expand.join(",");
|
||||
} else if (typeof options.expand === 'string') {
|
||||
expandString = options.expand;
|
||||
}
|
||||
}
|
||||
|
||||
const requestOptions = {
|
||||
...(filter && { filter }),
|
||||
...(sort && { sort }),
|
||||
...(options?.fields && { fields: options.fields.join(",") }),
|
||||
...(options?.expand && { expand: options.expand.join(",") }),
|
||||
...(expandString && { expand: expandString }),
|
||||
...(options?.disableAutoCancellation && { requestKey: null }),
|
||||
};
|
||||
|
||||
|
@ -276,18 +325,6 @@ export class Get {
|
|||
return result.map((item) => convertUTCToLocal(item));
|
||||
} catch (err) {
|
||||
console.error(`Failed to get all records from ${collectionName}:`, err);
|
||||
|
||||
// If the error is authentication-related, check if we're actually authenticated
|
||||
if (
|
||||
err instanceof Error &&
|
||||
(err.message.includes('auth') || err.message.includes('authentication'))
|
||||
) {
|
||||
if (!this.auth.isAuthenticated()) {
|
||||
console.error("Authentication check failed in getAll");
|
||||
throw new Error("User must be authenticated to retrieve records");
|
||||
}
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue