Compare commits
15 Commits
80d5199259
...
entitygrid
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acf104eaa7 | ||
|
|
e793d7a9e2 | ||
|
|
3ecae0be0f | ||
|
|
d37e53b1a9 | ||
|
|
2f89c7e13b | ||
|
|
6bf4055c2f | ||
|
|
bf7ee630d0 | ||
|
|
a5a9af5ddc | ||
|
|
4e3e293495 | ||
|
|
65533c15d2 | ||
|
|
2530bc0ec2 | ||
| b1fa6ac458 | |||
| 9ff24f8258 | |||
|
|
9a3409c29f | ||
|
|
a142737771 |
@@ -108,7 +108,7 @@ projects, and special entities with selection. * * @author Matthew Raymer */
|
|||||||
<li
|
<li
|
||||||
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
|
class="text-xs font-semibold text-slate-500 uppercase pt-5 pb-1.5 px-2 border-b border-slate-300"
|
||||||
>
|
>
|
||||||
Everyone Else
|
Everyone
|
||||||
</li>
|
</li>
|
||||||
<PersonCard
|
<PersonCard
|
||||||
v-for="person in alphabeticalContacts"
|
v-for="person in alphabeticalContacts"
|
||||||
@@ -164,6 +164,10 @@ import { Contact } from "../db/tables/contacts";
|
|||||||
import { PlanData } from "../interfaces/records";
|
import { PlanData } from "../interfaces/records";
|
||||||
import { NotificationIface } from "../constants/app";
|
import { NotificationIface } from "../constants/app";
|
||||||
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
import { UNNAMED_ENTITY_NAME } from "@/constants/entities";
|
||||||
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
|
import { getHeaders } from "../libs/endorserServer";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
import { TIMEOUTS } from "@/utils/notify";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constants for infinite scroll configuration
|
* Constants for infinite scroll configuration
|
||||||
@@ -191,6 +195,7 @@ const RECENT_CONTACTS_COUNT = 3;
|
|||||||
ProjectCard,
|
ProjectCard,
|
||||||
SpecialEntityCard,
|
SpecialEntityCard,
|
||||||
},
|
},
|
||||||
|
mixins: [PlatformServiceMixin],
|
||||||
})
|
})
|
||||||
export default class EntityGrid extends Vue {
|
export default class EntityGrid extends Vue {
|
||||||
/** Type of entities to display */
|
/** Type of entities to display */
|
||||||
@@ -202,13 +207,31 @@ export default class EntityGrid extends Vue {
|
|||||||
isSearching = false;
|
isSearching = false;
|
||||||
searchTimeout: NodeJS.Timeout | null = null;
|
searchTimeout: NodeJS.Timeout | null = null;
|
||||||
filteredEntities: Contact[] | PlanData[] = [];
|
filteredEntities: Contact[] | PlanData[] = [];
|
||||||
|
searchBeforeId: string | undefined = undefined;
|
||||||
|
isLoadingSearchMore = false;
|
||||||
|
|
||||||
|
// API server for project searches
|
||||||
|
apiServer = "";
|
||||||
|
|
||||||
// Infinite scroll state
|
// Infinite scroll state
|
||||||
displayedCount = INITIAL_BATCH_SIZE;
|
displayedCount = INITIAL_BATCH_SIZE;
|
||||||
infiniteScrollReset?: () => void;
|
infiniteScrollReset?: () => void;
|
||||||
scrollContainer?: HTMLElement;
|
scrollContainer?: HTMLElement;
|
||||||
|
isLoadingMore = false; // Prevent duplicate callback calls
|
||||||
|
|
||||||
/** Array of entities to display */
|
/**
|
||||||
|
* Array of entities to display
|
||||||
|
*
|
||||||
|
* For contacts: Must be a COMPLETE list from local database.
|
||||||
|
* Use $contactsByDateAdded() to ensure all contacts are included.
|
||||||
|
* Client-side filtering assumes the complete list is available.
|
||||||
|
* IMPORTANT: When passing Contact[] arrays, they must be sorted by date added
|
||||||
|
* (newest first) for the "Recently Added" section to display correctly.
|
||||||
|
*
|
||||||
|
* For projects: Can be partial list (pagination supported).
|
||||||
|
* Server-side search will fetch matching results with pagination,
|
||||||
|
* regardless of what's in this prop.
|
||||||
|
*/
|
||||||
@Prop({ required: true })
|
@Prop({ required: true })
|
||||||
entities!: Contact[] | PlanData[];
|
entities!: Contact[] | PlanData[];
|
||||||
|
|
||||||
@@ -275,6 +298,23 @@ export default class EntityGrid extends Vue {
|
|||||||
entityType: "people" | "projects",
|
entityType: "people" | "projects",
|
||||||
) => Contact[] | PlanData[];
|
) => Contact[] | PlanData[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional callback function to load more entities from server
|
||||||
|
* Called when infinite scroll reaches end and more data is available
|
||||||
|
* Required for projects when using server-side pagination
|
||||||
|
*
|
||||||
|
* @param entities - Current array of entities
|
||||||
|
* @returns Promise that resolves when more entities are loaded
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* :load-more-callback="async (entities) => {
|
||||||
|
* const lastEntity = entities[entities.length - 1];
|
||||||
|
* await loadMoreFromServer(lastEntity.rowId);
|
||||||
|
* }"
|
||||||
|
*/
|
||||||
|
@Prop({ default: null })
|
||||||
|
loadMoreCallback?: (entities: Contact[] | PlanData[]) => Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSS classes for the empty state message
|
* CSS classes for the empty state message
|
||||||
*/
|
*/
|
||||||
@@ -307,14 +347,17 @@ export default class EntityGrid extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the 3 most recently added contacts (when showing contacts and not searching)
|
* Get the most recently added contacts (when showing contacts and not searching)
|
||||||
|
*
|
||||||
|
* NOTE: This assumes entities are already sorted by date added (newest first).
|
||||||
|
* See the entities prop documentation for details on using $contactsByDateAdded().
|
||||||
*/
|
*/
|
||||||
get recentContacts(): Contact[] {
|
get recentContacts(): Contact[] {
|
||||||
if (this.entityType !== "people" || this.searchTerm.trim()) {
|
if (this.entityType !== "people" || this.searchTerm.trim()) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
// Entities are already sorted by date added (newest first)
|
// Entities are already sorted by date added (newest first)
|
||||||
return (this.entities as Contact[]).slice(0, 3);
|
return (this.entities as Contact[]).slice(0, RECENT_CONTACTS_COUNT);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -325,16 +368,16 @@ export default class EntityGrid extends Vue {
|
|||||||
if (this.entityType !== "people" || this.searchTerm.trim()) {
|
if (this.entityType !== "people" || this.searchTerm.trim()) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
// Skip the first 3 (recent contacts) and sort the rest alphabetically
|
// Skip the first few (recent contacts) and sort the rest alphabetically
|
||||||
// Create a copy to avoid mutating the original array
|
// Create a copy to avoid mutating the original array
|
||||||
const remaining = (this.entities as Contact[]).slice(RECENT_CONTACTS_COUNT);
|
const remaining = this.entities as Contact[];
|
||||||
const sorted = [...remaining].sort((a: Contact, b: Contact) => {
|
const sorted = [...remaining].sort((a: Contact, b: Contact) => {
|
||||||
// Sort alphabetically by name, falling back to DID if name is missing
|
// Sort alphabetically by name, falling back to DID if name is missing
|
||||||
const nameA = (a.name || a.did).toLowerCase();
|
const nameA = (a.name || a.did).toLowerCase();
|
||||||
const nameB = (b.name || b.did).toLowerCase();
|
const nameB = (b.name || b.did).toLowerCase();
|
||||||
return nameA.localeCompare(nameB);
|
return nameA.localeCompare(nameB);
|
||||||
});
|
});
|
||||||
// Apply infinite scroll: show based on displayedCount (minus the 3 recent)
|
// Apply infinite scroll: show based on displayedCount (minus the recent contacts)
|
||||||
const toShow = Math.max(0, this.displayedCount - RECENT_CONTACTS_COUNT);
|
const toShow = Math.max(0, this.displayedCount - RECENT_CONTACTS_COUNT);
|
||||||
return sorted.slice(0, toShow);
|
return sorted.slice(0, toShow);
|
||||||
}
|
}
|
||||||
@@ -443,47 +486,27 @@ export default class EntityGrid extends Vue {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform the actual search
|
* Perform the actual search
|
||||||
|
* Routes to server-side search for projects or client-side filtering for contacts
|
||||||
*/
|
*/
|
||||||
async performSearch(): Promise<void> {
|
async performSearch(): Promise<void> {
|
||||||
if (!this.searchTerm.trim()) {
|
if (!this.searchTerm.trim()) {
|
||||||
this.filteredEntities = [];
|
this.filteredEntities = [];
|
||||||
this.displayedCount = INITIAL_BATCH_SIZE;
|
this.displayedCount = INITIAL_BATCH_SIZE;
|
||||||
|
this.searchBeforeId = undefined;
|
||||||
this.infiniteScrollReset?.();
|
this.infiniteScrollReset?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isSearching = true;
|
this.isSearching = true;
|
||||||
|
this.searchBeforeId = undefined; // Reset pagination for new search
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Simulate async search (in case we need to add API calls later)
|
if (this.entityType === "projects") {
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
// Server-side search for projects (initial load, no beforeId)
|
||||||
|
await this.performProjectSearch();
|
||||||
const searchLower = this.searchTerm.toLowerCase().trim();
|
|
||||||
|
|
||||||
if (this.entityType === "people") {
|
|
||||||
this.filteredEntities = (this.entities as Contact[])
|
|
||||||
.filter((contact: Contact) => {
|
|
||||||
const name = contact.name?.toLowerCase() || "";
|
|
||||||
const did = contact.did.toLowerCase();
|
|
||||||
return name.includes(searchLower) || did.includes(searchLower);
|
|
||||||
})
|
|
||||||
.sort((a: Contact, b: Contact) => {
|
|
||||||
// Sort alphabetically by name, falling back to DID if name is missing
|
|
||||||
const nameA = (a.name || a.did).toLowerCase();
|
|
||||||
const nameB = (b.name || b.did).toLowerCase();
|
|
||||||
return nameA.localeCompare(nameB);
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
this.filteredEntities = (this.entities as PlanData[])
|
// Client-side filtering for contacts (complete list)
|
||||||
.filter((project: PlanData) => {
|
await this.performContactSearch();
|
||||||
const name = project.name?.toLowerCase() || "";
|
|
||||||
const handleId = project.handleId.toLowerCase();
|
|
||||||
return name.includes(searchLower) || handleId.includes(searchLower);
|
|
||||||
})
|
|
||||||
.sort((a: PlanData, b: PlanData) => {
|
|
||||||
// Sort alphabetically by name
|
|
||||||
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset displayed count when search completes
|
// Reset displayed count when search completes
|
||||||
@@ -494,6 +517,128 @@ export default class EntityGrid extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Perform server-side project search with optional pagination
|
||||||
|
* Uses claimContents parameter for search and beforeId for pagination.
|
||||||
|
* Results are appended when paginating, replaced on initial search.
|
||||||
|
*
|
||||||
|
* @param beforeId - Optional rowId for pagination (loads projects before this ID)
|
||||||
|
*/
|
||||||
|
async performProjectSearch(beforeId?: string): Promise<void> {
|
||||||
|
if (!this.apiServer) {
|
||||||
|
this.filteredEntities = [];
|
||||||
|
if (this.notify) {
|
||||||
|
this.notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "API server not configured",
|
||||||
|
},
|
||||||
|
TIMEOUTS.SHORT,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchLower = this.searchTerm.toLowerCase().trim();
|
||||||
|
let url = `${this.apiServer}/api/v2/report/plans?claimContents=${encodeURIComponent(searchLower)}`;
|
||||||
|
|
||||||
|
if (beforeId) {
|
||||||
|
url += `&beforeId=${encodeURIComponent(beforeId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "GET",
|
||||||
|
headers: await getHeaders(this.activeDid),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw new Error("Failed to search projects");
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await response.json();
|
||||||
|
if (results.data) {
|
||||||
|
const newProjects = results.data.map(
|
||||||
|
(plan: PlanData & { rowId?: string }) => ({
|
||||||
|
...plan,
|
||||||
|
rowId: plan.rowId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (beforeId) {
|
||||||
|
// Pagination: append new projects to existing search results
|
||||||
|
this.filteredEntities.push(...newProjects);
|
||||||
|
} else {
|
||||||
|
// Initial search: replace array
|
||||||
|
this.filteredEntities = newProjects;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update searchBeforeId for next pagination
|
||||||
|
// Use the last project's rowId, or undefined if no more results
|
||||||
|
if (newProjects.length > 0) {
|
||||||
|
const lastProject = newProjects[newProjects.length - 1];
|
||||||
|
// Only set searchBeforeId if rowId exists (indicates more results available)
|
||||||
|
this.searchBeforeId = lastProject.rowId || undefined;
|
||||||
|
} else {
|
||||||
|
this.searchBeforeId = undefined; // No more results
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!beforeId) {
|
||||||
|
// Only clear on initial search, not pagination
|
||||||
|
this.filteredEntities = [];
|
||||||
|
}
|
||||||
|
this.searchBeforeId = undefined;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error searching projects:", error);
|
||||||
|
if (!beforeId) {
|
||||||
|
// Only clear on initial search error, not pagination error
|
||||||
|
this.filteredEntities = [];
|
||||||
|
}
|
||||||
|
this.searchBeforeId = undefined;
|
||||||
|
if (this.notify) {
|
||||||
|
this.notify(
|
||||||
|
{
|
||||||
|
group: "alert",
|
||||||
|
type: "danger",
|
||||||
|
title: "Error",
|
||||||
|
text: "Failed to search projects. Please try again.",
|
||||||
|
},
|
||||||
|
TIMEOUTS.STANDARD,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side contact search
|
||||||
|
* Assumes entities prop contains complete contact list from local database
|
||||||
|
*/
|
||||||
|
async performContactSearch(): Promise<void> {
|
||||||
|
// Simulate async (for consistency with project search)
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
const searchLower = this.searchTerm.toLowerCase().trim();
|
||||||
|
|
||||||
|
this.filteredEntities = (this.entities as Contact[])
|
||||||
|
.filter((contact: Contact) => {
|
||||||
|
const name = contact.name?.toLowerCase() || "";
|
||||||
|
const did = contact.did.toLowerCase();
|
||||||
|
return name.includes(searchLower) || did.includes(searchLower);
|
||||||
|
})
|
||||||
|
.sort((a: Contact, b: Contact) => {
|
||||||
|
// Sort alphabetically by name, falling back to DID if name is missing
|
||||||
|
const nameA = (a.name || a.did).toLowerCase();
|
||||||
|
const nameB = (b.name || b.did).toLowerCase();
|
||||||
|
return nameA.localeCompare(nameB);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Contacts don't need pagination (complete list)
|
||||||
|
this.searchBeforeId = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear the search
|
* Clear the search
|
||||||
*/
|
*/
|
||||||
@@ -502,6 +647,7 @@ export default class EntityGrid extends Vue {
|
|||||||
this.filteredEntities = [];
|
this.filteredEntities = [];
|
||||||
this.isSearching = false;
|
this.isSearching = false;
|
||||||
this.displayedCount = INITIAL_BATCH_SIZE;
|
this.displayedCount = INITIAL_BATCH_SIZE;
|
||||||
|
this.searchBeforeId = undefined;
|
||||||
this.infiniteScrollReset?.();
|
this.infiniteScrollReset?.();
|
||||||
|
|
||||||
// Clear any pending timeout
|
// Clear any pending timeout
|
||||||
@@ -521,35 +667,114 @@ export default class EntityGrid extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (this.searchTerm.trim()) {
|
if (this.searchTerm.trim()) {
|
||||||
// Search mode: check filtered entities
|
// Search mode: check if more results available
|
||||||
return this.displayedCount < this.filteredEntities.length;
|
if (this.entityType === "projects") {
|
||||||
|
// Projects: can load more if:
|
||||||
|
// 1. We have more already-loaded results to show, OR
|
||||||
|
// 2. We've shown all loaded results AND there's a searchBeforeId to load more
|
||||||
|
const hasMoreLoaded =
|
||||||
|
this.displayedCount < this.filteredEntities.length;
|
||||||
|
const canLoadMoreFromServer =
|
||||||
|
this.displayedCount >= this.filteredEntities.length &&
|
||||||
|
!!this.searchBeforeId &&
|
||||||
|
!this.isLoadingSearchMore;
|
||||||
|
return hasMoreLoaded || canLoadMoreFromServer;
|
||||||
|
} else {
|
||||||
|
// Contacts: client-side filtering returns all results at once
|
||||||
|
return this.displayedCount < this.filteredEntities.length;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Non-search mode: existing logic
|
||||||
if (this.entityType === "projects") {
|
if (this.entityType === "projects") {
|
||||||
// Projects: check if more available
|
// Projects: if we've shown all loaded entities and callback exists, callback handles server-side availability
|
||||||
|
if (
|
||||||
|
this.displayedCount >= this.entities.length &&
|
||||||
|
this.loadMoreCallback
|
||||||
|
) {
|
||||||
|
return !this.isLoadingMore; // Only return true if not already loading
|
||||||
|
}
|
||||||
|
// Otherwise, check if more in memory
|
||||||
return this.displayedCount < this.entities.length;
|
return this.displayedCount < this.entities.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
// People: check if more alphabetical contacts available
|
// People: check if more alphabetical contacts available
|
||||||
// Total available = 3 recent + all alphabetical
|
// Total available = recent + all alphabetical
|
||||||
const remaining = (this.entities as Contact[]).slice(RECENT_CONTACTS_COUNT);
|
const totalAvailable = RECENT_CONTACTS_COUNT + this.entities.length;
|
||||||
const totalAvailable = RECENT_CONTACTS_COUNT + remaining.length;
|
|
||||||
return this.displayedCount < totalAvailable;
|
return this.displayedCount < totalAvailable;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize infinite scroll on mount
|
* Initialize infinite scroll on mount
|
||||||
*/
|
*/
|
||||||
mounted(): void {
|
async mounted(): Promise<void> {
|
||||||
|
// Load apiServer for project searches
|
||||||
|
if (this.entityType === "projects") {
|
||||||
|
const settings = await this.$accountSettings();
|
||||||
|
this.apiServer = settings.apiServer || "";
|
||||||
|
}
|
||||||
|
|
||||||
this.$nextTick(() => {
|
this.$nextTick(() => {
|
||||||
const container = this.$refs.scrollContainer as HTMLElement;
|
const container = this.$refs.scrollContainer as HTMLElement;
|
||||||
|
|
||||||
if (container) {
|
if (container) {
|
||||||
const { reset } = useInfiniteScroll(
|
const { reset } = useInfiniteScroll(
|
||||||
container,
|
container,
|
||||||
() => {
|
async () => {
|
||||||
// Load more: increment displayedCount
|
// Search mode: handle search pagination
|
||||||
this.displayedCount += INCREMENT_SIZE;
|
if (this.searchTerm.trim()) {
|
||||||
|
if (this.entityType === "projects") {
|
||||||
|
// Projects: load more search results if available
|
||||||
|
if (
|
||||||
|
this.displayedCount >= this.filteredEntities.length &&
|
||||||
|
this.searchBeforeId &&
|
||||||
|
!this.isLoadingSearchMore
|
||||||
|
) {
|
||||||
|
this.isLoadingSearchMore = true;
|
||||||
|
try {
|
||||||
|
await this.performProjectSearch(this.searchBeforeId);
|
||||||
|
// After loading more, reset scroll state to allow further loading
|
||||||
|
this.infiniteScrollReset?.();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error loading more search results:", error);
|
||||||
|
// Error already handled in performProjectSearch
|
||||||
|
} finally {
|
||||||
|
this.isLoadingSearchMore = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Show more from already-loaded search results
|
||||||
|
this.displayedCount += INCREMENT_SIZE;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Contacts: show more from already-filtered results
|
||||||
|
this.displayedCount += INCREMENT_SIZE;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Non-search mode: existing logic
|
||||||
|
// For projects: if we've shown all entities and callback exists, call it
|
||||||
|
if (
|
||||||
|
this.entityType === "projects" &&
|
||||||
|
this.displayedCount >= this.entities.length &&
|
||||||
|
this.loadMoreCallback &&
|
||||||
|
!this.isLoadingMore
|
||||||
|
) {
|
||||||
|
this.isLoadingMore = true;
|
||||||
|
try {
|
||||||
|
await this.loadMoreCallback(this.entities);
|
||||||
|
// After callback, entities prop will update via Vue reactivity
|
||||||
|
// Reset scroll state to allow further loading
|
||||||
|
this.infiniteScrollReset?.();
|
||||||
|
} catch (error) {
|
||||||
|
// Error handling is up to the callback, but we should reset loading state
|
||||||
|
console.error("Error in loadMoreCallback:", error);
|
||||||
|
} finally {
|
||||||
|
this.isLoadingMore = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Normal case: increment displayedCount to show more from memory
|
||||||
|
this.displayedCount += INCREMENT_SIZE;
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
distance: 50, // pixels from bottom
|
distance: 50, // pixels from bottom
|
||||||
@@ -575,19 +800,27 @@ export default class EntityGrid extends Vue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Watch for changes in search term to reset displayed count
|
* Watch for changes in search term to reset displayed count and pagination
|
||||||
*/
|
*/
|
||||||
@Watch("searchTerm")
|
@Watch("searchTerm")
|
||||||
onSearchTermChange(): void {
|
onSearchTermChange(): void {
|
||||||
|
// Reset displayed count and pagination when search term changes
|
||||||
this.displayedCount = INITIAL_BATCH_SIZE;
|
this.displayedCount = INITIAL_BATCH_SIZE;
|
||||||
|
this.searchBeforeId = undefined;
|
||||||
this.infiniteScrollReset?.();
|
this.infiniteScrollReset?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Watch for changes in entities prop to reset displayed count
|
* Watch for changes in entities prop to clear search and reset displayed count
|
||||||
*/
|
*/
|
||||||
@Watch("entities")
|
@Watch("entities")
|
||||||
onEntitiesChange(): void {
|
onEntitiesChange(): void {
|
||||||
|
// Clear search when entities change (fresh dialog open)
|
||||||
|
if (this.searchTerm) {
|
||||||
|
this.searchTerm = "";
|
||||||
|
this.filteredEntities = [];
|
||||||
|
this.searchBeforeId = undefined;
|
||||||
|
}
|
||||||
this.displayedCount = INITIAL_BATCH_SIZE;
|
this.displayedCount = INITIAL_BATCH_SIZE;
|
||||||
this.infiniteScrollReset?.();
|
this.infiniteScrollReset?.();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ properties * * @author Matthew Raymer */
|
|||||||
:you-selectable="youSelectable"
|
:you-selectable="youSelectable"
|
||||||
:notify="notify"
|
:notify="notify"
|
||||||
:conflict-context="conflictContext"
|
:conflict-context="conflictContext"
|
||||||
|
:load-more-callback="shouldShowProjects ? loadMoreCallback : undefined"
|
||||||
@entity-selected="handleEntitySelected"
|
@entity-selected="handleEntitySelected"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -148,6 +149,10 @@ export default class EntitySelectionStep extends Vue {
|
|||||||
@Prop()
|
@Prop()
|
||||||
notify?: (notification: NotificationIface, timeout?: number) => void;
|
notify?: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
|
||||||
|
/** Callback function to load more projects from server */
|
||||||
|
@Prop()
|
||||||
|
loadMoreCallback?: (entities: PlanData[]) => Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CSS classes for the cancel button
|
* CSS classes for the cancel button
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -29,6 +29,11 @@
|
|||||||
:unit-code="unitCode"
|
:unit-code="unitCode"
|
||||||
:offer-id="offerId"
|
:offer-id="offerId"
|
||||||
:notify="$notify"
|
:notify="$notify"
|
||||||
|
:load-more-callback="
|
||||||
|
giverEntityType === 'project' || recipientEntityType === 'project'
|
||||||
|
? handleLoadMoreProjects
|
||||||
|
: undefined
|
||||||
|
"
|
||||||
@entity-selected="handleEntitySelected"
|
@entity-selected="handleEntitySelected"
|
||||||
@cancel="cancel"
|
@cancel="cancel"
|
||||||
/>
|
/>
|
||||||
@@ -489,9 +494,17 @@ export default class GiftedDialog extends Vue {
|
|||||||
this.firstStep = false;
|
this.firstStep = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadProjects() {
|
/**
|
||||||
|
* Load projects from the API
|
||||||
|
* @param beforeId - Optional rowId for pagination (loads projects before this ID)
|
||||||
|
*/
|
||||||
|
async loadProjects(beforeId?: string) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(this.apiServer + "/api/v2/report/plans", {
|
let url = this.apiServer + "/api/v2/report/plans";
|
||||||
|
if (beforeId) {
|
||||||
|
url += `?beforeId=${encodeURIComponent(beforeId)}`;
|
||||||
|
}
|
||||||
|
const response = await fetch(url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: await getHeaders(this.activeDid),
|
headers: await getHeaders(this.activeDid),
|
||||||
});
|
});
|
||||||
@@ -502,14 +515,56 @@ export default class GiftedDialog extends Vue {
|
|||||||
|
|
||||||
const results = await response.json();
|
const results = await response.json();
|
||||||
if (results.data) {
|
if (results.data) {
|
||||||
this.projects = results.data;
|
// Ensure rowId is included in project data
|
||||||
|
const newProjects = results.data.map(
|
||||||
|
(plan: PlanData & { rowId?: string }) => ({
|
||||||
|
...plan,
|
||||||
|
rowId: plan.rowId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (beforeId) {
|
||||||
|
// Pagination: append new projects
|
||||||
|
this.projects.push(...newProjects);
|
||||||
|
} else {
|
||||||
|
// Initial load: replace array
|
||||||
|
this.projects = newProjects;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error loading projects:", error);
|
logger.error("Error loading projects:", error);
|
||||||
this.safeNotify.error("Failed to load projects", TIMEOUTS.STANDARD);
|
this.safeNotify.error("Failed to load projects", TIMEOUTS.STANDARD);
|
||||||
|
// Don't clear existing projects if this was a pagination request
|
||||||
|
if (!beforeId) {
|
||||||
|
this.projects = [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle loading more projects when EntityGrid reaches the end
|
||||||
|
* Called by EntitySelectionStep via loadMoreCallback
|
||||||
|
* @param entities - Current array of projects
|
||||||
|
*/
|
||||||
|
async handleLoadMoreProjects(
|
||||||
|
entities: Array<{
|
||||||
|
handleId: string;
|
||||||
|
rowId?: string;
|
||||||
|
}>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (entities.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastProject = entities[entities.length - 1];
|
||||||
|
if (!lastProject.rowId) {
|
||||||
|
// No rowId means we can't paginate - likely end of data
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.loadProjects(lastProject.rowId);
|
||||||
|
}
|
||||||
|
|
||||||
selectProject(project: PlanData) {
|
selectProject(project: PlanData) {
|
||||||
this.giver = {
|
this.giver = {
|
||||||
did: project.handleId,
|
did: project.handleId,
|
||||||
|
|||||||
140
src/components/MeetingProjectDialog.vue
Normal file
140
src/components/MeetingProjectDialog.vue
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="visible" class="dialog-overlay">
|
||||||
|
<div class="dialog">
|
||||||
|
<!-- Header -->
|
||||||
|
<h2 class="text-lg font-semibold leading-[1.25] mb-4">Select Project</h2>
|
||||||
|
|
||||||
|
<!-- EntityGrid for projects -->
|
||||||
|
<EntityGrid
|
||||||
|
:entity-type="'projects'"
|
||||||
|
:entities="allProjects"
|
||||||
|
:active-did="activeDid"
|
||||||
|
:all-my-dids="allMyDids"
|
||||||
|
:all-contacts="allContacts"
|
||||||
|
:conflict-checker="() => false"
|
||||||
|
:show-you-entity="false"
|
||||||
|
:show-unnamed-entity="false"
|
||||||
|
:notify="notify"
|
||||||
|
:conflict-context="'project'"
|
||||||
|
:load-more-callback="loadMoreCallback"
|
||||||
|
@entity-selected="handleEntitySelected"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Cancel Button -->
|
||||||
|
<div class="flex gap-2 mt-4">
|
||||||
|
<button
|
||||||
|
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
|
||||||
|
@click="handleCancel"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
|
||||||
|
import EntityGrid from "./EntityGrid.vue";
|
||||||
|
import { Contact } from "../db/tables/contacts";
|
||||||
|
import { PlanData } from "../interfaces/records";
|
||||||
|
import { NotificationIface } from "../constants/app";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MeetingProjectDialog - Dialog for selecting a project link for a meeting
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - EntityGrid integration for project selection
|
||||||
|
* - No special entities (You, Unnamed)
|
||||||
|
* - Immediate assignment on project selection
|
||||||
|
* - Cancel button to close without selection
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
components: {
|
||||||
|
EntityGrid,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export default class MeetingProjectDialog extends Vue {
|
||||||
|
/** Whether the dialog is visible */
|
||||||
|
visible = false;
|
||||||
|
|
||||||
|
/** Array of available projects */
|
||||||
|
@Prop({ required: true })
|
||||||
|
allProjects!: PlanData[];
|
||||||
|
|
||||||
|
/** Active user's DID */
|
||||||
|
@Prop({ required: true })
|
||||||
|
activeDid!: string;
|
||||||
|
|
||||||
|
/** All user's DIDs */
|
||||||
|
@Prop({ required: true })
|
||||||
|
allMyDids!: string[];
|
||||||
|
|
||||||
|
/** All contacts */
|
||||||
|
@Prop({ required: true })
|
||||||
|
allContacts!: Contact[];
|
||||||
|
|
||||||
|
/** Notification function from parent component */
|
||||||
|
@Prop()
|
||||||
|
notify?: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
|
||||||
|
/** Callback function to load more projects from server */
|
||||||
|
@Prop()
|
||||||
|
loadMoreCallback?: (entities: PlanData[]) => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle entity selection from EntityGrid
|
||||||
|
* Immediately assigns the selected project and closes the dialog
|
||||||
|
*/
|
||||||
|
handleEntitySelected(event: {
|
||||||
|
type: "person" | "project";
|
||||||
|
data: Contact | PlanData;
|
||||||
|
}) {
|
||||||
|
const project = event.data as PlanData;
|
||||||
|
this.emitAssign(project);
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle cancel button click
|
||||||
|
*/
|
||||||
|
handleCancel(): void {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the dialog
|
||||||
|
*/
|
||||||
|
open(): void {
|
||||||
|
this.visible = true;
|
||||||
|
this.emitOpen();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the dialog
|
||||||
|
*/
|
||||||
|
close(): void {
|
||||||
|
this.visible = false;
|
||||||
|
this.emitClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit methods using @Emit decorator
|
||||||
|
|
||||||
|
@Emit("assign")
|
||||||
|
emitAssign(project: PlanData): PlanData {
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Emit("open")
|
||||||
|
emitOpen(): void {
|
||||||
|
// Emit when dialog opens
|
||||||
|
}
|
||||||
|
|
||||||
|
@Emit("close")
|
||||||
|
emitClose(): void {
|
||||||
|
// Emit when dialog closes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -8,7 +8,7 @@ issuer information. * * @author Matthew Raymer */
|
|||||||
>
|
>
|
||||||
<ProjectIcon
|
<ProjectIcon
|
||||||
:entity-id="project.handleId"
|
:entity-id="project.handleId"
|
||||||
:icon-size="48"
|
:icon-size="30"
|
||||||
:image-url="project.image"
|
:image-url="project.image"
|
||||||
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
|
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
|
||||||
/>
|
/>
|
||||||
|
|||||||
117
src/components/ProjectRepresentativeDialog.vue
Normal file
117
src/components/ProjectRepresentativeDialog.vue
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="visible" class="dialog-overlay">
|
||||||
|
<div class="dialog">
|
||||||
|
<!-- Header -->
|
||||||
|
<h2 class="text-lg font-semibold leading-[1.25] mb-4">
|
||||||
|
Select Representative
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- EntityGrid for contacts -->
|
||||||
|
<EntityGrid
|
||||||
|
:entity-type="'people'"
|
||||||
|
:entities="allContacts"
|
||||||
|
:active-did="activeDid"
|
||||||
|
:all-my-dids="allMyDids"
|
||||||
|
:all-contacts="allContacts"
|
||||||
|
:conflict-checker="() => false"
|
||||||
|
:show-you-entity="false"
|
||||||
|
:show-unnamed-entity="false"
|
||||||
|
:notify="notify"
|
||||||
|
:conflict-context="'representative'"
|
||||||
|
@entity-selected="handleEntitySelected"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Cancel Button -->
|
||||||
|
<div class="flex gap-2 mt-4">
|
||||||
|
<button
|
||||||
|
class="block w-full text-center text-md uppercase bg-gradient-to-b from-slate-400 to-slate-700 shadow-[inset_0_-1px_0_0_rgba(0,0,0,0.5)] text-white px-2 py-2 rounded-md"
|
||||||
|
@click="handleCancel"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { Component, Prop, Vue, Emit } from "vue-facing-decorator";
|
||||||
|
import EntityGrid from "./EntityGrid.vue";
|
||||||
|
import { Contact } from "../db/tables/contacts";
|
||||||
|
import { NotificationIface } from "../constants/app";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ProjectRepresentativeDialog - Dialog for selecting an authorized representative
|
||||||
|
*
|
||||||
|
* Features:
|
||||||
|
* - EntityGrid integration for contact selection
|
||||||
|
* - No special entities (You, Unnamed)
|
||||||
|
* - Immediate assignment on contact selection
|
||||||
|
* - Cancel button to close without selection
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
components: {
|
||||||
|
EntityGrid,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export default class ProjectRepresentativeDialog extends Vue {
|
||||||
|
/** Whether the dialog is visible */
|
||||||
|
visible = false;
|
||||||
|
|
||||||
|
/** Array of available contacts */
|
||||||
|
@Prop({ required: true })
|
||||||
|
allContacts!: Contact[];
|
||||||
|
|
||||||
|
/** Active user's DID */
|
||||||
|
@Prop({ required: true })
|
||||||
|
activeDid!: string;
|
||||||
|
|
||||||
|
/** All user's DIDs */
|
||||||
|
@Prop({ required: true })
|
||||||
|
allMyDids!: string[];
|
||||||
|
|
||||||
|
/** Notification function from parent component */
|
||||||
|
@Prop()
|
||||||
|
notify?: (notification: NotificationIface, timeout?: number) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle entity selection from EntityGrid
|
||||||
|
* Immediately assigns the selected contact and closes the dialog
|
||||||
|
*/
|
||||||
|
handleEntitySelected(event: { type: "person" | "project"; data: Contact }) {
|
||||||
|
const contact = event.data as Contact;
|
||||||
|
this.emitAssign(contact);
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle cancel button click
|
||||||
|
*/
|
||||||
|
handleCancel(): void {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the dialog
|
||||||
|
*/
|
||||||
|
open(): void {
|
||||||
|
this.visible = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the dialog
|
||||||
|
*/
|
||||||
|
close(): void {
|
||||||
|
this.visible = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit methods using @Emit decorator
|
||||||
|
|
||||||
|
@Emit("assign")
|
||||||
|
emitAssign(contact: Contact): Contact {
|
||||||
|
return contact;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped></style>
|
||||||
@@ -60,12 +60,60 @@
|
|||||||
</div>
|
</div>
|
||||||
<ImageMethodDialog ref="imageDialog" default-camera-mode="environment" />
|
<ImageMethodDialog ref="imageDialog" default-camera-mode="environment" />
|
||||||
|
|
||||||
<input
|
<!-- Authorized Representative Selection -->
|
||||||
v-model="agentDid"
|
<div class="w-full flex items-stretch my-4">
|
||||||
type="text"
|
<div
|
||||||
placeholder="Other Authorized Representative"
|
class="flex items-center gap-2 grow border border-slate-400 border-r-0 last:border-r px-3 py-2 rounded-l last:rounded overflow-hidden cursor-pointer hover:bg-slate-100"
|
||||||
class="mt-4 block w-full rounded border border-slate-400 px-3 py-2"
|
@click="openRepresentativeDialog"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<EntityIcon
|
||||||
|
v-if="selectedRepresentative"
|
||||||
|
:contact="selectedRepresentative"
|
||||||
|
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
|
||||||
|
/>
|
||||||
|
<font-awesome v-else icon="user" class="text-slate-400" />
|
||||||
|
</div>
|
||||||
|
<div class="overflow-hidden">
|
||||||
|
<div
|
||||||
|
:class="{
|
||||||
|
'text-sm font-semibold': selectedRepresentative,
|
||||||
|
'text-slate-400': !selectedRepresentative,
|
||||||
|
}"
|
||||||
|
class="truncate"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
selectedRepresentative
|
||||||
|
? selectedRepresentative.name || AppString.NO_CONTACT_NAME
|
||||||
|
: "Assign Authorized Representative…"
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="selectedRepresentative"
|
||||||
|
class="text-xs text-slate-500 truncate"
|
||||||
|
>
|
||||||
|
{{ agentDid }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="selectedRepresentative"
|
||||||
|
class="text-rose-600 px-3 py-2 border border-slate-400 border-l-0 rounded-r hover:bg-rose-600 hover:text-white hover:border-rose-600"
|
||||||
|
@click="unsetRepresentative"
|
||||||
|
>
|
||||||
|
<font-awesome icon="trash-can" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ProjectRepresentativeDialog
|
||||||
|
ref="representativeDialog"
|
||||||
|
:all-contacts="allContacts"
|
||||||
|
:active-did="activeDid"
|
||||||
|
:all-my-dids="allMyDids"
|
||||||
|
:notify="$notify"
|
||||||
|
@assign="handleRepresentativeAssigned"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<p v-if="shouldShowOwnershipWarning">
|
<p v-if="shouldShowOwnershipWarning">
|
||||||
<span class="text-red-500">Beware!</span>
|
<span class="text-red-500">Beware!</span>
|
||||||
@@ -232,9 +280,12 @@ import { LMap, LMarker, LTileLayer } from "@vue-leaflet/vue-leaflet";
|
|||||||
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
||||||
import { LeafletMouseEvent } from "leaflet";
|
import { LeafletMouseEvent } from "leaflet";
|
||||||
|
|
||||||
|
import EntityIcon from "../components/EntityIcon.vue";
|
||||||
import ImageMethodDialog from "../components/ImageMethodDialog.vue";
|
import ImageMethodDialog from "../components/ImageMethodDialog.vue";
|
||||||
|
import ProjectRepresentativeDialog from "../components/ProjectRepresentativeDialog.vue";
|
||||||
import QuickNav from "../components/QuickNav.vue";
|
import QuickNav from "../components/QuickNav.vue";
|
||||||
import {
|
import {
|
||||||
|
AppString,
|
||||||
DEFAULT_IMAGE_API_SERVER,
|
DEFAULT_IMAGE_API_SERVER,
|
||||||
DEFAULT_PARTNER_API_SERVER,
|
DEFAULT_PARTNER_API_SERVER,
|
||||||
NotificationIface,
|
NotificationIface,
|
||||||
@@ -268,6 +319,7 @@ import {
|
|||||||
retrieveAccountCount,
|
retrieveAccountCount,
|
||||||
retrieveFullyDecryptedAccount,
|
retrieveFullyDecryptedAccount,
|
||||||
} from "../libs/util";
|
} from "../libs/util";
|
||||||
|
import { Contact } from "../db/tables/contacts";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EventTemplate,
|
EventTemplate,
|
||||||
@@ -323,7 +375,15 @@ import { logger } from "../utils/logger";
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
components: { ImageMethodDialog, LMap, LMarker, LTileLayer, QuickNav },
|
components: {
|
||||||
|
EntityIcon,
|
||||||
|
ImageMethodDialog,
|
||||||
|
ProjectRepresentativeDialog,
|
||||||
|
LMap,
|
||||||
|
LMarker,
|
||||||
|
LTileLayer,
|
||||||
|
QuickNav,
|
||||||
|
},
|
||||||
mixins: [PlatformServiceMixin],
|
mixins: [PlatformServiceMixin],
|
||||||
})
|
})
|
||||||
export default class NewEditProjectView extends Vue {
|
export default class NewEditProjectView extends Vue {
|
||||||
@@ -334,6 +394,9 @@ export default class NewEditProjectView extends Vue {
|
|||||||
// Notification helpers
|
// Notification helpers
|
||||||
private notify!: ReturnType<typeof createNotifyHelpers>;
|
private notify!: ReturnType<typeof createNotifyHelpers>;
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
AppString = AppString;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display error notification to user
|
* Display error notification to user
|
||||||
* Provides consistent error messaging with 5-second timeout
|
* Provides consistent error messaging with 5-second timeout
|
||||||
@@ -346,6 +409,8 @@ export default class NewEditProjectView extends Vue {
|
|||||||
// Component state properties
|
// Component state properties
|
||||||
activeDid = "";
|
activeDid = "";
|
||||||
agentDid = "";
|
agentDid = "";
|
||||||
|
allContacts: Array<Contact> = [];
|
||||||
|
allMyDids: string[] = [];
|
||||||
apiServer = "";
|
apiServer = "";
|
||||||
endDateInput?: string;
|
endDateInput?: string;
|
||||||
endTimeInput?: string;
|
endTimeInput?: string;
|
||||||
@@ -392,16 +457,24 @@ export default class NewEditProjectView extends Vue {
|
|||||||
const activeIdentity = await (this as any).$getActiveIdentity();
|
const activeIdentity = await (this as any).$getActiveIdentity();
|
||||||
this.activeDid = activeIdentity.activeDid || "";
|
this.activeDid = activeIdentity.activeDid || "";
|
||||||
|
|
||||||
|
// Get all user's DIDs
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
this.allMyDids = await (this as any).$getAllAccountDids();
|
||||||
|
|
||||||
|
// Load contacts sorted by date added (newest first) for consistent "Recently Added" display
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
this.allContacts = await (this as any).$contactsByDateAdded();
|
||||||
|
|
||||||
this.apiServer = settings.apiServer || "";
|
this.apiServer = settings.apiServer || "";
|
||||||
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
|
this.showGeneralAdvanced = !!settings.showGeneralAdvanced;
|
||||||
|
|
||||||
this.projectId = (this.$route.query["projectId"] as string) || "";
|
this.projectId = (this.$route.query["projectId"] as string) || "";
|
||||||
|
|
||||||
if (this.projectId) {
|
if (this.isSavedProject()) {
|
||||||
if (this.numAccounts === 0) {
|
if (this.numAccounts === 0) {
|
||||||
this.notify.error(NOTIFY_PROJECT_ACCOUNT_LOADING_ERROR.message);
|
this.notify.error(NOTIFY_PROJECT_ACCOUNT_LOADING_ERROR.message);
|
||||||
} else {
|
} else {
|
||||||
this.loadProject(this.activeDid);
|
this.loadProject(this.activeDid, this.projectId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -411,11 +484,9 @@ export default class NewEditProjectView extends Vue {
|
|||||||
* Retrieves project information from the API and populates form fields
|
* Retrieves project information from the API and populates form fields
|
||||||
* @param userDid - User's decentralized identifier
|
* @param userDid - User's decentralized identifier
|
||||||
*/
|
*/
|
||||||
async loadProject(userDid: string) {
|
async loadProject(userDid: string, projectId: string) {
|
||||||
const url =
|
const url =
|
||||||
this.apiServer +
|
this.apiServer + "/api/claim/byHandle/" + encodeURIComponent(projectId);
|
||||||
"/api/claim/byHandle/" +
|
|
||||||
encodeURIComponent(this.projectId);
|
|
||||||
const headers = await getHeaders(userDid);
|
const headers = await getHeaders(userDid);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -432,6 +503,12 @@ export default class NewEditProjectView extends Vue {
|
|||||||
}
|
}
|
||||||
if (this.fullClaim?.agent?.identifier) {
|
if (this.fullClaim?.agent?.identifier) {
|
||||||
this.agentDid = this.fullClaim.agent.identifier;
|
this.agentDid = this.fullClaim.agent.identifier;
|
||||||
|
if (this.activeDid !== this.projectIssuerDid) {
|
||||||
|
this.agentDid = this.projectIssuerDid;
|
||||||
|
this.notify.warning(
|
||||||
|
"You were previously the agent, so the agent has been set to the previous owner. You can change it.",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (this.fullClaim.startTime) {
|
if (this.fullClaim.startTime) {
|
||||||
const localDateTime = DateTime.fromISO(
|
const localDateTime = DateTime.fromISO(
|
||||||
@@ -536,7 +613,7 @@ export default class NewEditProjectView extends Vue {
|
|||||||
private async saveProject() {
|
private async saveProject() {
|
||||||
// Make a claim
|
// Make a claim
|
||||||
const vcClaim: PlanActionClaim = this.fullClaim;
|
const vcClaim: PlanActionClaim = this.fullClaim;
|
||||||
if (this.projectId) {
|
if (this.isSavedProject()) {
|
||||||
vcClaim.lastClaimId = this.lastClaimJwtId;
|
vcClaim.lastClaimId = this.lastClaimJwtId;
|
||||||
}
|
}
|
||||||
if (this.agentDid) {
|
if (this.agentDid) {
|
||||||
@@ -870,6 +947,10 @@ export default class NewEditProjectView extends Vue {
|
|||||||
this.longitude = event.latlng.lng;
|
this.longitude = event.latlng.lng;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isSavedProject(): boolean {
|
||||||
|
return !!this.projectId;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Computed property for character count display
|
* Computed property for character count display
|
||||||
* Shows current description length and maximum character limit
|
* Shows current description length and maximum character limit
|
||||||
@@ -885,6 +966,7 @@ export default class NewEditProjectView extends Vue {
|
|||||||
*/
|
*/
|
||||||
get shouldShowOwnershipWarning(): boolean {
|
get shouldShowOwnershipWarning(): boolean {
|
||||||
return (
|
return (
|
||||||
|
this.isSavedProject() &&
|
||||||
this.activeDid !== this.projectIssuerDid &&
|
this.activeDid !== this.projectIssuerDid &&
|
||||||
this.agentDid !== this.projectIssuerDid
|
this.agentDid !== this.projectIssuerDid
|
||||||
);
|
);
|
||||||
@@ -961,5 +1043,37 @@ export default class NewEditProjectView extends Vue {
|
|||||||
get shouldShowSpinner(): boolean {
|
get shouldShowSpinner(): boolean {
|
||||||
return !this.isHiddenSpinner;
|
return !this.isHiddenSpinner;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed property for selected representative contact
|
||||||
|
* Derives the contact from agentDid by finding it in allContacts
|
||||||
|
*/
|
||||||
|
get selectedRepresentative(): Contact | null {
|
||||||
|
if (!this.agentDid) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.allContacts.find((c) => c.did === this.agentDid) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the representative selection dialog
|
||||||
|
*/
|
||||||
|
openRepresentativeDialog(): void {
|
||||||
|
(this.$refs.representativeDialog as ProjectRepresentativeDialog).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle representative assignment from dialog
|
||||||
|
*/
|
||||||
|
handleRepresentativeAssigned(contact: Contact): void {
|
||||||
|
this.agentDid = contact.did;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unset the representative and revert to initial state
|
||||||
|
*/
|
||||||
|
unsetRepresentative(): void {
|
||||||
|
this.agentDid = "";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -186,16 +186,59 @@
|
|||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
for="projectLink"
|
for="projectLink"
|
||||||
class="block text-sm font-medium text-gray-700"
|
class="block text-sm font-medium text-gray-700 mb-1"
|
||||||
>Project Link</label
|
>Project Link</label
|
||||||
>
|
>
|
||||||
<input
|
<div class="w-full flex items-stretch">
|
||||||
id="projectLink"
|
<div
|
||||||
v-model="newOrUpdatedMeetingInputs.projectLink"
|
class="flex items-center gap-2 grow border border-slate-400 border-r-0 last:border-r px-3 py-2 rounded-l last:rounded overflow-hidden cursor-pointer hover:bg-slate-100"
|
||||||
type="text"
|
@click="openProjectLinkDialog"
|
||||||
class="mt-1 block w-full rounded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none"
|
>
|
||||||
placeholder="Project ID"
|
<div>
|
||||||
/>
|
<ProjectIcon
|
||||||
|
v-if="selectedProject"
|
||||||
|
:entity-id="selectedProject.handleId"
|
||||||
|
:icon-size="30"
|
||||||
|
:image-url="selectedProject.image"
|
||||||
|
class="!size-[2rem] shrink-0 border border-slate-300 bg-white overflow-hidden rounded-full"
|
||||||
|
/>
|
||||||
|
<font-awesome
|
||||||
|
v-else
|
||||||
|
icon="folder-open"
|
||||||
|
class="text-slate-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-hidden">
|
||||||
|
<div
|
||||||
|
:class="{
|
||||||
|
'text-sm font-semibold': selectedProject,
|
||||||
|
'text-slate-400': !selectedProject,
|
||||||
|
}"
|
||||||
|
class="truncate"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
selectedProject
|
||||||
|
? selectedProject.name || "Unnamed Project"
|
||||||
|
: "Select Project…"
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="selectedProject"
|
||||||
|
class="text-xs text-slate-500 truncate"
|
||||||
|
>
|
||||||
|
<font-awesome icon="user" class="text-slate-400" />
|
||||||
|
{{ selectedProjectIssuerName }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="selectedProject"
|
||||||
|
class="text-rose-600 px-3 py-2 border border-slate-400 border-l-0 rounded-r hover:bg-rose-600 hover:text-white hover:border-rose-600"
|
||||||
|
@click="unsetProjectLink"
|
||||||
|
>
|
||||||
|
<font-awesome icon="trash-can" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -224,6 +267,19 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<MeetingProjectDialog
|
||||||
|
ref="meetingProjectDialog"
|
||||||
|
:all-projects="allProjects"
|
||||||
|
:active-did="activeDid"
|
||||||
|
:all-my-dids="allMyDids"
|
||||||
|
:all-contacts="allContacts"
|
||||||
|
:notify="$notify"
|
||||||
|
:load-more-callback="handleLoadMoreProjects"
|
||||||
|
@assign="handleProjectLinkAssigned"
|
||||||
|
@open="handleDialogOpen"
|
||||||
|
@close="handleDialogClose"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- Members Section -->
|
<!-- Members Section -->
|
||||||
<div
|
<div
|
||||||
v-if="!isLoading && currentMeeting != null && !!currentMeeting.password"
|
v-if="!isLoading && currentMeeting != null && !!currentMeeting.password"
|
||||||
@@ -254,6 +310,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<MembersList
|
<MembersList
|
||||||
|
ref="membersList"
|
||||||
:password="currentMeeting.password || ''"
|
:password="currentMeeting.password || ''"
|
||||||
:show-organizer-tools="true"
|
:show-organizer-tools="true"
|
||||||
class="mt-4"
|
class="mt-4"
|
||||||
@@ -292,10 +349,13 @@ import { RouteLocationNormalizedLoaded, Router } from "vue-router";
|
|||||||
import QuickNav from "../components/QuickNav.vue";
|
import QuickNav from "../components/QuickNav.vue";
|
||||||
import TopMessage from "../components/TopMessage.vue";
|
import TopMessage from "../components/TopMessage.vue";
|
||||||
import MembersList from "../components/MembersList.vue";
|
import MembersList from "../components/MembersList.vue";
|
||||||
|
import MeetingProjectDialog from "../components/MeetingProjectDialog.vue";
|
||||||
|
import ProjectIcon from "../components/ProjectIcon.vue";
|
||||||
import {
|
import {
|
||||||
errorStringForLog,
|
errorStringForLog,
|
||||||
getHeaders,
|
getHeaders,
|
||||||
serverMessageForUser,
|
serverMessageForUser,
|
||||||
|
didInfo,
|
||||||
} from "../libs/endorserServer";
|
} from "../libs/endorserServer";
|
||||||
import { encryptMessage } from "../libs/crypto";
|
import { encryptMessage } from "../libs/crypto";
|
||||||
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
import { PlatformServiceMixin } from "@/utils/PlatformServiceMixin";
|
||||||
@@ -309,6 +369,8 @@ import {
|
|||||||
NOTIFY_MEETING_DELETED,
|
NOTIFY_MEETING_DELETED,
|
||||||
NOTIFY_MEETING_LINK_COPIED,
|
NOTIFY_MEETING_LINK_COPIED,
|
||||||
} from "@/constants/notifications";
|
} from "@/constants/notifications";
|
||||||
|
import { PlanData } from "../interfaces/records";
|
||||||
|
import { Contact } from "../db/tables/contacts";
|
||||||
interface ServerMeeting {
|
interface ServerMeeting {
|
||||||
groupId: number; // from the server
|
groupId: number; // from the server
|
||||||
name: string; // to & from the server
|
name: string; // to & from the server
|
||||||
@@ -331,6 +393,8 @@ interface MeetingSetupInputs {
|
|||||||
QuickNav,
|
QuickNav,
|
||||||
TopMessage,
|
TopMessage,
|
||||||
MembersList,
|
MembersList,
|
||||||
|
MeetingProjectDialog,
|
||||||
|
ProjectIcon,
|
||||||
},
|
},
|
||||||
mixins: [PlatformServiceMixin],
|
mixins: [PlatformServiceMixin],
|
||||||
})
|
})
|
||||||
@@ -354,6 +418,10 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
isRegistered = false;
|
isRegistered = false;
|
||||||
showDeleteConfirm = false;
|
showDeleteConfirm = false;
|
||||||
fullName = "";
|
fullName = "";
|
||||||
|
allProjects: PlanData[] = [];
|
||||||
|
allContacts: Contact[] = [];
|
||||||
|
allMyDids: string[] = [];
|
||||||
|
selectedProjectData: PlanData | null = null;
|
||||||
get minDateTime() {
|
get minDateTime() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
now.setMinutes(now.getMinutes() + 5); // Set minimum 5 minutes in the future
|
now.setMinutes(now.getMinutes() + 5); // Set minimum 5 minutes in the future
|
||||||
@@ -370,7 +438,17 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
this.fullName = settings?.firstName || "";
|
this.fullName = settings?.firstName || "";
|
||||||
this.isRegistered = !!settings?.isRegistered;
|
this.isRegistered = !!settings?.isRegistered;
|
||||||
|
|
||||||
|
// Load contacts and DIDs for dialog
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
this.allContacts = await (this as any).$contactsByDateAdded();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
this.allMyDids = await (this as any).$getAllAccountDids();
|
||||||
|
|
||||||
await this.fetchCurrentMeeting();
|
await this.fetchCurrentMeeting();
|
||||||
|
|
||||||
|
// Ensure selected project is loaded if projectLink exists
|
||||||
|
await this.ensureSelectedProjectLoaded();
|
||||||
|
|
||||||
this.isLoading = false;
|
this.isLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -442,6 +520,54 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the selected project is loaded if projectLink exists
|
||||||
|
*/
|
||||||
|
async ensureSelectedProjectLoaded(): Promise<void> {
|
||||||
|
const projectLink =
|
||||||
|
this.currentMeeting?.projectLink ||
|
||||||
|
this.newOrUpdatedMeetingInputs?.projectLink;
|
||||||
|
|
||||||
|
if (!projectLink) {
|
||||||
|
this.selectedProjectData = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.fetchProjectByHandleId(projectLink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single project by handleId
|
||||||
|
* @param handleId - The project handleId to fetch
|
||||||
|
*/
|
||||||
|
async fetchProjectByHandleId(handleId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const headers = await getHeaders(this.activeDid);
|
||||||
|
const url = `${this.apiServer}/api/v2/report/plans?handleId=${encodeURIComponent(handleId)}`;
|
||||||
|
const resp = await this.axios.get(url, { headers });
|
||||||
|
|
||||||
|
if (resp.status === 200 && resp.data.data && resp.data.data.length > 0) {
|
||||||
|
const project = resp.data.data[0];
|
||||||
|
this.selectedProjectData = {
|
||||||
|
name: project.name,
|
||||||
|
description: project.description,
|
||||||
|
image: project.image,
|
||||||
|
handleId: project.handleId,
|
||||||
|
issuerDid: project.issuerDid,
|
||||||
|
rowId: project.rowId,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
this.selectedProjectData = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.$logAndConsole(
|
||||||
|
"Error fetching project by handleId: " + errorStringForLog(error),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
this.selectedProjectData = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async createMeeting() {
|
async createMeeting() {
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
|
|
||||||
@@ -576,7 +702,7 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
startEditing() {
|
async startEditing() {
|
||||||
// Populate form with existing meeting data
|
// Populate form with existing meeting data
|
||||||
if (this.currentMeeting) {
|
if (this.currentMeeting) {
|
||||||
const localExpiresAt = new Date(this.currentMeeting.expiresAt);
|
const localExpiresAt = new Date(this.currentMeeting.expiresAt);
|
||||||
@@ -587,6 +713,10 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
password: this.currentMeeting.password || "",
|
password: this.currentMeeting.password || "",
|
||||||
projectLink: this.currentMeeting.projectLink || "",
|
projectLink: this.currentMeeting.projectLink || "",
|
||||||
};
|
};
|
||||||
|
// Ensure selected project is loaded if projectLink exists
|
||||||
|
if (this.currentMeeting.projectLink) {
|
||||||
|
await this.ensureSelectedProjectLoaded();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.$logError(
|
this.$logError(
|
||||||
"There is no current meeting to edit. We should never get here.",
|
"There is no current meeting to edit. We should never get here.",
|
||||||
@@ -594,9 +724,15 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cancelEditing() {
|
async cancelEditing() {
|
||||||
// Reset form data
|
// Reset form data
|
||||||
this.newOrUpdatedMeetingInputs = null;
|
this.newOrUpdatedMeetingInputs = null;
|
||||||
|
// Restore selected project from currentMeeting if it exists
|
||||||
|
if (this.currentMeeting?.projectLink) {
|
||||||
|
await this.ensureSelectedProjectLoaded();
|
||||||
|
} else {
|
||||||
|
this.selectedProjectData = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateMeeting() {
|
async updateMeeting() {
|
||||||
@@ -710,5 +846,159 @@ export default class OnboardMeetingView extends Vue {
|
|||||||
this.notify.error("Failed to copy meeting link to clipboard.");
|
this.notify.error("Failed to copy meeting link to clipboard.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load projects from the API
|
||||||
|
* @param beforeId - Optional rowId for pagination (loads projects before this ID)
|
||||||
|
*/
|
||||||
|
async loadProjects(beforeId?: string) {
|
||||||
|
try {
|
||||||
|
const headers = await getHeaders(this.activeDid);
|
||||||
|
let url = `${this.apiServer}/api/v2/report/plans`;
|
||||||
|
if (beforeId) {
|
||||||
|
url += `?beforeId=${encodeURIComponent(beforeId)}`;
|
||||||
|
}
|
||||||
|
const resp = await this.axios.get(url, { headers });
|
||||||
|
|
||||||
|
if (resp.status === 200 && resp.data.data) {
|
||||||
|
const newProjects = resp.data.data.map(
|
||||||
|
(plan: {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
image?: string;
|
||||||
|
handleId: string;
|
||||||
|
issuerDid: string;
|
||||||
|
rowId?: string;
|
||||||
|
}) => ({
|
||||||
|
name: plan.name,
|
||||||
|
description: plan.description,
|
||||||
|
image: plan.image,
|
||||||
|
handleId: plan.handleId,
|
||||||
|
issuerDid: plan.issuerDid,
|
||||||
|
rowId: plan.rowId,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (beforeId) {
|
||||||
|
// Pagination: append new projects
|
||||||
|
this.allProjects.push(...newProjects);
|
||||||
|
} else {
|
||||||
|
// Initial load: replace array
|
||||||
|
this.allProjects = newProjects;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.$logAndConsole(
|
||||||
|
"Error loading projects: " + errorStringForLog(error),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
// Don't show error to user - just leave projects empty (or keep existing if pagination)
|
||||||
|
if (!beforeId) {
|
||||||
|
this.allProjects = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle loading more projects when EntityGrid reaches the end
|
||||||
|
* Called by MeetingProjectDialog via loadMoreCallback
|
||||||
|
* @param entities - Current array of projects
|
||||||
|
*/
|
||||||
|
async handleLoadMoreProjects(
|
||||||
|
entities: Array<{
|
||||||
|
handleId: string;
|
||||||
|
rowId?: string;
|
||||||
|
}>,
|
||||||
|
): Promise<void> {
|
||||||
|
if (entities.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastProject = entities[entities.length - 1];
|
||||||
|
if (!lastProject.rowId) {
|
||||||
|
// No rowId means we can't paginate - likely end of data
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.loadProjects(lastProject.rowId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed property for selected project
|
||||||
|
* Returns the separately stored selected project data
|
||||||
|
*/
|
||||||
|
get selectedProject(): PlanData | null {
|
||||||
|
return this.selectedProjectData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed property for selected project issuer display name
|
||||||
|
* Uses didInfo to format the issuer name similar to ProjectCard
|
||||||
|
*/
|
||||||
|
get selectedProjectIssuerName(): string {
|
||||||
|
if (!this.selectedProject) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
return didInfo(
|
||||||
|
this.selectedProject.issuerDid,
|
||||||
|
this.activeDid,
|
||||||
|
this.allMyDids,
|
||||||
|
this.allContacts,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the project link selection dialog
|
||||||
|
*/
|
||||||
|
openProjectLinkDialog(): void {
|
||||||
|
(this.$refs.meetingProjectDialog as MeetingProjectDialog).open();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle project assignment from dialog
|
||||||
|
*/
|
||||||
|
handleProjectLinkAssigned(project: PlanData): void {
|
||||||
|
// Store the selected project directly
|
||||||
|
this.selectedProjectData = project;
|
||||||
|
|
||||||
|
if (this.newOrUpdatedMeetingInputs) {
|
||||||
|
this.newOrUpdatedMeetingInputs.projectLink = project.handleId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unset the project link and revert to initial state
|
||||||
|
*/
|
||||||
|
unsetProjectLink(): void {
|
||||||
|
this.selectedProjectData = null;
|
||||||
|
if (this.newOrUpdatedMeetingInputs) {
|
||||||
|
this.newOrUpdatedMeetingInputs.projectLink = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle dialog open event - stop auto-refresh in MembersList and load projects
|
||||||
|
*/
|
||||||
|
async handleDialogOpen(): Promise<void> {
|
||||||
|
const membersList = this.$refs.membersList as MembersList;
|
||||||
|
if (membersList) {
|
||||||
|
membersList.stopAutoRefresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load projects when dialog opens (if not already loaded)
|
||||||
|
if (this.allProjects.length === 0) {
|
||||||
|
await this.loadProjects();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle dialog close event - start auto-refresh in MembersList
|
||||||
|
*/
|
||||||
|
handleDialogClose(): void {
|
||||||
|
const membersList = this.$refs.membersList as MembersList;
|
||||||
|
if (membersList) {
|
||||||
|
membersList.startAutoRefresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user