Hello from MCP server

List Files | Just Commands | Repo | Logs

← back |
<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>