Hello from MCP server
<template>
<BaseLayout title="Technician - Manage Organization">
<!-- Toolbar at top of screen -->
<Toolbar @help-clicked="openInfoModal" />
<div class="manage-container">
<h2 class="manage-title">Manage Organization</h2>
<!-- Organization Variables Section -->
<div class="section">
<h3 class="section-heading">Organization Variables</h3>
<p class="section-message">Configure pricing and operational variables for {{ org?.name }}</p>
<div class="variables-grid">
<div v-for="(value, key) in displayVariables" :key="key" class="variable-item">
<div class="variable-content">
<span class="variable-label">{{ key }}</span>
<div v-if="editingVariable !== key" class="variable-display">
<ShowCurrency
v-if="isCurrencyVariable(key)"
:currencyIn="orgVariables[key]"
/>
<span v-else class="variable-value">{{ orgVariables[key] }}</span>
</div>
<ion-input
v-if="editingVariable === key"
:value="value"
placeholder="Enter value"
@ionInput="updateVariable(key, $event.detail.value)"
@ionBlur="stopEditing(key)"
@keyup.enter="stopEditing(key)"
class="variable-input"
></ion-input>
</div>
<div class="variable-actions">
<ion-button
v-if="editingVariable !== key"
fill="clear"
size="small"
color="primary"
@click="startEditing(key)"
>
<ion-icon slot="icon-only" :icon="pencilOutline"></ion-icon>
</ion-button>
<ion-button
v-if="editingVariable === key"
fill="clear"
size="small"
color="success"
@click="stopEditing(key)"
>
<ion-icon slot="icon-only" :icon="checkmarkOutline"></ion-icon>
</ion-button>
</div>
</div>
</div>
</div>
<!-- User Management Section -->
<div class="section">
<h3 class="section-heading">User Management</h3>
<p class="section-message">Manage users and their roles within {{ org?.name }}</p>
<ion-loading
:is-open="loading"
message="Loading users..."
></ion-loading>
<div v-if="!loading && profiles && profiles.length > 0" class="users-grid">
<div v-for="profile in profiles" :key="profile.id" class="user-item">
<div class="user-avatar">
<ion-icon :icon="personOutline"></ion-icon>
</div>
<div class="user-content">
<span class="user-email">{{ (profile.expand as any)?.user?.email || "No email" }}</span>
<span class="user-name">{{ (profile.expand as any)?.user?.name || "Unknown User" }}</span>
</div>
<ion-select
:value="profile.roles"
:multiple="true"
placeholder="Select roles..."
@ionChange="updateUserRoles(profile, $event.detail.value)"
class="user-roles-select"
>
<ion-select-option
v-for="role in availableRoles"
:key="role.id"
:value="role.id"
>
{{ role.name }}
</ion-select-option>
</ion-select>
</div>
</div>
<div v-if="!loading && profiles?.length === 0" class="empty-message">
<p>No users found in this organization.</p>
</div>
</div>
<!-- Available Roles Section -->
<div class="section">
<h3 class="section-heading">Available Roles</h3>
<p class="section-message">Roles that can be assigned to users</p>
<div v-if="availableRoles && availableRoles.length > 0" class="roles-grid">
<div v-for="role in availableRoles" :key="role.id" class="role-item">
<ion-icon :icon="buildOutline" class="role-icon"></ion-icon>
<span class="role-name">{{ role.name }}</span>
</div>
</div>
<div v-if="availableRoles?.length === 0" class="empty-message">
<p>No roles defined for this organization.</p>
</div>
</div>
<!-- Invite Link Section -->
<div class="section">
<h3 class="section-heading">Invite Link</h3>
<p class="section-message">Share this link for users to request to join the organization</p>
<div class="invite-box">
<pre class="invite-link">https://pricebookplatform.com/organization/{{ org?.id }}/join</pre>
<ion-button expand="block" color="secondary" @click="copyOrgLink">
Copy Link
</ion-button>
</div>
</div>
</div>
<!-- Info Modal -->
<ion-modal :is-open="isInfoModalOpen" @didDismiss="closeInfoModal">
<ion-header>
<ion-toolbar>
<ion-title>Organization Data</ion-title>
<ion-buttons slot="end">
<ion-button @click="closeInfoModal">Close</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="info-content">
<pre>{{ JSON.stringify({ org, orgVariables, profiles, availableRoles }, null, 2) }}</pre>
</div>
</ion-content>
</ion-modal>
</BaseLayout>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { getApi } from "@/dataAccess/getApi";
import {
IonIcon,
IonLoading,
IonInput,
IonButton,
IonSelect,
IonSelectOption,
IonModal,
IonHeader,
IonToolbar,
IonTitle,
IonButtons,
IonContent,
toastController,
} from "@ionic/vue";
import {
personOutline,
buildOutline,
pencilOutline,
checkmarkOutline,
} from "ionicons/icons";
import BaseLayout from "@/components/BaseLayout.vue";
import Toolbar from "@/components/Toolbar.vue";
import ShowCurrency from "@/components/ShowCurrency.vue";
import {
convertToBaseCurrency,
convertFromBaseCurrency,
} from "@/utils/currencyConverter";
import { useCurrencyStore } from "@/stores/currency";
import { usePreferencesStore } from "@/stores/preferences";
import {
OrganizationsRecord,
ProfilesResponse,
RolesResponse,
} from "@/pocketbase-types";
const route = useRoute();
const org = ref<OrganizationsRecord>();
const orgVariables = ref<Record<string, any>>({});
const displayVariables = ref<Record<string, any>>({});
const editingVariable = ref<string | null>(null);
const profiles = ref<ProfilesResponse[]>([]);
const availableRoles = ref<RolesResponse[]>([]);
const loading = ref(true);
const isInfoModalOpen = ref(false);
const currencyStore = useCurrencyStore();
const preferencesStore = usePreferencesStore();
let pb: any = null;
let saveTimeout: ReturnType<typeof setTimeout> | null = null;
// List of variables that should be displayed as currency
const currencyVariables = ["hourlyFee", "serviceCallFee"];
const isCurrencyVariable = (key: string): boolean => {
return currencyVariables.includes(key);
};
const openInfoModal = () => {
isInfoModalOpen.value = true;
};
const closeInfoModal = () => {
isInfoModalOpen.value = false;
};
const copyOrgLink = async () => {
try {
const text = `https://pricebookplatform.com/organization/${org.value?.id}/join`;
await navigator.clipboard.writeText(text);
const toast = await toastController.create({
message: "Invite link copied to clipboard!",
duration: 2000,
color: "success",
});
await toast.present();
} catch (err) {
console.error("Copy failed", err);
const toast = await toastController.create({
message: "Failed to copy link",
duration: 2000,
color: "danger",
});
await toast.present();
}
};
const startEditing = (key: string) => {
editingVariable.value = key;
// For currency variables, convert to user currency for editing
if (isCurrencyVariable(key)) {
const userCurrencyValue = convertFromBaseCurrency(orgVariables.value[key]);
displayVariables.value[key] = userCurrencyValue;
} else {
displayVariables.value[key] = orgVariables.value[key];
}
};
const stopEditing = async (key: string) => {
// Prevent double execution
if (editingVariable.value !== key) {
return;
}
// For currency variables, convert back to base currency
if (isCurrencyVariable(key)) {
const baseCurrencyValue = convertToBaseCurrency(
displayVariables.value[key],
);
orgVariables.value[key] = baseCurrencyValue;
} else {
// Parse numeric values properly to avoid saving as strings
const value = displayVariables.value[key];
if (!isNaN(value) && value !== "" && value !== null) {
orgVariables.value[key] = parseFloat(value);
} else {
orgVariables.value[key] = value;
}
}
editingVariable.value = null;
await saveVariables();
};
const updateVariable = (key: string, value: any) => {
displayVariables.value[key] = value;
};
const saveVariables = async () => {
// Clear any existing timeout
if (saveTimeout) {
clearTimeout(saveTimeout);
}
// Debounce the save operation
saveTimeout = setTimeout(async () => {
try {
await pb.collection("organizations").update(route.params.id, {
variables: orgVariables.value,
});
const toast = await toastController.create({
message: "Organization variables updated successfully",
duration: 2000,
color: "success",
});
await toast.present();
} catch (error) {
console.error("Error updating organization variables:", error);
const toast = await toastController.create({
message: "Error updating organization variables",
duration: 3000,
color: "danger",
});
await toast.present();
}
}, 300); // 300ms debounce
};
const updateUserRoles = async (
profile: ProfilesResponse,
roleIds: string[],
) => {
try {
// Update the profile in the backend
await pb.collection("profiles").update(profile.id, {
roles: roleIds,
});
// Update the local state to reflect the change
const profileIndex = profiles.value.findIndex((p) => p.id === profile.id);
if (profileIndex !== -1) {
profiles.value[profileIndex].roles = roleIds;
}
const toast = await toastController.create({
message: "User roles updated successfully",
duration: 2000,
color: "success",
});
await toast.present();
} catch (error) {
console.error("Error updating user roles:", error);
const toast = await toastController.create({
message: "Error updating user roles",
duration: 3000,
color: "danger",
});
await toast.present();
}
};
onMounted(async () => {
try {
const api = await getApi();
pb = api.pb;
// Initialize currency stores
await preferencesStore.getPreferences();
await currencyStore.getCurrencies();
await currencyStore.getRates();
// Get organization details
org.value = await pb.collection("organizations").getOne(route.params.id);
// Initialize organization variables from database
orgVariables.value = org.value?.variables || {};
// Initialize display variables (copy for non-currency, keep base values for currency)
displayVariables.value = { ...orgVariables.value };
// Get all profiles (users) in this organization with user details expanded
profiles.value = await pb.collection("profiles").getFullList({
filter: `org="${route.params.id}"`,
expand: "user,roles",
});
// Get all roles available in this organization
availableRoles.value = await pb.collection("roles").getFullList({
filter: `org="${route.params.id}"`,
});
} catch (error) {
console.error("Error loading organization data:", error);
const toast = await toastController.create({
message: "Error loading organization data",
duration: 3000,
color: "danger",
});
await toast.present();
} finally {
loading.value = false;
}
});
</script>
<style scoped>
:deep(.ion-page) {
animation: none !important;
transform: none !important;
transition: none !important;
}
.manage-container {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
animation: none !important;
transform: none !important;
transition: none !important;
}
.manage-title {
margin: 0 0 32px 0;
color: var(--ion-text-color);
font-size: 32px;
font-weight: 600;
text-align: center;
font-variant: small-caps;
}
.section {
margin-bottom: 40px;
}
.section-heading {
margin: 0 0 12px 0;
color: var(--ion-text-color);
font-size: 24px;
font-weight: 700;
text-align: center;
}
.section-message {
margin: 0 0 24px 0;
color: var(--ion-color-medium);
font-size: 16px;
font-style: italic;
text-align: center;
}
/* Variables Grid */
.variables-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.variable-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px;
background-color: var(--ion-background-color-step-50);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.variable-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.variable-label {
color: var(--ion-color-medium);
font-size: 14px;
font-weight: 600;
text-transform: capitalize;
}
.variable-display {
color: var(--ion-text-color);
font-size: 18px;
font-weight: 500;
}
.variable-value {
color: var(--ion-text-color);
font-size: 18px;
font-weight: 500;
}
.variable-input {
font-size: 18px;
--padding-start: 0;
--padding-end: 0;
}
.variable-actions {
display: flex;
gap: 8px;
}
/* Users Grid */
.users-grid {
display: flex;
flex-direction: column;
gap: 12px;
}
.user-item {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background-color: var(--ion-background-color-step-50);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.user-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--ion-color-primary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.user-avatar ion-icon {
font-size: 24px;
color: white;
}
.user-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.user-email {
color: var(--ion-text-color);
font-size: 16px;
font-weight: 600;
}
.user-name {
color: var(--ion-color-medium);
font-size: 14px;
}
.user-roles-select {
max-width: 200px;
}
/* Roles Grid */
.roles-grid {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.role-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background-color: var(--ion-background-color-step-50);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.role-icon {
font-size: 20px;
color: var(--ion-color-primary);
}
.role-name {
color: var(--ion-text-color);
font-size: 16px;
font-weight: 500;
}
/* Invite Box */
.invite-box {
max-width: 600px;
margin: 0 auto;
padding: 20px;
background-color: var(--ion-background-color-step-50);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.invite-link {
margin: 0 0 16px 0;
padding: 12px;
background: var(--ion-background-color);
border-radius: 4px;
color: var(--ion-text-color);
font-size: 14px;
word-break: break-all;
white-space: pre-wrap;
}
.empty-message {
text-align: center;
padding: 40px 20px;
color: var(--ion-color-medium);
font-style: italic;
}
/* Info Modal Styles */
.info-content {
color: var(--ion-color-dark);
}
.info-content pre {
background: var(--ion-color-light);
padding: 16px;
border-radius: 8px;
overflow-x: auto;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
}
/* Mobile adjustments */
@media (max-width: 767px) {
.manage-container {
padding: 16px;
}
.manage-title {
font-size: 28px;
}
.section-heading {
font-size: 20px;
}
.section-message {
font-size: 14px;
}
.variable-item,
.user-item {
padding: 12px;
}
.user-avatar {
width: 40px;
height: 40px;
}
.user-avatar ion-icon {
font-size: 20px;
}
.user-roles-select {
max-width: 150px;
}
}
</style>