refactor: organize frontend by feature
This commit is contained in:
366
frontend/src/features/clients/views/ClientsView.vue
Normal file
366
frontend/src/features/clients/views/ClientsView.vue
Normal file
@@ -0,0 +1,366 @@
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import AppAvatar from '@/components/AppAvatar.vue';
|
||||
import { useAuthStore } from '@/features/auth/stores/authStore.js';
|
||||
import { useWorkspaceStore } from '@/features/workspaces/stores/workspaceStore.js';
|
||||
import { useClientsStore } from '@/features/clients/stores/clientsStore.js';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const workspaceStore = useWorkspaceStore();
|
||||
const clientsStore = useClientsStore();
|
||||
const { t } = useI18n();
|
||||
const isCreateFormVisible = ref(false);
|
||||
const formError = ref(null);
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
portraitUrl: '',
|
||||
primaryContactName: '',
|
||||
primaryContactEmail: '',
|
||||
primaryContactPortraitUrl: '',
|
||||
});
|
||||
|
||||
function resetForm() {
|
||||
form.name = '';
|
||||
form.portraitUrl = '';
|
||||
form.primaryContactName = '';
|
||||
form.primaryContactEmail = '';
|
||||
form.primaryContactPortraitUrl = '';
|
||||
formError.value = null;
|
||||
}
|
||||
|
||||
function openCreateForm() {
|
||||
resetForm();
|
||||
isCreateFormVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
if (clientsStore.isCreating) {
|
||||
return;
|
||||
}
|
||||
|
||||
formError.value = null;
|
||||
|
||||
if (!form.name) {
|
||||
formError.value = t('clients.errors.nameRequired');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await clientsStore.createClient({
|
||||
name: form.name,
|
||||
portraitUrl: form.portraitUrl,
|
||||
primaryContactName: form.primaryContactName,
|
||||
primaryContactEmail: form.primaryContactEmail,
|
||||
primaryContactPortraitUrl: form.primaryContactPortraitUrl,
|
||||
});
|
||||
|
||||
isCreateFormVisible.value = false;
|
||||
resetForm();
|
||||
} catch (error) {
|
||||
formError.value = t('clients.errors.createFailed');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-shell">
|
||||
<div class="header">
|
||||
<div>
|
||||
<div class="eyebrow">{{ t('clients.eyebrow') }}</div>
|
||||
<h1>{{ t('clients.title') }}</h1>
|
||||
<p>{{ t('clients.description') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-row">
|
||||
<button
|
||||
v-if="authStore.isManager"
|
||||
class="create-button"
|
||||
@click="openCreateForm"
|
||||
>
|
||||
{{ t('clients.newClient') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isCreateFormVisible"
|
||||
class="create-panel"
|
||||
>
|
||||
<div class="panel-header">
|
||||
<strong>{{ t('clients.createTitle') }}</strong>
|
||||
<span>{{ workspaceStore.activeWorkspace?.name }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="formError"
|
||||
class="page-message error"
|
||||
>
|
||||
{{ formError }}
|
||||
</div>
|
||||
|
||||
<div class="form-grid">
|
||||
<label class="field field-wide">
|
||||
<span>{{ t('clients.fields.name') }}</span>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
:disabled="clientsStore.isCreating"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field field-wide">
|
||||
<span>{{ t('clients.fields.portraitUrl') }}</span>
|
||||
<input
|
||||
v-model="form.portraitUrl"
|
||||
type="url"
|
||||
placeholder="https://..."
|
||||
:disabled="clientsStore.isCreating"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>{{ t('clients.fields.primaryContactName') }}</span>
|
||||
<input
|
||||
v-model="form.primaryContactName"
|
||||
type="text"
|
||||
:disabled="clientsStore.isCreating"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field">
|
||||
<span>{{ t('clients.fields.primaryContactEmail') }}</span>
|
||||
<input
|
||||
v-model="form.primaryContactEmail"
|
||||
type="email"
|
||||
:disabled="clientsStore.isCreating"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="field field-wide">
|
||||
<span>{{ t('clients.fields.primaryContactPortraitUrl') }}</span>
|
||||
<input
|
||||
v-model="form.primaryContactPortraitUrl"
|
||||
type="url"
|
||||
placeholder="https://..."
|
||||
:disabled="clientsStore.isCreating"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="panel-actions">
|
||||
<button
|
||||
class="secondary"
|
||||
:disabled="clientsStore.isCreating"
|
||||
@click="isCreateFormVisible = false"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="primary"
|
||||
:disabled="clientsStore.isCreating"
|
||||
@click="submitForm"
|
||||
>
|
||||
<v-progress-circular
|
||||
v-if="clientsStore.isCreating"
|
||||
indeterminate
|
||||
:size="16"
|
||||
:width="2"
|
||||
/>
|
||||
<span>{{ clientsStore.isCreating ? t('common.creating') : t('clients.createTitle') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="clientsStore.isLoading"
|
||||
class="page-message"
|
||||
>
|
||||
{{ t('clients.loading') }}
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="clientsStore.error"
|
||||
class="page-message error"
|
||||
>
|
||||
{{ clientsStore.error }}
|
||||
</div>
|
||||
|
||||
<div class="grid-list">
|
||||
<router-link
|
||||
v-for="client in clientsStore.clients"
|
||||
:key="client.id"
|
||||
:to="{ name: 'client-detail', params: { clientId: client.id } }"
|
||||
class="client-card"
|
||||
>
|
||||
<div class="client-card-header">
|
||||
<div>
|
||||
<strong>{{ client.name }}</strong>
|
||||
<span>{{ client.status }}</span>
|
||||
</div>
|
||||
<AppAvatar
|
||||
:name="client.name"
|
||||
:src="client.portraitUrl"
|
||||
/>
|
||||
</div>
|
||||
<em>{{ client.primaryContactName || t('clients.noPrimaryContact') }}</em>
|
||||
<small>{{ client.primaryContactEmail || t('clients.noPrimaryContactEmail') }}</small>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!clientsStore.isLoading && !clientsStore.clients.length"
|
||||
class="page-message"
|
||||
>
|
||||
{{ t('clients.empty') }}
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-shell {
|
||||
@apply mx-auto flex w-full max-w-7xl flex-col gap-6 px-5 py-8 md:px-8;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
@apply text-xs font-bold uppercase tracking-[0.24em];
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
@apply mt-2 text-4xl font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.header p {
|
||||
@apply mt-2 text-sm leading-6;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.header {
|
||||
@apply flex flex-col gap-3;
|
||||
}
|
||||
|
||||
.action-row {
|
||||
@apply flex justify-start;
|
||||
}
|
||||
|
||||
.create-button,
|
||||
.primary,
|
||||
.secondary {
|
||||
@apply rounded-full px-5 py-3 text-sm font-bold transition;
|
||||
}
|
||||
|
||||
.primary,
|
||||
.secondary {
|
||||
@apply inline-flex items-center justify-center gap-2;
|
||||
}
|
||||
|
||||
.create-button,
|
||||
.primary {
|
||||
background: #172033;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.create-button:hover,
|
||||
.primary:hover {
|
||||
background: #0f172a;
|
||||
}
|
||||
|
||||
.secondary {
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
border: 1px solid rgba(23, 32, 51, 0.12);
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.create-panel {
|
||||
@apply flex flex-col gap-5 rounded-[1.75rem] border p-5 md:p-6;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
@apply flex flex-col gap-1 md:flex-row md:items-center md:justify-between;
|
||||
}
|
||||
|
||||
.panel-header strong {
|
||||
@apply text-lg font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.panel-header span {
|
||||
@apply text-sm font-semibold;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
@apply grid gap-4 md:grid-cols-2;
|
||||
}
|
||||
|
||||
.field {
|
||||
@apply flex flex-col gap-2 text-sm font-semibold;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.field.field-wide {
|
||||
@apply md:col-span-2;
|
||||
}
|
||||
|
||||
.field input {
|
||||
@apply rounded-2xl border px-4 py-3 text-sm;
|
||||
border-color: rgba(23, 32, 51, 0.12);
|
||||
background: white;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.panel-actions {
|
||||
@apply flex flex-col gap-3 sm:flex-row sm:justify-end;
|
||||
}
|
||||
|
||||
.grid-list {
|
||||
@apply grid gap-4 md:grid-cols-2 xl:grid-cols-3;
|
||||
}
|
||||
|
||||
.page-message {
|
||||
@apply rounded-[1.25rem] border p-4 text-sm font-medium;
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.page-message.error {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.client-card {
|
||||
@apply flex flex-col gap-3 rounded-[1.5rem] border p-5;
|
||||
background: rgba(255, 255, 255, 0.84);
|
||||
border-color: rgba(23, 32, 51, 0.08);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.client-card-header {
|
||||
@apply flex items-start justify-between gap-4;
|
||||
}
|
||||
|
||||
.client-card strong {
|
||||
@apply text-xl font-black;
|
||||
color: #172033;
|
||||
}
|
||||
|
||||
.client-card span {
|
||||
@apply text-sm font-semibold uppercase tracking-[0.18em];
|
||||
color: #ff8a3d;
|
||||
}
|
||||
|
||||
.client-card em {
|
||||
@apply text-sm leading-6 not-italic;
|
||||
color: #526178;
|
||||
}
|
||||
|
||||
.client-card small {
|
||||
@apply text-xs leading-5;
|
||||
color: #7b8798;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user