feat: update workspace settings

This commit is contained in:
2026-04-30 02:03:42 -04:00
parent 6177eec2bf
commit 63738ad027
20 changed files with 7168 additions and 54 deletions

View File

@@ -1,6 +1,9 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import AppAvatar from '@/components/AppAvatar.vue';
import ImageCropperDialog from '@/components/ImageCropperDialog.vue';
import TimeZoneSelect from '@/features/workspaces/components/TimeZoneSelect.vue';
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
import {
mdiAccountGroupOutline,
@@ -14,6 +17,15 @@
const { t } = useI18n();
const workspaceStore = useWorkspaceStore();
const activeTab = ref('general');
const settingsForm = reactive({
name: '',
timeZone: '',
});
const settingsError = ref(null);
const settingsStatus = ref(null);
const logoError = ref(null);
const logoStatus = ref(null);
const isLogoDialogOpen = ref(false);
const inviteForm = reactive({
email: '',
@@ -26,6 +38,15 @@
const workspaceMembers = computed(() =>
workspaceStore.membersByWorkspace[workspaceStore.activeWorkspaceId] ?? []
);
const isSettingsDirty = computed(() => {
const workspace = workspaceStore.activeWorkspace;
if (!workspace) {
return false;
}
return settingsForm.name.trim() !== workspace.name || settingsForm.timeZone.trim() !== workspace.timeZone;
});
const settingsTabs = computed(() => [
{ key: 'general', label: t('workspaceSettings.tabs.general'), icon: mdiCogOutline },
{ key: 'members', label: t('workspaceSettings.tabs.members'), icon: mdiAccountGroupOutline },
@@ -50,6 +71,17 @@
},
]);
watch(
() => workspaceStore.activeWorkspace,
workspace => {
settingsForm.name = workspace?.name ?? '';
settingsForm.timeZone = workspace?.timeZone ?? '';
settingsError.value = null;
settingsStatus.value = null;
},
{ immediate: true }
);
watch(
() => workspaceStore.activeWorkspaceId,
async workspaceId => {
@@ -67,6 +99,56 @@
{ immediate: true }
);
async function submitWorkspaceSettings() {
const workspace = workspaceStore.activeWorkspace;
if (!workspace || workspaceStore.isUpdating) {
return;
}
settingsError.value = null;
settingsStatus.value = null;
const name = settingsForm.name.trim();
const timeZone = settingsForm.timeZone.trim();
if (!name || !timeZone) {
settingsError.value = t('workspaceSettings.errors.required');
return;
}
try {
await workspaceStore.updateWorkspace(workspace.id, {
name,
timeZone,
});
settingsStatus.value = t('workspaceSettings.general.saved');
} catch (error) {
console.error('Failed to update workspace settings:', error);
settingsError.value = t('workspaceSettings.errors.updateFailed');
}
}
async function saveWorkspaceLogo(result) {
const workspace = workspaceStore.activeWorkspace;
if (!workspace || workspaceStore.isUploadingLogo) {
return;
}
logoError.value = null;
logoStatus.value = null;
try {
await workspaceStore.uploadWorkspaceLogo(workspace.id, result.file);
logoStatus.value = t('workspaceSettings.logo.saved');
isLogoDialogOpen.value = false;
} catch (error) {
console.error('Failed to update workspace logo:', error);
logoError.value = t('workspaceSettings.errors.logoUploadFailed');
}
}
async function submitInvite() {
if (!inviteForm.email.trim() || !inviteForm.role) {
return;
@@ -133,31 +215,93 @@
>
<article class="settings-card">
<div class="section-copy">
<span class="section-kicker">{{ t('workspaceSettings.general.summaryTitle') }}</span>
<p>{{ t('workspaceSettings.general.summaryDescription') }}</p>
<span class="section-kicker">{{ t('workspaceSettings.general.detailsTitle') }}</span>
<p>{{ t('workspaceSettings.general.detailsDescription') }}</p>
</div>
<dl
v-if="workspaceStore.activeWorkspace"
class="summary-grid"
<div
v-if="settingsError"
class="page-message error"
>
<div>
<dt>{{ t('workspaceSettings.summary.name') }}</dt>
<dd>{{ workspaceStore.activeWorkspace.name }}</dd>
{{ settingsError }}
</div>
<div
v-if="settingsStatus"
class="page-message success"
>
{{ settingsStatus }}
</div>
<form
v-if="workspaceStore.activeWorkspace"
class="form-stack"
@submit.prevent="submitWorkspaceSettings"
>
<div class="logo-picker-card">
<AppAvatar
:name="settingsForm.name || workspaceStore.activeWorkspace.name"
:src="workspaceStore.activeWorkspace.logoUrl"
size="lg"
/>
<div class="logo-picker-copy">
<strong>{{ t('workspaceSettings.logo.title') }}</strong>
<small>{{ t('workspaceSettings.logo.description') }}</small>
<small
v-if="logoError"
class="field-error"
>
{{ logoError }}
</small>
<small
v-if="logoStatus"
class="field-success"
>
{{ logoStatus }}
</small>
</div>
<button
class="secondary-button"
type="button"
:disabled="workspaceStore.isUploadingLogo"
@click="isLogoDialogOpen = true"
>
{{ workspaceStore.isUploadingLogo ? t('common.saving') : t('workspaceSettings.logo.changeAction') }}
</button>
</div>
<div>
<dt>{{ t('workspaceSettings.summary.slug') }}</dt>
<dd>{{ workspaceStore.activeWorkspace.slug }}</dd>
</div>
<div>
<dt>{{ t('workspaceSettings.summary.timeZone') }}</dt>
<dd>{{ workspaceStore.activeWorkspace.timeZone }}</dd>
</div>
<div>
<dt>{{ t('workspaceSettings.summary.created') }}</dt>
<dd>{{ formatDate(workspaceStore.activeWorkspace.createdAt) }}</dd>
</div>
</dl>
<label class="field">
<span>{{ t('workspaceSettings.fields.name') }}</span>
<input
v-model="settingsForm.name"
type="text"
:disabled="workspaceStore.isUpdating"
/>
</label>
<label class="field">
<span>{{ t('workspaceSettings.fields.timeZone') }}</span>
<TimeZoneSelect
v-model="settingsForm.timeZone"
:disabled="workspaceStore.isUpdating"
/>
</label>
<button
class="primary-button"
type="submit"
:disabled="workspaceStore.isUpdating || !isSettingsDirty"
>
{{ workspaceStore.isUpdating ? t('common.saving') : t('workspaceSettings.general.saveAction') }}
</button>
</form>
<div
v-else
class="empty-state"
>
{{ t('workspaceSettings.noWorkspaceSelected') }}
</div>
</article>
</div>
@@ -366,6 +510,16 @@
</router-link>
</article>
</div>
<ImageCropperDialog
v-model="isLogoDialogOpen"
:title="t('workspaceSettings.logo.cropperTitle')"
:confirm-label="t('workspaceSettings.logo.saveAction')"
:upload-label="t('workspaceSettings.logo.chooseAction')"
:initial-url="workspaceStore.activeWorkspace?.logoUrl"
:is-saving="workspaceStore.isUploadingLogo"
@save="saveWorkspaceLogo"
/>
</section>
</template>
@@ -426,7 +580,6 @@
}
.section-copy h1,
.summary-grid dd,
.invite-row strong,
.connector-copy strong,
.connector-status,
@@ -440,7 +593,6 @@
}
.section-copy p,
.summary-grid dt,
.invite-row span,
.invite-row small,
.empty-state,
@@ -452,22 +604,32 @@
color: #526178;
}
.summary-grid {
@apply grid gap-4 sm:grid-cols-2;
}
.summary-grid div {
@apply rounded-[1rem] border p-4;
background: #f8fafc;
.logo-picker-card {
@apply flex flex-col gap-4 rounded-[1rem] border p-4 sm:flex-row sm:items-center;
background: #fffaf2;
border-color: rgba(23, 32, 51, 0.08);
}
.summary-grid dt {
@apply text-xs font-bold uppercase tracking-[0.16em];
.logo-picker-copy {
@apply flex min-w-0 flex-1 flex-col gap-1;
}
.summary-grid dd {
@apply mt-2 text-base font-semibold;
.logo-picker-copy strong {
color: #172033;
}
.logo-picker-copy small,
.field-error,
.field-success {
@apply text-sm leading-6;
}
.field-error {
color: #b91c1c;
}
.field-success {
color: #0f766e;
}
.form-stack {
@@ -498,6 +660,18 @@
color: #fffaf2;
}
.secondary-button {
@apply inline-flex items-center justify-center rounded-full px-4 py-2 text-sm font-semibold;
background: rgba(23, 32, 51, 0.08);
color: #172033;
}
.primary-button:disabled,
.secondary-button:disabled {
cursor: not-allowed;
opacity: 0.56;
}
.invite-list,
.connector-list,
.workflow-rule-list,