Added Multi-Company Support

This commit is contained in:
2026-06-08 00:54:21 -05:00
parent c164395915
commit db614a6707
32 changed files with 1133 additions and 323 deletions

View File

@@ -72,6 +72,9 @@
:theme-items="themeItems"
:title="currentTitle"
:user="user"
:companies="entities"
:active-company-id="activeCompanyId"
@change-company="changeCompany"
@change-password="changePasswordDialog = true"
@logout="logout"
@open-help="helpDialog = true"
@@ -83,7 +86,7 @@
</template>
</TopNav>
<v-main id="main-content" tabindex="-1">
<v-main id="main-content" :key="`company-${activeCompanyId}`" tabindex="-1">
<DashboardView
v-if="activeView === 'dashboard'"
:category-chart="categoryChart"
@@ -185,12 +188,14 @@
:team-saving="teamSaving"
:team-notice="teamNotice"
:teams="teams"
:companies="entities"
:user-saving="userSaving"
:users="users"
:workday-connection="workdayConnection"
:workday-saving="workdaySaving"
:workday-syncing="workdaySyncing"
@categories-updated="loadReferences"
@companies-updated="loadCompanies"
@create-team="createTeam"
@update-team="updateTeam"
@delete-team="deleteTeam"
@@ -318,7 +323,7 @@ import MassEditDialog from './components/MassEditDialog.vue';
import PmCompletionDialog from './components/PmCompletionDialog.vue';
import TopNav from './components/TopNav.vue';
import UserManual from './components/UserManual.vue';
import { apiRequest, loginRequest, saveBlob } from './services/api';
import { apiRequest, loginRequest, saveBlob, setActiveCompany } from './services/api';
import { blankAsset, defaultValueForField, makeDefaultBooks, mergeBooks, normalizeCustomFields } from './utils/assets';
import { currency, shortDate } from './utils/format';
import { bookItems, customFieldTypes, darkThemes, fontScaleMap, navItems, statusItems, themeItems } from './constants';
@@ -393,6 +398,7 @@ export default {
selectedLeaseId: null,
employees: [],
entities: [],
activeCompanyId: null,
error: '',
helpDialog: false,
changePasswordDialog: false,
@@ -554,6 +560,9 @@ export default {
entityOptions() {
return this.entities.map((entity) => ({ title: entity.name, value: entity.id }));
},
activeCompany() {
return this.entities.find((entity) => entity.id === this.activeCompanyId) || null;
},
activeBookItems() {
return this.bookCodes.length ? this.bookCodes : this.bookItems;
},
@@ -1093,12 +1102,13 @@ export default {
this.dashboard = await (await this.api('/api/dashboard')).json();
},
async loadInitial() {
// Resolve the active company first so every other request is scoped to it.
await this.loadCompanies();
await Promise.all([
this.loadProfile(),
this.loadDashboard(),
this.loadAssets(),
this.loadAssignments(),
this.api('/api/entities').then((r) => r.json()).then((data) => { this.entities = data.entities; }),
this.api('/api/references').then((r) => r.json()).then((data) => { this.references = data.references; }),
this.api('/api/templates').then((r) => r.json()).then((data) => { this.templates = data.templates; }),
this.api('/api/tax-rules').then((r) => r.json()).then((data) => { this.taxRules = data.ruleSets; }),
@@ -1108,6 +1118,24 @@ export default {
this.loadDepreciationZones()
]);
},
// Load the active companies and pick the active one (stored choice if still valid, else the first).
async loadCompanies() {
const data = await (await this.api('/api/entities')).json();
this.entities = data.entities || [];
const stored = Number(localStorage.getItem('deprecore.activeCompany'));
const valid = this.entities.find((e) => e.id === stored);
this.activeCompanyId = valid ? valid.id : (this.entities[0]?.id || null);
localStorage.setItem('deprecore.activeCompany', String(this.activeCompanyId || ''));
setActiveCompany(this.activeCompanyId);
},
// Switch the active company: persist it, update the request header, and reload all scoped data.
async changeCompany(id) {
if (!id || id === this.activeCompanyId) return;
this.activeCompanyId = id;
localStorage.setItem('deprecore.activeCompany', String(id));
setActiveCompany(id);
await this.loadInitial();
},
async loadAssignments() {
const [employees, assignments, workday] = await Promise.all([
this.api('/api/employees').then((r) => r.json()),
@@ -1210,7 +1238,7 @@ export default {
async newAsset() {
await this.loadBookCodes();
this.assetForm = blankAsset();
this.assetForm.entity_id = this.entities[0]?.id || null;
this.assetForm.entity_id = this.activeCompanyId || this.entities[0]?.id || null;
this.assetForm.books = makeDefaultBooks(this.assetForm, this.bookCodes);
this.selectedTemplateId = null;
this.drawerError = '';

View File

@@ -0,0 +1,194 @@
<template>
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-3">
<div>
<h2 class="section-title">Companies</h2>
<p class="quiet text-body-2">
Each company keeps its own assets, books, general ledger, PM plans, and alerts. Switch the active company
from the top bar. Disposing a company archives it (history is preserved) and removes it from the switcher.
</p>
</div>
<v-spacer />
<v-btn color="primary" prepend-icon="mdi-domain-plus" @click="openNew">New company</v-btn>
</div>
<DataTable
:columns="columns"
:rows="companies"
row-key="id"
:page-size="10"
has-actions
searchable
search-placeholder="Filter companies"
empty-text="No companies."
>
<template #cell-name="{ row }">
<span class="font-weight-bold">{{ row.name }}</span>
</template>
<template #cell-status="{ row }">
<v-chip size="x-small" :color="row.status === 'active' ? 'success' : 'error'" variant="tonal">{{ row.status }}</v-chip>
</template>
<template #cell-base_currency="{ row }">{{ row.base_currency || 'USD' }}</template>
<template #actions="{ row }">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEdit(row)" />
<v-btn v-if="row.status === 'active'" icon="mdi-archive-arrow-down-outline" size="small" variant="text" color="error" title="Dispose / archive" @click="confirmDispose(row)" />
<v-btn v-else icon="mdi-restore" size="small" variant="text" color="success" title="Restore" @click="restore(row)" />
</template>
</DataTable>
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
<!-- Create / edit -->
<v-dialog v-model="dialog" max-width="560">
<v-card>
<v-card-title>{{ form.id ? 'Edit company' : 'New company' }}</v-card-title>
<v-card-text>
<div class="form-grid">
<v-text-field v-model="form.name" class="full" label="Company name *" autofocus />
<v-text-field v-model="form.legal_name" class="full" label="Legal name" />
<v-text-field v-model="form.tax_id" label="Tax ID / EIN" />
<v-text-field v-model="form.base_currency" label="Base currency" placeholder="USD" />
<v-select v-model.number="form.fiscal_year_end_month" :items="monthItems" item-title="title" item-value="value" label="Fiscal year-end month" />
</div>
<v-alert v-if="!form.id" type="info" variant="tonal" density="compact" class="mt-2">
A new company starts with the standard book set (GAAP, Federal, State, AMT, ACE, User, PM).
</v-alert>
<v-alert v-if="dialogError" type="error" density="compact" class="mt-2">{{ dialogError }}</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="dialog = false">Cancel</v-btn>
<v-btn color="primary" prepend-icon="mdi-content-save" :loading="saving" :disabled="!form.name.trim()" @click="save">Save company</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- Dispose confirmation -->
<v-dialog v-model="disposeDialog" max-width="500">
<v-card>
<v-card-title class="text-error">Dispose company</v-card-title>
<v-card-text>
<p class="mb-3">Archive <strong>{{ disposeTarget?.name }}</strong>?</p>
<v-alert type="info" variant="tonal" density="comfortable" class="mb-3">
Its assets, books, GL, PM plans, and alerts are <strong>kept</strong> for audit and reporting, but the company
is hidden from the switcher and can no longer be selected for new work. You can restore it later.
</v-alert>
<v-text-field v-model="disposeReason" label="Reason (optional)" density="compact" />
<v-alert v-if="dialogError" type="error" density="compact" class="mt-2">{{ dialogError }}</v-alert>
</v-card-text>
<v-card-actions>
<v-spacer />
<v-btn variant="text" @click="disposeDialog = false">Cancel</v-btn>
<v-btn color="error" prepend-icon="mdi-archive-arrow-down-outline" @click="performDispose">Dispose company</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</v-card>
</template>
<script>
import DataTable from './DataTable.vue';
import { apiRequest } from '../services/api';
function blankCompany() {
return { id: null, name: '', legal_name: '', tax_id: '', base_currency: 'USD', fiscal_year_end_month: 12 };
}
export default {
components: { DataTable },
props: {
token: { type: String, required: true }
},
emits: ['updated'],
data() {
return {
companies: [],
dialog: false,
disposeDialog: false,
disposeTarget: null,
disposeReason: '',
form: blankCompany(),
saving: false,
error: '',
dialogError: '',
monthItems: Array.from({ length: 12 }, (_, i) => ({ title: new Date(2000, i, 1).toLocaleString('en-US', { month: 'long' }), value: i + 1 })),
columns: [
{ key: 'name', label: 'Company' },
{ key: 'legal_name', label: 'Legal name' },
{ key: 'tax_id', label: 'Tax ID' },
{ key: 'base_currency', label: 'Currency' },
{ key: 'status', label: 'Status', sortable: false }
]
};
},
mounted() {
this.load();
},
methods: {
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
async load() {
this.error = '';
try {
const data = await (await this.api('/api/entities?includeDisposed=1')).json();
this.companies = data.entities;
} catch (error) {
this.error = error.message;
}
},
openNew() {
this.form = blankCompany();
this.dialogError = '';
this.dialog = true;
},
openEdit(row) {
this.form = { id: row.id, name: row.name, legal_name: row.legal_name || '', tax_id: row.tax_id || '', base_currency: row.base_currency || 'USD', fiscal_year_end_month: row.fiscal_year_end_month || 12 };
this.dialogError = '';
this.dialog = true;
},
async save() {
this.saving = true;
this.dialogError = '';
try {
const method = this.form.id ? 'PUT' : 'POST';
const url = this.form.id ? `/api/entities/${this.form.id}` : '/api/entities';
await this.api(url, { method, body: JSON.stringify(this.form) });
this.dialog = false;
await this.load();
this.$emit('updated');
} catch (error) {
this.dialogError = error.message;
} finally {
this.saving = false;
}
},
confirmDispose(row) {
this.disposeTarget = row;
this.disposeReason = '';
this.dialogError = '';
this.disposeDialog = true;
},
async performDispose() {
this.dialogError = '';
try {
await this.api(`/api/entities/${this.disposeTarget.id}/dispose`, { method: 'POST', body: JSON.stringify({ reason: this.disposeReason || null }) });
this.disposeDialog = false;
await this.load();
this.$emit('updated');
} catch (error) {
this.dialogError = error.message;
}
},
async restore(row) {
this.error = '';
try {
await this.api(`/api/entities/${row.id}/restore`, { method: 'POST', body: JSON.stringify({}) });
await this.load();
this.$emit('updated');
} catch (error) {
this.error = error.message;
}
}
}
};
</script>

View File

@@ -6,6 +6,23 @@
<div class="text-h6 font-weight-bold">{{ title }}</div>
</div>
<v-spacer />
<v-select
v-if="companies.length > 1"
:model-value="activeCompanyId"
:items="companyItems"
item-title="title"
item-value="value"
density="compact"
variant="solo-filled"
flat
hide-details
prepend-inner-icon="mdi-domain"
class="company-switch mr-2"
style="max-width:220px"
aria-label="Active company"
@update:model-value="$emit('change-company', $event)"
/>
<v-chip v-else-if="companies.length === 1" size="small" variant="tonal" prepend-icon="mdi-domain" class="mr-2">{{ companies[0].name }}</v-chip>
<slot name="actions" />
<v-btn icon="mdi-help-circle-outline" variant="text" class="mr-1" title="User guide" aria-label="Open user guide" @click="$emit('open-help')" />
<v-menu v-model="profileMenu" :close-on-content-click="false" location="bottom end">
@@ -117,9 +134,11 @@ export default {
subtitle: { type: String, required: true },
themeItems: { type: Array, required: true },
title: { type: String, required: true },
user: { type: Object, required: true }
user: { type: Object, required: true },
companies: { type: Array, default: () => [] },
activeCompanyId: { type: [Number, String], default: null }
},
emits: ['change-password', 'logout', 'open-help', 'save-preferences', 'toggle-nav'],
emits: ['change-company', 'change-password', 'logout', 'open-help', 'save-preferences', 'toggle-nav'],
data() {
return {
accentPresets,
@@ -131,6 +150,9 @@ export default {
};
},
computed: {
companyItems() {
return this.companies.map((c) => ({ title: c.name, value: c.id }));
},
initials() {
return String(this.user?.name || 'MA')
.split(/\s+/)

View File

@@ -1,9 +1,16 @@
// The active company id is sent on every request as X-Company-Id so the server scopes data to it.
let activeCompanyId = null;
export function setActiveCompany(id) {
activeCompanyId = id || null;
}
export async function apiRequest(path, token, options = {}) {
const response = await fetch(path, {
...options,
headers: {
...(options.body instanceof FormData ? {} : { 'Content-Type': 'application/json' }),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(activeCompanyId ? { 'X-Company-Id': String(activeCompanyId) } : {}),
...(options.headers || {})
}
});

View File

@@ -172,6 +172,8 @@
<!-- APPLICATION CONFIGURATION -->
<template v-if="adminSection === 'admin-config'">
<CompanySettings :token="token" @updated="$emit('companies-updated')" />
<v-card class="span-6 panel-pad">
<h2 class="section-title mb-4">Application settings</h2>
<v-text-field v-model="localSettings.server_fqdn" label="Server FQDN" />
@@ -262,6 +264,19 @@
<v-text-field v-model="userForm.password" type="password" :label="userForm.id ? 'New password' : 'Password'" :hint="userForm.id ? 'Leave blank to keep the current password' : 'Leave blank to issue a temporary password'" persistent-hint />
</div>
<v-switch v-model="userForm.must_change_password" color="primary" density="compact" hide-details class="mt-2" label="Require password change at next sign-in" />
<v-select
v-model="userForm.company_ids"
:items="companyItems"
item-title="title"
item-value="value"
label="Company access"
multiple
chips
closable-chips
class="mt-3"
hint="Companies this user can see and switch between. Administrators always see every company."
persistent-hint
/>
<v-alert v-if="userForm.role" class="mt-3" density="compact" type="info">
{{ roleSummary(userForm.role) }}
</v-alert>
@@ -296,6 +311,7 @@ import AssetClassSettings from '../components/AssetClassSettings.vue';
import AssetIdTemplateSettings from '../components/AssetIdTemplateSettings.vue';
import AuditTrailSettings from '../components/AuditTrailSettings.vue';
import CategorySettings from '../components/CategorySettings.vue';
import CompanySettings from '../components/CompanySettings.vue';
import PasswordPolicySettings from '../components/PasswordPolicySettings.vue';
import DataTable from '../components/DataTable.vue';
import DepartmentSettings from '../components/DepartmentSettings.vue';
@@ -310,7 +326,7 @@ import TeamsSettings from '../components/TeamsSettings.vue';
import WebhookSettings from '../components/WebhookSettings.vue';
export default {
components: { AssetClassSettings, AssetIdTemplateSettings, AuditTrailSettings, CategorySettings, DataTable, DepartmentSettings, DepreciationZoneSettings, NotificationSettings, PartCategorySettings, PasswordPolicySettings, PmCategorySettings, PmSettings, Section179LimitSettings, ServiceNowSettings, TeamsSettings, WebhookSettings },
components: { AssetClassSettings, AssetIdTemplateSettings, AuditTrailSettings, CategorySettings, CompanySettings, DataTable, DepartmentSettings, DepreciationZoneSettings, NotificationSettings, PartCategorySettings, PasswordPolicySettings, PmCategorySettings, PmSettings, Section179LimitSettings, ServiceNowSettings, TeamsSettings, WebhookSettings },
props: {
token: { type: String, default: '' },
section: { type: String, default: 'admin-users' },
@@ -322,13 +338,14 @@ export default {
teamSaving: { type: Boolean, default: false },
teams: { type: Array, default: () => [] },
teamNotice: { type: String, default: '' },
companies: { type: Array, default: () => [] },
userSaving: { type: Boolean, default: false },
users: { type: Array, default: () => [] },
workdayConnection: { type: Object, default: () => ({}) },
workdaySaving: { type: Boolean, default: false },
workdaySyncing: { type: Boolean, default: false }
},
emits: ['create-team', 'update-team', 'delete-team', 'create-user', 'update-user', 'unlock-user', 'create-role', 'update-role', 'delete-role', 'save-settings', 'save-workday', 'sync-workday', 'categories-updated'],
emits: ['create-team', 'update-team', 'delete-team', 'create-user', 'update-user', 'unlock-user', 'create-role', 'update-role', 'delete-role', 'save-settings', 'save-workday', 'sync-workday', 'categories-updated', 'companies-updated'],
data() {
return {
localSettings: { ...this.settings },
@@ -364,6 +381,9 @@ export default {
teamOptions() {
return this.teams.map((team) => ({ title: team.name, value: team.id }));
},
companyItems() {
return this.companies.map((company) => ({ title: company.name, value: company.id }));
},
roleSelectItems() {
return this.roles.map((role) => ({ title: role.name, value: role.key }));
},
@@ -404,7 +424,8 @@ export default {
role: 'viewer',
status: 'active',
password: '',
must_change_password: false
must_change_password: false,
company_ids: []
};
},
editUser(account) {
@@ -416,7 +437,8 @@ export default {
role: account.role,
status: account.status,
password: '',
must_change_password: Boolean(account.must_change_password)
must_change_password: Boolean(account.must_change_password),
company_ids: Array.isArray(account.company_ids) ? [...account.company_ids] : []
};
this.userDialog = true;
},