Added Multi-Company Support
This commit is contained in:
36
src/App.vue
36
src/App.vue
@@ -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 = '';
|
||||
|
||||
194
src/components/CompanySettings.vue
Normal file
194
src/components/CompanySettings.vue
Normal 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>
|
||||
@@ -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+/)
|
||||
|
||||
@@ -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 || {})
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user