Updated A LOT

This commit is contained in:
2026-06-05 04:15:24 -05:00
parent 8335f1af1b
commit 4072695432
92 changed files with 16139 additions and 570 deletions

253
src/views/ContactsView.vue Normal file
View File

@@ -0,0 +1,253 @@
<template>
<section class="content-grid">
<v-card class="span-12 panel-pad">
<div class="toolbar-row mb-3">
<div>
<h2 class="section-title">Contacts</h2>
<div class="quiet text-body-2">Employees, vendors, service providers, contractors, work locations, and more.</div>
</div>
<v-spacer />
<v-select v-model="typeFilter" :items="typeFilterItems" item-title="title" item-value="value" label="Type" density="compact" hide-details style="max-width:200px" @update:model-value="load" />
<v-btn color="primary" prepend-icon="mdi-account-plus" @click="openNew">New contact</v-btn>
</div>
<DataTable
:columns="columns"
:rows="contacts"
row-key="id"
:page-size="15"
has-actions
searchable
search-placeholder="Filter contacts"
empty-text="No contacts yet."
>
<template #cell-display_name="{ row }">
<span class="font-weight-bold">{{ row.display_name }}</span>
<v-chip v-if="row.source === 'workday'" size="x-small" variant="tonal" class="ml-2">Workday</v-chip>
</template>
<template #cell-type="{ row }">{{ typeLabel(row.type) }}</template>
<template #actions="{ row }">
<v-btn icon="mdi-pencil" size="small" variant="text" @click="openEdit(row)" />
<v-btn v-if="canDelete" icon="mdi-delete-outline" size="small" variant="text" color="error" @click="remove(row)" />
</template>
</DataTable>
<v-alert v-if="error" type="error" density="compact" class="mt-3">{{ error }}</v-alert>
</v-card>
<v-dialog v-model="dialog" max-width="760" scrollable>
<v-card>
<v-card-title>{{ form.id ? 'Edit contact' : 'New contact' }}</v-card-title>
<v-card-text>
<div class="form-grid">
<v-select v-model="form.type" :items="contactTypeItems" item-title="title" item-value="value" label="Type" />
<v-select v-model="form.status" :items="['active', 'inactive']" label="Status" />
<template v-if="showName">
<v-text-field v-model="form.first_name" label="First name" />
<v-text-field v-model="form.last_name" label="Last name" />
</template>
<v-text-field v-if="showOrg" v-model="form.organization" class="full" :label="form.type === 'work_location' ? 'Location name' : 'Organization'" />
<v-text-field v-model="form.email" label="Email" />
<v-text-field v-model="form.phone" label="Phone (international)" placeholder="+1 555-0100" />
<!-- Employee / contractor job info -->
<template v-if="showJob">
<v-text-field v-if="form.type === 'employee'" v-model="form.employee_id" label="Employee ID" />
<v-text-field v-if="form.type === 'contractor'" v-model="form.tax_id" label="Tax ID / business license #" />
<v-text-field v-model="form.department" label="Department" />
<v-text-field v-model="form.work_location" label="Work location" />
<v-text-field v-model="form.start_date" type="date" label="Start date" />
<v-text-field v-model="form.manager_first_name" label="Manager first name" />
<v-text-field v-model="form.manager_last_name" label="Manager last name" />
<v-text-field v-model="form.manager_email" label="Manager email" />
</template>
<!-- Vendor / service provider -->
<template v-if="showVendor">
<div>
<div class="quiet text-caption mb-1">Rating</div>
<v-rating v-model="form.rating" length="5" color="amber" active-color="amber" half-increments="false" density="compact" clearable />
</div>
<v-select v-model="form.credit_term" :items="creditTermItems" item-title="title" item-value="value" label="Credit term" />
</template>
</div>
<v-divider class="my-4" />
<h3 class="section-title mb-2">Address</h3>
<div class="form-grid">
<v-text-field v-model="form.address_line1" class="full" label="Address line 1" />
<v-text-field v-model="form.address_line2" class="full" label="Address line 2" />
<v-text-field v-model="form.city" label="City / locality" />
<v-text-field v-model="form.state_region" label="State / region / province" />
<v-text-field v-model="form.postal_code" label="Postal code" />
<v-text-field v-model="form.country" label="Country" />
</div>
<v-textarea v-model="form.notes" class="mt-3" label="Notes" rows="2" />
<template v-if="form.id">
<v-divider class="my-4" />
<div class="toolbar-row mb-2">
<h3 class="section-title">Notes log</h3>
</div>
<div class="toolbar-row">
<v-text-field v-model="noteDraft" label="Add a note" density="compact" hide-details @keyup.enter="addNote" />
<v-btn variant="tonal" prepend-icon="mdi-plus" :disabled="!noteDraft.trim()" @click="addNote">Add</v-btn>
</div>
<v-list v-if="form.contact_notes && form.contact_notes.length" density="compact">
<v-list-item v-for="note in form.contact_notes" :key="note.id">
<v-list-item-title style="white-space:normal">{{ note.body }}</v-list-item-title>
<v-list-item-subtitle>{{ note.author || 'Unknown' }} · {{ note.created_at }}</v-list-item-subtitle>
<template #append>
<v-btn icon="mdi-delete-outline" size="x-small" variant="text" color="error" @click="deleteNote(note.id)" />
</template>
</v-list-item>
</v-list>
</template>
<v-alert v-if="dialogError" type="error" density="compact" class="mt-3">{{ 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" @click="save">Save contact</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
</section>
</template>
<script>
import DataTable from '../components/DataTable.vue';
import { apiRequest } from '../services/api';
import { contactTypeItems, creditTermItems } from '../constants';
import { clonePlain } from '../utils/clone';
function blankContact() {
return {
id: null, type: 'employee', status: 'active', first_name: '', last_name: '', organization: '',
email: '', phone: '', address_line1: '', address_line2: '', city: '', state_region: '', postal_code: '', country: '',
notes: '', employee_id: '', tax_id: '', department: '', work_location: '',
manager_first_name: '', manager_last_name: '', manager_email: '', start_date: '', rating: null, credit_term: 'na',
contact_notes: []
};
}
export default {
components: { DataTable },
props: {
token: { type: String, required: true },
canDelete: { type: Boolean, default: false }
},
data() {
return {
contactTypeItems,
creditTermItems,
contacts: [],
typeFilter: null,
dialog: false,
form: blankContact(),
noteDraft: '',
saving: false,
error: '',
dialogError: '',
columns: [
{ key: 'display_name', label: 'Name / organization' },
{ key: 'type', label: 'Type' },
{ key: 'email', label: 'Email' },
{ key: 'phone', label: 'Phone' },
{ key: 'department', label: 'Department' },
{ key: 'work_location', label: 'Location' }
]
};
},
computed: {
typeFilterItems() {
return [{ title: 'All types', value: null }, ...contactTypeItems];
},
showName() {
return ['employee', 'contractor', 'other'].includes(this.form.type);
},
showOrg() {
return ['vendor', 'service_provider', 'work_location', 'other'].includes(this.form.type);
},
showJob() {
return ['employee', 'contractor'].includes(this.form.type);
},
showVendor() {
return ['vendor', 'service_provider'].includes(this.form.type);
}
},
mounted() {
this.load();
},
methods: {
async api(path, options = {}) {
return apiRequest(path, this.token, options);
},
typeLabel(type) {
return (contactTypeItems.find((t) => t.value === type) || {}).title || type;
},
async load() {
this.error = '';
try {
const query = this.typeFilter ? `?type=${this.typeFilter}` : '';
const data = await (await this.api(`/api/contacts${query}`)).json();
this.contacts = data.contacts;
} catch (error) {
this.error = error.message;
}
},
openNew() {
this.form = blankContact();
this.dialogError = '';
this.noteDraft = '';
this.dialog = true;
},
async openEdit(contact) {
this.dialogError = '';
this.noteDraft = '';
const data = await (await this.api(`/api/contacts/${contact.id}`)).json();
this.form = { ...blankContact(), ...clonePlain(data.contact) };
this.dialog = true;
},
async save() {
this.saving = true;
this.dialogError = '';
try {
const method = this.form.id ? 'PUT' : 'POST';
const url = this.form.id ? `/api/contacts/${this.form.id}` : '/api/contacts';
const data = await (await this.api(url, { method, body: JSON.stringify(this.form) })).json();
this.form = { ...blankContact(), ...data.contact };
await this.load();
} catch (error) {
this.dialogError = error.message;
} finally {
this.saving = false;
}
},
async remove(contact) {
if (!window.confirm(`Delete ${contact.display_name}?`)) return;
try {
await this.api(`/api/contacts/${contact.id}`, { method: 'DELETE' });
await this.load();
} catch (error) {
this.error = error.message;
}
},
async addNote() {
const body = this.noteDraft.trim();
if (!body || !this.form.id) return;
await this.api(`/api/contacts/${this.form.id}/notes`, { method: 'POST', body: JSON.stringify({ body }) });
this.noteDraft = '';
const data = await (await this.api(`/api/contacts/${this.form.id}`)).json();
this.form.contact_notes = data.contact.contact_notes;
},
async deleteNote(noteId) {
await this.api(`/api/contacts/${this.form.id}/notes/${noteId}`, { method: 'DELETE' });
this.form.contact_notes = this.form.contact_notes.filter((n) => n.id !== noteId);
}
}
};
</script>