Initial commit

This commit is contained in:
root
2025-12-21 09:53:25 -05:00
committed by Sebastian Krupinski
commit fa24f1468f
32 changed files with 4972 additions and 0 deletions

29
.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# Frontend development
node_modules/
*.local
.env.local
.env.*.local
.cache/
.vite/
.temp/
.tmp/
# Frontend build
/static/
# Backend development
/lib/vendor/
coverage/
phpunit.xml.cache
.phpunit.result.cache
.php-cs-fixer.cache
.phpstan.cache
.phpactor/
# Editors
.DS_Store
.vscode/
.idea/
# Logs
*.log

9
composer.json Normal file
View File

@@ -0,0 +1,9 @@
{
"name": "ktxm/people",
"type": "project",
"autoload": {
"psr-4": {
"KTXM\\People\\": "lib/"
}
}
}

80
lib/Module.php Normal file
View File

@@ -0,0 +1,80 @@
<?php
namespace KTXM\People;
use KTXF\Module\ModuleBrowserInterface;
use KTXF\Module\ModuleInstanceAbstract;
/**
* People Module
*/
class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
{
public function __construct()
{ }
public function handle(): string
{
return 'people';
}
public function label(): string
{
return 'People';
}
public function author(): string
{
return 'Ktrix';
}
public function description(): string
{
return 'People management interface - provides address book selection and contact viewing/editing capabilities';
}
public function version(): string
{
return '0.0.1';
}
public function permissions(): array
{
return [
'people' => [
'label' => 'Access People',
'description' => 'View and access the people module',
'group' => 'People Management'
],
'people.contacts.view' => [
'label' => 'View Contacts',
'description' => 'View contact details',
'group' => 'People Management'
],
'people.contacts.edit' => [
'label' => 'Edit Contacts',
'description' => 'Edit contact information',
'group' => 'People Management'
],
'people.*' => [
'label' => 'Full People Management',
'description' => 'All people management operations',
'group' => 'People Management'
],
];
}
public function registerBI(): array
{
return [
'handle' => $this->handle(),
'namespace' => 'People',
'version' => $this->version(),
'label' => $this->label(),
'author' => $this->author(),
'description' => $this->description(),
'boot' => 'static/module.mjs',
];
}
}

1527
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "@ktrix/people",
"description": "Ktrix People Module",
"version": "0.0.1",
"private": true,
"license": "AGPL-3.0-or-later",
"author": "Sebastian Krupinski",
"type": "module",
"scripts": {
"build": "vite build --mode production --config vite.config.ts",
"dev": "vite build --mode development --config vite.config.ts",
"watch": "vite build --mode development --watch --config vite.config.ts",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"dependencies": {
"pinia": "^2.3.1",
"vue": "^3.5.18",
"vue-router": "^4.5.1",
"vuetify": "^3.10.2"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"@vue/tsconfig": "^0.7.0",
"typescript": "~5.8.3",
"vite": "^7.1.2",
"vue-tsc": "^3.0.5"
}
}

View File

@@ -0,0 +1,305 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useServicesStore } from '@PeopleManager/stores/servicesStore'
import { CollectionObject } from '@PeopleManager/models/collection'
import { ServiceObject } from '@PeopleManager/models/service'
// Store
const servicesStore = useServicesStore()
// Props
const props = defineProps<{
modelValue: boolean
collection: CollectionObject | null
mode: 'create' | 'edit'
}>()
// Emits
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'save': [collection: CollectionObject, service: ServiceObject]
'delete': [collection: CollectionObject]
}>()
// State
const services = ref<ServiceObject[]>([])
const editingCollection = ref<CollectionObject | null>(null)
const editingCollectionService = ref<ServiceObject | null>(null)
const editingCollectionValidated = ref(false)
const colorMenuOpen = ref(false)
const colorOptions = [
'#FF6B6B', '#FF8E4D', '#FFB347', '#FFD166', '#E5FF7F', '#B5E86D',
'#7CD992', '#46CBB7', '#2ECCCB', '#27B1E6', '#2196F3', '#2E6FE3',
'#3A4CC9', '#5A3FC0', '#7F3FBF', '#B339AE', '#DE3A9B', '#FF6FB7'
]
const COLORS_PER_ROW = 6
const colorRows = computed(() => {
const rows: string[][] = []
for (let i = 0; i < colorOptions.length; i += COLORS_PER_ROW) {
rows.push(colorOptions.slice(i, i + COLORS_PER_ROW))
}
return rows
})
// Computed
const dialogOpen = computed({
get: () => props.modelValue,
set: (value: boolean) => emit('update:modelValue', value)
})
// Functions
const onOpen = async () => {
if (services.value.length === 0) {
services.value = await servicesStore.list()
}
if (!props.collection) {
return
}
// Clone the collection to avoid mutating the original
editingCollection.value = props.collection.clone()
if (props.collection.id !== null) {
// Edit mode - find the service
editingCollectionService.value = services.value.find(s =>
s.provider === props.collection!.provider && s.id === props.collection!.service
) || null
} else {
// Create mode - use first service that can create
editingCollectionService.value = services.value.filter(s => s.capabilities?.CollectionCreate)[0] || null
}
}
const onSave = () => {
if (!editingCollection.value || !editingCollectionService.value) {
return
}
emit('save', editingCollection.value, editingCollectionService.value)
dialogOpen.value = false
}
const onDelete = () => {
if (!editingCollection.value) {
return
}
emit('delete', editingCollection.value)
dialogOpen.value = false
}
const onColorSelect = (color: string | null, closeMenu = true) => {
if (!editingCollection.value) {
return
}
editingCollection.value.color = color
if (closeMenu) {
colorMenuOpen.value = false
}
}
const onCustomColorChange = (color: string) => {
if (!color) {
return
}
onColorSelect(color, false)
}
// Watch for dialog opening
watch(() => props.modelValue, async (newValue) => {
if (newValue) {
await onOpen()
}
})
</script>
<template>
<v-dialog
v-model="dialogOpen"
max-width="500"
persistent
>
<v-card>
<v-card-title>
<v-icon icon="mdi-book-plus" class="mr-2" />
{{ mode === 'create' ? 'Create New Address Book' : 'Edit Address Book' }}
</v-card-title>
<v-divider />
<v-card-text v-if="editingCollection" class="pt-4">
<v-form v-model="editingCollectionValidated" ref="collectionEditor">
<v-combobox v-show="mode === 'create' && services.length > 1"
v-model="editingCollectionService"
label="Service"
:items="services.filter(s => s.capabilities?.CollectionCreate)"
item-title="label"
item-value="id"
required
:rules="[(v: ServiceObject) => !!v || 'Service is required']"
/>
<div class="mb-4"><strong>Service </strong> {{ editingCollection.service }}</div>
<v-text-field
v-model="editingCollection.label"
label="Label"
required
:rules="[(v: string) => !!v || 'Label is required']"
>
<template #append-inner>
<v-menu
v-model="colorMenuOpen"
:close-on-content-click="false"
location="bottom end"
offset="8"
>
<template #activator="{ props: menuProps }">
<v-btn
v-bind="menuProps"
icon
variant="text"
size="small"
:style="{ color: editingCollection.color || 'var(--v-theme-on-surface)' }"
aria-label="Select book color"
title="Select color"
>
<v-icon icon="mdi-palette" />
</v-btn>
</template>
<v-card class="pa-2 color-menu" elevation="4">
<div class="color-menu-header">
<v-btn
variant="text"
size="small"
class="color-menu-header__close"
aria-label="Close"
title="Close"
>
<v-icon icon="mdi-close" />
</v-btn>
</div>
<div class="color-menu-body__presets">
<div
v-for="(rowColors, rowIndex) in colorRows"
:key="`color-row-${rowIndex}`"
class="color-menu-body__row"
>
<v-btn
v-for="color in rowColors"
:key="color"
variant="flat"
size="small"
class="color-menu-body__presets--swatch"
:class="{ 'color-menu-body__presets--swatch--active': editingCollection.color === color }"
:style="{ backgroundColor: color }"
@click="onColorSelect(color)">
<v-icon
v-if="editingCollection.color === color"
icon="mdi-check"
size="x-small"
color="white"
/>
</v-btn>
</div>
</div>
<div class="color-menu-body__picker">
<v-color-picker
v-model="editingCollection.color"
mode="hex"
hide-canvas
width="100%"
elevation="0"
@update:model-value="onCustomColorChange"
/>
</div>
</v-card>
</v-menu>
</template>
</v-text-field>
<v-textarea
v-model="editingCollection.description"
label="Description"
rows="2"
/>
<v-row>
<v-col v-if="mode === 'edit'" cols="6">
<v-switch
v-model="editingCollection.enabled"
label="Enabled"
color="primary"
/>
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-divider />
<v-card-actions class="justify-space-between align-center">
<div>
<v-btn
v-if="mode === 'edit' && editingCollectionService?.capabilities?.CollectionDestroy"
color="error"
variant="text"
@click="onDelete"
>
Delete
</v-btn>
</div>
<div class="d-flex align-center">
<v-btn
variant="text"
@click="dialogOpen = false"
>
Cancel
</v-btn>
<v-btn
color="primary"
variant="flat"
:disabled="!editingCollectionValidated"
@click="onSave"
>
{{ mode === 'create' ? 'Create' : 'Save' }}
</v-btn>
</div>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<style scoped>
.color-menu {
max-width: 320px;
}
.color-menu-header {
display: flex;
justify-content: flex-end;
width: 100%;
margin-bottom: 16px;
}
.color-menu-header__close {
margin-left: auto;
}
.color-menu-body__presets {
width: 100%;
margin-bottom: 20px;
}
.color-menu-body__row {
display: flex;
gap: 8px;
}
.color-menu-body__picker {
margin-top: 20px;
}
</style>

View File

@@ -0,0 +1,127 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useCollectionsStore } from '@PeopleManager/stores/collectionsStore'
import { CollectionObject } from '@PeopleManager/models/collection';
// Store
const collectionsStore = useCollectionsStore()
// Props
const props = defineProps<{
selectedCollection?: CollectionObject | null
}>()
// Emits
const emit = defineEmits<{
'select': [collection: CollectionObject]
'edit': [collection: CollectionObject]
}>()
// State
const loading = ref(false)
const collections = ref<CollectionObject[]>([])
// Lifecycle
onMounted(async () => {
loading.value = true
try {
collections.value = await collectionsStore.list()
} catch (error) {
console.error('[People] - Failed to load collections:', error)
}
loading.value = false
})
// Functions
const onCollectionSelect = (collection: CollectionObject) => {
console.log('[People] - Collection selected', collection)
emit('select', collection)
}
const onCollectionEdit = (collection: CollectionObject) => {
emit('edit', collection)
}
// Expose refresh method
defineExpose({
async refresh() {
loading.value = true
try {
collections.value = await collectionsStore.list()
} catch (error) {
console.error('[People] - Failed to load collections:', error)
}
loading.value = false
}
})
</script>
<template>
<div class="collection-selector">
<div class="collection-selector-header">
<div class="d-flex align-center mb-3">
<v-icon icon="mdi-book-multiple" size="small" class="mr-2" />
<span class="text-subtitle-2 font-weight-bold">Address Books</span>
</div>
</div>
<v-divider class="my-2" />
<div class="collection-selector-content">
<v-progress-linear v-if="loading" indeterminate color="primary" />
<v-list v-else density="compact" nav class="pa-0">
<v-list-item
v-for="collection in collections"
:key="collection.id"
:value="collection.id"
:active="selectedCollection?.id === collection.id"
@click="onCollectionSelect(collection)"
rounded="lg"
class="mb-1"
>
<template #prepend>
<v-icon :color="collection.color || 'primary'" icon="mdi-book-outline" size="small" />
</template>
<v-list-item-title>{{ collection.label }}</v-list-item-title>
<template #append>
<v-btn
icon="mdi-pencil"
size="x-small"
variant="text"
@click.stop="onCollectionEdit(collection)"
/>
</template>
</v-list-item>
</v-list>
<v-alert v-if="!loading && collections.length === 0" type="info" variant="tonal" density="compact" class="mt-2">
No address books found
</v-alert>
</div>
</div>
</template>
<style scoped>
.collection-selector {
display: flex;
flex-direction: column;
height: 100%;
}
.collection-selector-header {
flex-shrink: 0;
}
.collection-selector-content {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.v-list-item {
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,400 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { EntityObject } from '@PeopleManager/models/entity'
import PersonEditorNames from './editors/PersonEditorNames.vue'
import PersonEditorTitles from './editors/PersonEditorTitles.vue'
import PersonEditorAnniversaries from './editors/PersonEditorAnniversaries.vue'
import PersonEditorPhysicalLocations from './editors/PersonEditorPhysicalLocations.vue'
import PersonEditorPhones from './editors/PersonEditorPhones.vue'
import PersonEditorEmails from './editors/PersonEditorEmails.vue'
import PersonEditorOrganizations from './editors/PersonEditorOrganizations.vue'
import PersonEditorNotes from './editors/PersonEditorNotes.vue'
import PersonEditorLanguages from './editors/PersonEditorLanguages.vue'
import PersonEditorMedia from './editors/PersonEditorMedia.vue'
import PersonEditorLabel from './editors/PersonEditorLabel.vue'
import PersonEditorVirtualLocations from './editors/PersonEditorVirtualLocations.vue'
import PersonEditorMembers from './editors/PersonEditorMembers.vue'
// Props
const props = defineProps<{
mode: 'edit' | 'view'
selectedEntity?: EntityObject | null
}>()
// Emits
const emit = defineEmits<{
'edit': []
'cancel': []
'close': []
'save': [entity: EntityObject]
'delete': [entity: EntityObject]
}>()
// State
const loading = ref(false)
const saving = ref(false)
// Computed
const mode = computed(() => props.mode)
const entity = computed(() => props.selectedEntity || null)
const entityObject = computed(() => entity.value?.data ?? null)
const entityFresh = computed(() => entity.value?.id === null || entity.value?.id === undefined)
const entityType = computed(() => entityObject.value?.type || 'individual')
// Determine which sections to show based on entity type
const showNames = computed(() => entityType.value === 'individual')
const showTitles = computed(() => entityType.value === 'individual')
const showAnniversaries = computed(() => entityType.value === 'individual')
const showOrganizations = computed(() => entityType.value === 'individual')
const showLanguages = computed(() => entityType.value === 'individual')
const showMembers = computed(() => entityType.value === 'group')
// Sections shown for all types
const showLabel = computed(() => true)
const showPhysicalLocations = computed(() => entityType.value !== 'group')
const showPhones = computed(() => entityType.value !== 'group')
const showEmails = computed(() => entityType.value !== 'group')
const showVirtualLocations = computed(() => true)
const showMedia = computed(() => true)
const showNotes = computed(() => true)
// UI helpers
const entityIcon = computed(() => {
switch (entityType.value) {
case 'organization':
return 'mdi-domain'
case 'group':
return 'mdi-account-group'
default:
return 'mdi-account'
}
})
// Functions
const startEdit = () => {
emit('edit')
}
const cancelEdit = () => {
emit('cancel')
}
const saveEntity = async () => {
saving.value = true
emit('save', entity.value!)
saving.value = false
}
// Title management
const addTitle = () => {
(entityObject.value as any)?.addTitle()
}
const removeTitle = (key: string) => {
(entityObject.value as any)?.removeTitle(key)
}
// Anniversary management
const addAnniversary = () => {
(entityObject.value as any)?.addAnniversary()
}
const removeAnniversary = (index: number) => {
(entityObject.value as any)?.removeAnniversary(index)
}
const updateAnniversaryDate = (anniversary: any, date: string) => {
anniversary.when = date
}
// Physical location management
const addPhysicalLocation = () => {
(entityObject.value as any)?.addPhysicalLocation()
}
const removePhysicalLocation = (key: string) => {
(entityObject.value as any)?.removePhysicalLocation(key)
}
// Phone management
const addPhone = () => {
(entityObject.value as any)?.addPhone()
}
const removePhone = (key: string) => {
(entityObject.value as any)?.removePhone(key)
}
// Email management
const addEmail = () => {
(entityObject.value as any)?.addEmail()
}
const removeEmail = (key: string) => {
(entityObject.value as any)?.removeEmail(key)
}
// Organization management
const addOrganization = () => {
(entityObject.value as any)?.addOrganization()
}
const removeOrganization = (key: string) => {
(entityObject.value as any)?.removeOrganization(key)
}
// Note management
const addNote = () => {
(entityObject.value as any)?.addNote()
}
const removeNote = (key: string) => {
(entityObject.value as any)?.removeNote(key)
}
// Language management
const addLanguage = () => {
(entityObject.value as any)?.addLanguage()
}
const removeLanguage = (index: number) => {
(entityObject.value as any)?.removeLanguage(index)
}
// Media management
const addMedia = () => {
(entityObject.value as any)?.addMedia()
}
const removeMedia = (key: string) => {
(entityObject.value as any)?.removeMedia(key)
}
// Member management (for groups)
const addMember = () => {
(entityObject.value as any)?.addMember()
}
const removeMember = (key: string) => {
(entityObject.value as any)?.removeMember(key)
}
// Virtual location management
const addVirtualLocation = () => {
(entityObject.value as any)?.addVirtualLocation()
}
const removeVirtualLocation = (key: string) => {
(entityObject.value as any)?.removeVirtualLocation(key)
}
</script>
<template>
<div class="person-editor-card">
<div class="people-editor-header d-flex justify-space-between pa-4">
<div>
<v-icon :icon="entityIcon" class="mr-2" />
{{ entity?.data?.label || 'Nothing Selected' }}
</div>
<div v-if="!loading && entity">
<v-btn
v-if="mode === 'view'"
icon="mdi-pencil"
size="small"
variant="text"
@click="startEdit"
/>
<v-btn
v-if="!entityFresh"
icon="mdi-delete"
size="small"
variant="text"
color="error"
@click="emit('delete', entity!)"
/>
<v-btn
icon="mdi-close"
size="small"
variant="text"
@click="$emit('close')"
/>
</div>
</div>
<div class="people-editor-fields">
<v-progress-linear v-if="loading" indeterminate color="primary" />
<div v-if="!entity" class="text-center pa-8">
<v-icon icon="mdi-account-search" size="64" color="grey" class="mb-4" />
<p class="text-h6 text-grey">Select an individual, organization or group to view details</p>
</div>
<div v-else class="pa-4">
<PersonEditorLabel
v-if="showLabel"
:mode="mode"
:label="entity.data!.label"
@update:label="entity.data!.label = $event"
/>
<PersonEditorNames
v-if="showNames"
:mode="mode"
:names="entity.data!.names"
@update:names="entity.data!.names = $event"
/>
<PersonEditorTitles
v-if="showTitles"
:titles="(entity.data as any)!.titles || {}"
:mode="mode"
@add-title="addTitle"
@remove-title="removeTitle"
/>
<PersonEditorAnniversaries
v-if="showAnniversaries"
:anniversaries="(entity.data as any)!.anniversaries || []"
:mode="mode"
@add-anniversary="addAnniversary"
@remove-anniversary="removeAnniversary"
@update-anniversary-date="updateAnniversaryDate"
/>
<PersonEditorPhysicalLocations
v-if="showPhysicalLocations"
:physical-locations="(entity.data as any)!.physicalLocations || {}"
:mode="mode"
@add-physical-location="addPhysicalLocation"
@remove-physical-location="removePhysicalLocation"
/>
<PersonEditorPhones
v-if="showPhones"
:phones="(entity.data as any)!.phones || {}"
:mode="mode"
@add-phone="addPhone"
@remove-phone="removePhone"
/>
<PersonEditorEmails
v-if="showEmails"
:emails="(entity.data as any)!.emails || {}"
:mode="mode"
@add-email="addEmail"
@remove-email="removeEmail"
/>
<PersonEditorVirtualLocations
v-if="showVirtualLocations"
:virtual-locations="(entity.data as any)!.virtualLocations || {}"
:mode="mode"
@add-virtual-location="addVirtualLocation"
@remove-virtual-location="removeVirtualLocation"
/>
<PersonEditorOrganizations
v-if="showOrganizations"
:organizations="(entity.data as any)!.organizations || {}"
:mode="mode"
@add-organization="addOrganization"
@remove-organization="removeOrganization"
/>
<PersonEditorMembers
v-if="showMembers"
:members="(entity.data as any)!.members || {}"
:mode="mode"
@add-member="addMember"
@remove-member="removeMember"
/>
<PersonEditorNotes
v-if="showNotes"
:notes="entity.data!.notes || {}"
:mode="mode"
@add-note="addNote"
@remove-note="removeNote"
/>
<PersonEditorLanguages
v-if="showLanguages"
:languages="(entity.data as any)!.languages || []"
:mode="mode"
@add-language="addLanguage"
@remove-language="removeLanguage"
/>
<PersonEditorMedia
v-if="showMedia"
:media="entity.data!.media || {}"
:mode="mode"
@add-media="addMedia"
@remove-media="removeMedia"
/>
</div>
</div>
<div class="people-editor-footer d-flex text-end justify-space-between pa-4">
<v-btn v-if="mode === 'edit'"
variant="text"
@click="cancelEdit"
:disabled="saving">
Cancel
</v-btn>
<v-btn v-if="mode === 'edit'"
color="primary"
variant="elevated"
@click="saveEntity"
:loading="saving">
Save
</v-btn>
</div>
</div>
</template>
<style scoped>
.person-editor-card {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
background: transparent !important;
}
.people-editor-header {
background: transparent;
min-height: 72px;
align-items: center;
border-bottom: 1px solid rgb(var(--v-border-color));
flex-shrink: 0;
}
.people-editor-fields {
flex: 1;
overflow-y: auto;
background: transparent;
min-height: 0;
}
.people-editor-footer {
background: transparent;
flex-shrink: 0;
}
.person-editor-card :deep(.v-field) {
background: transparent;
}
.person-editor-card :deep(.v-input) {
background: transparent;
}
.person-editor-card :deep(.v-card) {
background: transparent;
}
</style>

View File

@@ -0,0 +1,265 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useEntitiesStore } from '@PeopleManager/stores/entitiesStore'
import type { CollectionObject } from '@PeopleManager/models/collection'
import type { EntityObject } from '@PeopleManager/models/entity'
// Stores
const entitiesStore = useEntitiesStore()
// Props
const props = defineProps<{
selectedCollection?: CollectionObject | null
selectedEntity?: EntityObject | null
}>()
// Emits
const emit = defineEmits<{
'select': [entity: EntityObject]
'fresh': [type: string]
}>()
// State
const loading = ref(false)
const searchQuery = ref('')
const collectionEntities = ref<EntityObject[]>([])
// Computed
const filteredEntities = computed(() => {
if (!searchQuery.value) {
return collectionEntities.value
}
const query = searchQuery.value.toLowerCase()
return collectionEntities.value.filter(entity => {
const name = `${entity.data?.names.family || ''} ${entity.data?.names.given || ''}`.toLowerCase()
const email = (entity.data?.emails?.[0]?.address || '').toLowerCase()
return name.includes(query) || email.includes(query)
})
})
// Watchers
watch(() => props.selectedCollection, async (newCollection) => {
if (newCollection) {
loading.value = true
try {
collectionEntities.value = await entitiesStore.list(
newCollection.provider,
newCollection.service,
newCollection.id
);
} catch (error) {
console.error('[People] - Failed to load contacts:', error);
} finally {
loading.value = false
}
} else {
collectionEntities.value = []
loading.value = false
}
}, { immediate: true });
// Functions
function entityType(entity: EntityObject): string {
return entity.data?.type || 'individual'
}
function entityIcon(entity: EntityObject): string {
const type = entityType(entity)
switch (type) {
case 'organization':
return 'mdi-domain'
case 'group':
return 'mdi-account-group'
default:
return 'mdi-account'
}
}
function entityPhoto(entity: EntityObject): string | null {
// TODO: Implement photo retrieval from entity data
return null
}
function entityInitials(entity: EntityObject): string {
const type = entityType(entity)
if (type === 'organization') {
const name = (entity.data as any)?.names?.full || entity.data?.label || ''
return name.substring(0, 2).toUpperCase() || 'OR'
}
if (type === 'group') {
const name = (entity.data as any)?.names?.full || entity.data?.label || ''
return name.substring(0, 2).toUpperCase() || 'GR'
}
// Individual
const given = (entity.data as any)?.names?.given
const family = (entity.data as any)?.names?.family
const initials = `${given?.[0] || ''}${family?.[0] || ''}`.toUpperCase()
return initials || '?'
}
function entityLabel(entity: EntityObject): string {
const type = entityType(entity)
if (type === 'organization' || type === 'group') {
return entity.data?.label || (entity.data as any)?.names?.full || 'Unknown'
}
// Individual
return entity.data?.label || `${(entity.data as any)?.names?.given || ''} ${(entity.data as any)?.names?.family || ''}`.trim() || 'Unknown'
}
function entityEmail(entity: EntityObject): string | null {
const emails = (entity.data as any)?.emails
if (!emails) return null
const emailEntries = Object.values(emails)
if (emailEntries.length === 0) return null
// Sort by priority (assuming lower number = higher priority)
emailEntries.sort((a: any, b: any) => (a.priority || 999) - (b.priority || 999))
return (emailEntries[0] as any).address || null
}
function entityOrganization(entity: EntityObject): string | null {
const organizations = (entity.data as any)?.organizations
if (!organizations || Object.keys(organizations).length === 0) return null
const orgEntries = Object.values(organizations)
return (orgEntries[0] as any).Label || null
}
function entityMemberCount(entity: EntityObject): number | null {
const type = entityType(entity)
if (type !== 'group') return null
const members = (entity.data as any)?.members
if (!members) return 0
return Object.keys(members).length
}
function entityAvatarColor(entity: EntityObject): string {
const type = entityType(entity)
switch (type) {
case 'organization':
return 'blue-darken-1'
case 'group':
return 'green-darken-1'
default:
return 'primary'
}
}
</script>
<template>
<div class="person-list-container">
<div class="person-list-header pa-4">
<v-text-field
v-model="searchQuery"
density="compact"
variant="outlined"
label="Search contacts"
prepend-inner-icon="mdi-magnify"
clearable
hide-details
/>
</div>
<div class="pa-0 person-list-content">
<v-progress-linear v-if="loading" indeterminate color="primary" />
<div v-else-if="!selectedCollection" class="text-center pa-8">
<v-icon icon="mdi-book-alert" size="64" color="grey" class="mb-4" />
<p class="text-body-1 text-grey">Select an address book to view contacts</p>
</div>
<div v-else-if="filteredEntities.length === 0" class="text-center pa-8">
<v-icon icon="mdi-account-search" size="64" color="grey" class="mb-4" />
<p class="text-body-1 text-grey">No Individuals, Organizations or Groups found</p>
</div>
<v-list v-else density="compact" nav>
<v-list-item
v-for="entity in filteredEntities"
:key="entity.id"
:value="entity.id"
@click="$emit('select', entity)">
<template #prepend>
<v-avatar :color="entityAvatarColor(entity)" size="40">
<v-img v-if="entityPhoto(entity)" :src="entityPhoto(entity)!" />
<v-icon v-else-if="entityType(entity) !== 'individual'" :icon="entityIcon(entity)" size="small" />
<span v-else class="text-subtitle-2">{{ entityInitials(entity) }}</span>
</v-avatar>
</template>
<v-list-item-title>
<v-icon :icon="entityIcon(entity)" size="x-small" class="mr-1" />
{{ entityLabel(entity) }}
</v-list-item-title>
<v-list-item-subtitle>
<!-- For individuals: show email and organization -->
<template v-if="entityType(entity) === 'individual'">
<div v-if="entityEmail(entity)">
<v-icon icon="mdi-email" size="x-small" class="mr-1" />
{{ entityEmail(entity) }}
</div>
<div v-if="entityOrganization(entity)">
<v-icon icon="mdi-domain" size="x-small" class="mr-1" />
{{ entityOrganization(entity) }}
</div>
</template>
<!-- For organizations: show email -->
<template v-else-if="entityType(entity) === 'organization'">
<div v-if="entityEmail(entity)">
<v-icon icon="mdi-email" size="x-small" class="mr-1" />
{{ entityEmail(entity) }}
</div>
</template>
<!-- For groups: show member count -->
<template v-else-if="entityType(entity) === 'group'">
<div>
<v-icon icon="mdi-account-multiple" size="x-small" class="mr-1" />
{{ entityMemberCount(entity) }} member{{ entityMemberCount(entity) !== 1 ? 's' : '' }}
</div>
</template>
</v-list-item-subtitle>
</v-list-item>
</v-list>
</div>
</div>
</template>
<style scoped>
.person-list-container {
display: flex;
flex-direction: column;
height: 100%;
overflow: auto;
}
.person-list-header {
min-height: 72px;
display: flex;
align-items: center;
border-bottom: 1px solid rgb(var(--v-border-color));
}
.person-list-content {
flex: 1;
overflow-y: auto;
}
.person-list-container :deep(.v-list) {
background: transparent;
}
.person-list-container :deep(.v-field) {
background: transparent;
}
.v-list-item {
cursor: pointer;
}
</style>

View File

@@ -0,0 +1,115 @@
<script setup lang="ts">
import { useClipboard } from '@KTXC/composables/useClipboard'
interface Props {
mode: 'edit' | 'view'
anniversaries: any[]
}
defineProps<Props>()
const emit = defineEmits<{
'add-anniversary': []
'remove-anniversary': [index: number]
'update-anniversary-date': [anniversary: any, date: string]
}>()
const { copiedKey, copyToClipboard } = useClipboard()
const updateAnniversaryDate = (anniversary: any, date: string) => {
emit('update-anniversary-date', anniversary, date)
}
</script>
<template>
<div v-if="(anniversaries && anniversaries.length > 0) || mode === 'edit'" class="person-editor-section">
<div class="d-flex align-center justify-space-between mb-2">
<div class="text-subtitle-1">Anniversaries</div>
<v-btn v-if="mode === 'edit'"
size="small"
variant="outlined"
@click="$emit('add-anniversary')">
<v-icon left>mdi-plus</v-icon>
Add Anniversary
</v-btn>
</div>
<!-- Read-only view -->
<div v-if="mode === 'view'">
<div v-for="(anniversary, index) in anniversaries" :key="index" class="mb-2 pa-2 border rounded">
<div class="d-flex align-center justify-space-between mb-1">
<div class="d-flex align-center">
<v-icon icon="mdi-cake-variant" size="small" class="mr-2" />
<strong>{{ anniversary.type || 'Unknown' }} Anniversary</strong>
</div>
<v-btn
:icon="copiedKey === index ? 'mdi-check' : 'mdi-content-copy'"
size="x-small"
variant="text"
:color="copiedKey === index ? 'success' : undefined"
@click="copyToClipboard(anniversary.when?.date || '', index)"
/>
</div>
<div class="text-caption text-grey">
Date: {{ anniversary.when?.date || 'Not set' }} |
Location: {{ anniversary.location || 'Not set' }}
</div>
</div>
</div>
<!-- Edit view -->
<div v-else>
<div v-for="(anniversary, index) in anniversaries" :key="index" class="mb-4 pa-3 border rounded">
<div class="d-flex align-center justify-space-between mb-2">
<strong>Anniversary</strong>
<v-btn
icon="mdi-delete"
size="small"
color="error"
variant="text"
@click="$emit('remove-anniversary', index)">
</v-btn>
</div>
<v-row class="mb-2">
<v-col cols="12" md="6">
<v-select
v-model="anniversary.type"
:items="['birth', 'death', 'nuptial']"
label="Type"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="anniversary.location"
label="Location"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-text-field
:model-value="anniversary.when?.date || ''"
@update:model-value="updateAnniversaryDate(anniversary, $event)"
label="Date"
type="date"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
</div>
</div>
</div>
</template>
<style scoped>
.person-editor-section {
margin-bottom: 1.5rem;
}
</style>

View File

@@ -0,0 +1,108 @@
<script setup lang="ts">
import { useClipboard } from '@KTXC/composables/useClipboard'
interface Props {
mode: 'edit' | 'view'
emails: Record<string, any>
}
defineProps<Props>()
const emit = defineEmits<{
'add-email': []
'remove-email': [key: string]
}>()
const { copiedKey, copyToClipboard } = useClipboard<string>()
</script>
<template>
<div v-if="Object.keys(emails || {}).length > 0 || mode === 'edit'" class="person-editor-section">
<div class="d-flex align-center justify-space-between mb-2">
<div class="text-subtitle-1">Emails</div>
<v-btn v-if="mode === 'edit'"
size="small"
variant="outlined"
@click="$emit('add-email')">
<v-icon left>mdi-plus</v-icon>
Add Email
</v-btn>
</div>
<!-- Read-only view -->
<div v-if="mode === 'view'">
<div v-for="(email, key) in emails" :key="key" class="mb-2 pa-2 border rounded">
<div class="d-flex align-center justify-space-between mb-1">
<div class="d-flex align-center">
<v-icon icon="mdi-email" size="small" class="mr-2" />
<strong>{{ email.address || 'No address' }}</strong>
</div>
<v-btn
:icon="copiedKey === key ? 'mdi-check' : 'mdi-content-copy'"
size="x-small"
variant="text"
:color="copiedKey === key ? 'success' : undefined"
@click="copyToClipboard(email.address || '', key as string)"
/>
</div>
<div class="text-caption text-grey">
Context: {{ email.context || 'Not set' }} |
Priority: {{ email.priority || 'Not set' }}
</div>
</div>
</div>
<!-- Edit view -->
<div v-else>
<div v-for="(email, index) in emails" :key="index" class="mb-4 pa-3 border rounded">
<div class="d-flex align-center justify-space-between mb-2">
<strong>Email</strong>
<v-btn
icon="mdi-delete"
size="small"
color="error"
variant="text"
@click="$emit('remove-email', index)">
</v-btn>
</div>
<v-row class="mb-2">
<v-col cols="12" md="6">
<v-text-field
v-model="email.address"
label="Address"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="email.context"
label="Context"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model.number="email.priority"
label="Priority"
type="number"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
</div>
</div>
</div>
</template>
<style scoped>
.person-editor-section {
margin-bottom: 1.5rem;
}
</style>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useClipboard } from '@KTXC/composables/useClipboard'
interface Props {
mode: 'edit' | 'view'
label?: string | null
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:label': [label: string | null]
}>()
const { copiedKey, copyToClipboard } = useClipboard<string>()
const labelValue = computed({
get: () => props.label ?? '',
set: (value) => emit('update:label', value || null)
})
</script>
<template>
<div class="person-editor-section">
<div class="text-subtitle-1 mb-2">Label</div>
<!-- Read-only view -->
<div v-if="mode === 'view'" class="mb-4">
<div class="d-flex align-center justify-space-between pa-2">
<div class="d-flex align-center">
<v-icon icon="mdi-label" size="small" class="mr-2" />
<span>{{ labelValue || 'Not set' }}</span>
</div>
<v-btn
v-if="labelValue"
:icon="copiedKey === 'label' ? 'mdi-check' : 'mdi-content-copy'"
size="x-small"
variant="text"
:color="copiedKey === 'label' ? 'success' : undefined"
@click="copyToClipboard(labelValue, 'label')"
/>
</div>
</div>
<!-- Edit view -->
<div v-else>
<v-text-field
v-model="labelValue"
label="Label"
variant="outlined"
density="compact"
/>
</div>
</div>
</template>
<style scoped>
.person-editor-section {
margin-bottom: 1.5rem;
}
</style>

View File

@@ -0,0 +1,117 @@
<script setup lang="ts">
import { useClipboard } from '@KTXC/composables/useClipboard'
interface Props {
mode: 'edit' | 'view'
languages: any[]
}
defineProps<Props>()
const emit = defineEmits<{
'add-language': []
'remove-language': [index: number]
}>()
const { copiedKey, copyToClipboard } = useClipboard()
</script>
<template>
<div v-if="(languages || []).length > 0 || mode === 'edit'" class="person-editor-section">
<div class="d-flex align-center justify-space-between mb-2">
<div class="text-subtitle-1">Languages</div>
<v-btn v-if="mode === 'edit'"
size="small"
variant="outlined"
@click="$emit('add-language')">
<v-icon left>mdi-plus</v-icon>
Add Language
</v-btn>
</div>
<!-- Read-only view -->
<div v-if="mode === 'view'">
<div v-for="(lang, index) in languages" :key="index" class="mb-2 pa-2 border rounded">
<div class="d-flex align-center justify-space-between mb-1">
<div class="d-flex align-center">
<v-icon icon="mdi-translate" size="small" class="mr-2" />
<strong>{{ lang.Data || 'Unknown Language' }}</strong>
</div>
<v-btn
:icon="copiedKey === index ? 'mdi-check' : 'mdi-content-copy'"
size="x-small"
variant="text"
:color="copiedKey === index ? 'success' : undefined"
@click="copyToClipboard(lang.Data || '', index)"
/>
</div>
<div class="text-caption text-grey">
ID: {{ lang.Id || 'Not set' }} |
Context: {{ lang.Context || 'Not set' }} |
Priority: {{ lang.Priority || 'Not set' }}
</div>
</div>
</div>
<!-- Edit view -->
<div v-else>
<div v-for="(lang, index) in languages" :key="index" class="mb-4 pa-3 border rounded">
<div class="d-flex align-center justify-space-between mb-2">
<strong>Language</strong>
<v-btn
icon="mdi-delete"
size="small"
color="error"
variant="text"
@click="$emit('remove-language', index)">
</v-btn>
</div>
<v-row class="mb-2">
<v-col cols="12" md="6">
<v-text-field
v-model="lang.Data"
label="Language"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="lang.Id"
label="Language ID"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="lang.Context"
label="Context"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model.number="lang.Priority"
label="Priority"
type="number"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
</div>
</div>
</div>
</template>
<style scoped>
.person-editor-section {
margin-bottom: 1.5rem;
}
</style>

View File

@@ -0,0 +1,148 @@
<script setup lang="ts">
import { useClipboard } from '@KTXC/composables/useClipboard'
interface Props {
mode: 'edit' | 'view'
media: Record<string, any>
}
defineProps<Props>()
const emit = defineEmits<{
'add-media': []
'remove-media': [key: string]
}>()
const { copiedKey, copyToClipboard } = useClipboard<string>()
</script>
<template>
<div v-if="Object.keys(media || {}).length > 0 || mode === 'edit'" class="person-editor-section">
<div class="d-flex align-center justify-space-between mb-2">
<div class="text-subtitle-1">Media</div>
<v-btn v-if="mode === 'edit'"
size="small"
variant="outlined"
@click="$emit('add-media')">
<v-icon left>mdi-plus</v-icon>
Add Media
</v-btn>
</div>
<!-- Read-only view -->
<div v-if="mode === 'view'">
<div v-for="(item, key) in media" :key="key" class="mb-2 pa-2 border rounded">
<div class="d-flex align-center justify-space-between mb-1">
<div class="d-flex align-center">
<v-icon icon="mdi-image" size="small" class="mr-2" />
<strong>{{ item.label || item.uri || 'Unnamed Media' }}</strong>
</div>
<v-btn
:icon="copiedKey === key ? 'mdi-check' : 'mdi-content-copy'"
size="x-small"
variant="text"
:color="copiedKey === key ? 'success' : undefined"
@click="copyToClipboard(item.uri || '', key as string)"
/>
</div>
<div class="text-caption text-grey">
Type: {{ item.type || 'Not set' }} |
Kind: {{ item.kind || 'Not set' }} |
Contexts: {{ (item.contexts || []).join(', ') || 'None' }} |
Pref: {{ item.pref || 'Not set' }}
</div>
</div>
</div>
<!-- Edit view -->
<div v-else>
<div v-for="(item, index) in media" :key="index" class="mb-4 pa-3 border rounded">
<div class="d-flex align-center justify-space-between mb-2">
<strong>Media</strong>
<v-btn
icon="mdi-delete"
size="small"
color="error"
variant="text"
@click="$emit('remove-media', index)">
</v-btn>
</div>
<v-row class="mb-2">
<v-col cols="12" md="6">
<v-text-field
v-model="item.uri"
label="URI"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="item.label"
label="Label"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
<v-row class="mb-2">
<v-col cols="12" md="4">
<v-select
v-model="item.type"
:items="['photo', 'video', 'audio', 'document']"
label="Type"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="4">
<v-select
v-model="item.kind"
:items="['photo', 'sound', 'logo']"
label="Kind"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model="item.mediaType"
label="Media Type"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
<v-row class="mb-2">
<v-col cols="12" md="6">
<v-text-field
v-model="item.contexts"
label="Contexts (comma-separated)"
variant="outlined"
density="compact"
hint="Enter contexts separated by commas"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model.number="item.pref"
label="Preference"
type="number"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
</div>
</div>
</div>
</template>
<style scoped>
.person-editor-section {
margin-bottom: 1.5rem;
}
</style>

View File

@@ -0,0 +1,117 @@
<script setup lang="ts">
import { useClipboard } from '@KTXC/composables/useClipboard'
interface Props {
mode: 'edit' | 'view'
members: Record<string, any>
}
defineProps<Props>()
const emit = defineEmits<{
'add-member': []
'remove-member': [key: string]
}>()
const { copiedKey, copyToClipboard } = useClipboard<string>()
</script>
<template>
<div v-if="Object.keys(members || {}).length > 0 || mode === 'edit'" class="person-editor-section">
<div class="d-flex align-center justify-space-between mb-2">
<div class="text-subtitle-1">Members</div>
<v-btn v-if="mode === 'edit'"
size="small"
variant="outlined"
@click="$emit('add-member')">
<v-icon left>mdi-plus</v-icon>
Add Member
</v-btn>
</div>
<!-- Read-only view -->
<div v-if="mode === 'view'">
<div v-for="(member, key) in members" :key="key" class="mb-2 pa-2 border rounded">
<div class="d-flex align-center justify-space-between mb-1">
<div class="d-flex align-center">
<v-icon icon="mdi-account" size="small" class="mr-2" />
<strong>Entity ID: {{ member.entityId || 'Not set' }}</strong>
</div>
<v-btn
:icon="copiedKey === key ? 'mdi-check' : 'mdi-content-copy'"
size="x-small"
variant="text"
:color="copiedKey === key ? 'success' : undefined"
@click="copyToClipboard(member.entityId || '', key as string)"
/>
</div>
<div class="text-caption text-grey">
Role: {{ member.role || 'Not set' }} |
Context: {{ member.context || 'Not set' }} |
Priority: {{ member.priority || 'Not set' }}
</div>
</div>
</div>
<!-- Edit view -->
<div v-else>
<div v-for="(member, index) in members" :key="index" class="mb-4 pa-3 border rounded">
<div class="d-flex align-center justify-space-between mb-2">
<strong>Member</strong>
<v-btn
icon="mdi-delete"
size="small"
color="error"
variant="text"
@click="$emit('remove-member', index)">
</v-btn>
</div>
<v-row class="mb-2">
<v-col cols="12" md="6">
<v-text-field
v-model="member.entityId"
label="Entity ID"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="member.role"
label="Role"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="member.context"
label="Context"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model.number="member.priority"
label="Priority"
type="number"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
</div>
</div>
</div>
</template>
<style scoped>
.person-editor-section {
margin-bottom: 1.5rem;
}
</style>

View File

@@ -0,0 +1,139 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useClipboard } from '@KTXC/composables/useClipboard'
interface Props {
mode: 'edit' | 'view'
names: any
}
const props = defineProps<Props>()
const emit = defineEmits<{
'update:names': [names: any]
}>()
const { copiedKey, copyToClipboard } = useClipboard<string>()
const names = computed({
get: () => props.names,
set: (value) => emit('update:names', value)
})
const formatName = (names: any) => {
if (!names) return 'Not set'
const parts = []
if (names.prefix) parts.push(names.prefix)
if (names.given) parts.push(names.given)
if (names.additional) parts.push(names.additional)
if (names.family) parts.push(names.family)
if (names.suffix) parts.push(names.suffix)
return parts.join(' ') || 'Not set'
}
</script>
<template>
<div class="person-editor-section">
<div class="text-subtitle-1 mb-2">Name</div>
<!-- Read-only view -->
<div v-if="mode === 'view'" class="mb-4">
<div class="d-flex align-center justify-space-between pa-2">
<div class="d-flex align-center">
<v-icon icon="mdi-account" size="small" class="mr-2" />
<span>{{ formatName(names) }}</span>
</div>
<v-btn
v-if="formatName(names) !== 'Not set'"
:icon="copiedKey === 'name' ? 'mdi-check' : 'mdi-content-copy'"
size="x-small"
variant="text"
:color="copiedKey === 'name' ? 'success' : undefined"
@click="copyToClipboard(formatName(names), 'name')"
/>
</div>
</div>
<!-- Edit view -->
<div v-else>
<v-row class="mb-2">
<v-col cols="12" md="6">
<v-text-field
v-model="names.given"
label="Given Name"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="names.family"
label="Family Name"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
<v-row class="mb-2">
<v-col cols="12" md="4">
<v-text-field
v-model="names.additional"
label="Additional Name"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model="names.prefix"
label="Prefix"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model="names.suffix"
label="Suffix"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
<div class="text-subtitle-1 mb-2 mt-4">Phonetic Name</div>
<v-row class="mb-2">
<v-col cols="12" md="4">
<v-text-field
v-model="names.phoneticGiven"
label="Phonetic Given Name"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model="names.phoneticAdditional"
label="Phonetic Additional Name"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model="names.phoneticFamily"
label="Phonetic Family Name"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
</div>
</div>
</template>
<style scoped>
.person-editor-section {
margin-bottom: 1.5rem;
}
</style>

View File

@@ -0,0 +1,160 @@
<script setup lang="ts">
import { useClipboard } from '@KTXC/composables/useClipboard'
interface Props {
mode: 'edit' | 'view'
notes: Record<string, any>
}
defineProps<Props>()
const emit = defineEmits<{
'add-note': []
'remove-note': [key: string]
}>()
const { copiedKey, copyToClipboard } = useClipboard<string>()
</script>
<template>
<div v-if="Object.keys(notes || {}).length > 0 || mode === 'edit'" class="person-editor-section">
<div class="d-flex align-center justify-space-between mb-2">
<div class="text-subtitle-1">Notes</div>
<v-btn v-if="mode === 'edit'"
size="small"
variant="outlined"
@click="$emit('add-note')">
<v-icon left>mdi-plus</v-icon>
Add Note
</v-btn>
</div>
<!-- Read-only view -->
<div v-if="mode === 'view'">
<div v-for="(note, key) in notes" :key="key" class="mb-2 pa-2 border rounded">
<div class="d-flex align-center justify-space-between mb-1">
<div class="d-flex align-center">
<v-icon icon="mdi-note-text" size="small" class="mr-2" />
<strong>{{ note.content || 'Empty note' }}</strong>
</div>
<v-btn
:icon="copiedKey === key ? 'mdi-check' : 'mdi-content-copy'"
size="x-small"
variant="text"
:color="copiedKey === key ? 'success' : undefined"
@click="copyToClipboard(note.content || '', key as string)"
/>
</div>
<div class="text-caption text-grey">
Date: {{ note.date?.date || 'Not set' }} |
Author: {{ note.authorName || note.authorUri || 'Unknown' }} |
Context: {{ note.context || 'Not set' }} |
Priority: {{ note.priority || 'Not set' }}
</div>
</div>
</div>
<!-- Edit view -->
<div v-else>
<div v-for="(note, index) in notes" :key="index" class="mb-4 pa-3 border rounded">
<div class="d-flex align-center justify-space-between mb-2">
<strong>Note</strong>
<v-btn
icon="mdi-delete"
size="small"
color="error"
variant="text"
@click="$emit('remove-note', index)">
</v-btn>
</div>
<v-row class="mb-2">
<v-col cols="12">
<v-textarea
v-model="note.content"
label="Content"
variant="outlined"
density="compact"
rows="3"
/>
</v-col>
</v-row>
<v-row class="mb-2">
<v-col cols="12" md="6">
<v-text-field
v-model="note.authorName"
label="Author Name"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="note.authorUri"
label="Author URI"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
<v-row class="mb-2">
<v-col cols="12" md="6">
<v-text-field
v-model="note.context"
label="Context"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model.number="note.priority"
label="Priority"
type="number"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-text-field
:model-value="note.date?.date || ''"
@update:model-value="updateNoteDate(note, $event)"
label="Date"
type="date"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
</div>
</div>
</div>
</template>
<script lang="ts">
export default {
methods: {
updateNoteDate(note: any, date: string) {
if (!note.date) {
note.date = {
date: '',
timezone_type: 3,
timezone: 'UTC'
}
}
note.date.date = date
}
}
}
</script>
<style scoped>
.person-editor-section {
margin-bottom: 1.5rem;
}
</style>

View File

@@ -0,0 +1,131 @@
<script setup lang="ts">
import { useClipboard } from '@KTXC/composables/useClipboard'
interface Props {
mode: 'edit' | 'view'
organizations: Record<string, any>
}
defineProps<Props>()
const emit = defineEmits<{
'add-organization': []
'remove-organization': [key: string]
}>()
const { copiedKey, copyToClipboard } = useClipboard<string>()
</script>
<template>
<div v-if="(organizations || []).length > 0 || mode === 'edit'" class="person-editor-section">
<div class="d-flex align-center justify-space-between mb-2">
<div class="text-subtitle-1">Organizations</div>
<v-btn v-if="mode === 'edit'"
size="small"
variant="outlined"
@click="$emit('add-organization')">
<v-icon left>mdi-plus</v-icon>
Add Organization
</v-btn>
</div>
<!-- Read-only view -->
<div v-if="mode === 'view'">
<div v-for="(org, key) in organizations" :key="key" class="mb-2 pa-2 border rounded">
<div class="d-flex align-center justify-space-between mb-1">
<div class="d-flex align-center">
<v-icon icon="mdi-domain" size="small" class="mr-2" />
<strong>{{ org.Label || 'Unnamed Organization' }}</strong>
</div>
<v-btn
:icon="copiedKey === key ? 'mdi-check' : 'mdi-content-copy'"
size="x-small"
variant="text"
:color="copiedKey === key ? 'success' : undefined"
@click="copyToClipboard(org.Label || '', key as string)"
/>
</div>
<div class="text-caption text-grey">
Units: {{ (org.Units || []).join(', ') || 'None' }}
</div>
<div class="text-caption text-grey">
Context: {{ org.context || 'Not set' }} |
Priority: {{ org.priority || 'Not set' }}
</div>
</div>
</div>
<!-- Edit view -->
<div v-else>
<div v-for="(org, index) in organizations" :key="index" class="mb-4 pa-3 border rounded">
<div class="d-flex align-center justify-space-between mb-2">
<strong>Organization</strong>
<v-btn
icon="mdi-delete"
size="small"
color="error"
variant="text"
@click="$emit('remove-organization', index)">
</v-btn>
</div>
<v-row class="mb-2">
<v-col cols="12" md="6">
<v-text-field
v-model="org.Label"
label="Label"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="org.sortName"
label="Sort Name"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
<v-row class="mb-2">
<v-col cols="12" md="6">
<v-text-field
v-model="org.context"
label="Context"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model.number="org.priority"
label="Priority"
type="number"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12">
<v-text-field
v-model="org.Units"
label="Units (comma-separated)"
variant="outlined"
density="compact"
hint="Enter units separated by commas"
/>
</v-col>
</v-row>
</div>
</div>
</div>
</template>
<style scoped>
.person-editor-section {
margin-bottom: 1.5rem;
}
</style>

View File

@@ -0,0 +1,117 @@
<script setup lang="ts">
import { useClipboard } from '@KTXC/composables/useClipboard'
interface Props {
mode: 'edit' | 'view'
phones: Record<string, any>
}
defineProps<Props>()
const emit = defineEmits<{
'add-phone': []
'remove-phone': [key: string]
}>()
const { copiedKey, copyToClipboard } = useClipboard<string>()
</script>
<template>
<div v-if="Object.keys(phones || {}).length > 0 || mode === 'edit'" class="person-editor-section">
<div class="d-flex align-center justify-space-between mb-2">
<div class="text-subtitle-1">Phones</div>
<v-btn v-if="mode === 'edit'"
size="small"
variant="outlined"
@click="$emit('add-phone')">
<v-icon left>mdi-plus</v-icon>
Add Phone
</v-btn>
</div>
<!-- Read-only view -->
<div v-if="mode === 'view'">
<div v-for="(phone, key) in phones" :key="key" class="mb-2 pa-2 border rounded">
<div class="d-flex align-center justify-space-between mb-1">
<div class="d-flex align-center">
<v-icon icon="mdi-phone" size="small" class="mr-2" />
<strong>{{ phone.number || 'No number' }}</strong>
</div>
<v-btn
:icon="copiedKey === key ? 'mdi-check' : 'mdi-content-copy'"
size="x-small"
variant="text"
:color="copiedKey === key ? 'success' : undefined"
@click="copyToClipboard(phone.number || '', key as string)"
/>
</div>
<div class="text-caption text-grey">
Label: {{ phone.label || 'Not set' }} |
Context: {{ phone.context || 'Not set' }} |
Priority: {{ phone.priority || 'Not set' }}
</div>
</div>
</div>
<!-- Edit view -->
<div v-else>
<div v-for="(phone, index) in phones" :key="index" class="mb-4 pa-3 border rounded">
<div class="d-flex align-center justify-space-between mb-2">
<strong>Phone</strong>
<v-btn
icon="mdi-delete"
size="small"
color="error"
variant="text"
@click="$emit('remove-phone', index)">
</v-btn>
</div>
<v-row class="mb-2">
<v-col cols="12" md="6">
<v-text-field
v-model="phone.number"
label="Number"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="phone.label"
label="Label"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="phone.context"
label="Context"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model.number="phone.priority"
label="Priority"
type="number"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
</div>
</div>
</div>
</template>
<style scoped>
.person-editor-section {
margin-bottom: 1.5rem;
}
</style>

View File

@@ -0,0 +1,206 @@
<script setup lang="ts">
import { useClipboard } from '@KTXC/composables/useClipboard'
interface Props {
mode: 'edit' | 'view'
physicalLocations: Record<string, any>
}
defineProps<Props>()
const emit = defineEmits<{
'add-physical-location': []
'remove-physical-location': [key: string]
}>()
const { copiedKey, copyToClipboard } = useClipboard<string>()
const formatAddress = (location: any) => {
if (!location) return 'Not set'
const parts = []
if (location.street) parts.push(location.street)
if (location.locality) parts.push(location.locality)
if (location.region) parts.push(location.region)
if (location.code) parts.push(location.code)
if (location.country) parts.push(location.country)
return parts.join(', ') || 'Not set'
}
</script>
<template>
<div v-if="Object.keys(physicalLocations || {}).length > 0 || mode === 'edit'" class="person-editor-section">
<div class="d-flex align-center justify-space-between mb-2">
<div class="text-subtitle-1">Physical Locations</div>
<v-btn v-if="mode === 'edit'"
size="small"
variant="outlined"
@click="$emit('add-physical-location')">
<v-icon left>mdi-plus</v-icon>
Add Location
</v-btn>
</div>
<!-- Read-only view -->
<div v-if="mode === 'view'">
<div v-for="(location, key) in physicalLocations" :key="key" class="mb-2 pa-2 border rounded">
<div class="d-flex align-center justify-space-between mb-1">
<div class="d-flex align-center">
<v-icon icon="mdi-map-marker" size="small" class="mr-2" />
<strong>{{ location.label || `Location ${parseInt(key.split('_')[1]) || key}` }}</strong>
</div>
<v-btn
:icon="copiedKey === key ? 'mdi-check' : 'mdi-content-copy'"
size="x-small"
variant="text"
:color="copiedKey === key ? 'success' : undefined"
@click="copyToClipboard(formatAddress(location), key as string)"
/>
</div>
<div class="text-caption text-grey">
{{ formatAddress(location) }}
<span v-if="location.context"> | Context: {{ location.context }}</span>
<span v-if="location.priority"> | Priority: {{ location.priority }}</span>
</div>
</div>
</div>
<!-- Edit view -->
<div v-else>
<div v-for="(location, index) in physicalLocations" :key="index" class="mb-4 pa-3 border rounded">
<div class="d-flex align-center justify-space-between mb-2">
<strong>{{ location.label || `Location` }}</strong>
<v-btn
icon="mdi-delete"
size="small"
color="error"
variant="text"
@click="$emit('remove-physical-location', index)">
</v-btn>
</div>
<div class="text-subtitle-2 mb-2">Address</div>
<v-row class="mb-2">
<v-col cols="12" md="6">
<v-text-field
v-model="location.box"
label="Box"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="location.unit"
label="Unit"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
<v-row class="mb-2">
<v-col cols="12">
<v-text-field
v-model="location.street"
label="Street"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
<v-row class="mb-2">
<v-col cols="12" md="6">
<v-text-field
v-model="location.locality"
label="City/Locality"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="location.region"
label="State/Region"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
<v-row class="mb-4">
<v-col cols="12" md="6">
<v-text-field
v-model="location.code"
label="Postal Code"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="location.country"
label="Country"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
<div class="text-subtitle-2 mb-2">Additional Information</div>
<v-row class="mb-2">
<v-col cols="12" md="6">
<v-text-field
v-model="location.label"
label="Label"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="location.coordinates"
label="Coordinates"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
<v-row class="mb-2">
<v-col cols="12" md="4">
<v-text-field
v-model="location.timeZone"
label="Time Zone"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model="location.context"
label="Context"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="4">
<v-text-field
v-model.number="location.priority"
label="Priority"
type="number"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
</div>
</div>
</div>
</template>
<style scoped>
.person-editor-section {
margin-bottom: 1.5rem;
}
</style>

View File

@@ -0,0 +1,130 @@
<script setup lang="ts">
import { useClipboard } from '@KTXC/composables/useClipboard'
interface Props {
mode: 'edit' | 'view'
titles: Record<string, any>
}
defineProps<Props>()
const emit = defineEmits<{
'add-title': []
'remove-title': [key: string]
}>()
const { copiedKey, copyToClipboard } = useClipboard<string>()
</script>
<template>
<div v-if="Object.keys(titles || {}).length > 0 || mode === 'edit'" class="person-editor-section">
<div class="d-flex align-center justify-space-between mb-2">
<div class="text-subtitle-1">Titles</div>
<v-btn v-if="mode === 'edit'"
size="small"
variant="outlined"
@click="$emit('add-title')">
<v-icon left>mdi-plus</v-icon>
Add Title
</v-btn>
</div>
<!-- Read-only view -->
<div v-if="mode === 'view'">
<div v-for="(title, key) in titles" :key="key" class="mb-2 pa-2 border rounded">
<div class="d-flex align-center justify-space-between mb-1">
<div class="d-flex align-center">
<v-icon icon="mdi-badge-account" size="small" class="mr-2" />
<strong>{{ title.label || 'Untitled' }}</strong>
</div>
<v-btn
:icon="copiedKey === key ? 'mdi-check' : 'mdi-content-copy'"
size="x-small"
variant="text"
:color="copiedKey === key ? 'success' : undefined"
@click="copyToClipboard(title.label || '', key as string)"
/>
</div>
<div class="text-caption text-grey">
Kind: {{ title.kind || 'Not set' }} |
Relation: {{ title.relation || 'Not set' }} |
Context: {{ title.context || 'Not set' }} |
Priority: {{ title.priority || 'Not set' }}
</div>
</div>
</div>
<!-- Edit view -->
<div v-else>
<div v-for="(title, key) in titles" :key="key" class="mb-4 pa-3 border rounded">
<div class="d-flex align-center justify-space-between mb-2">
<strong>Title</strong>
<v-btn
icon="mdi-delete"
size="small"
color="error"
variant="text"
@click="$emit('remove-title', key)">
</v-btn>
</div>
<v-row class="mb-2">
<v-col cols="12" md="6">
<v-select
v-model="title.kind"
:items="['t', 'r']"
label="Kind"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="title.label"
label="Label"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
<v-row class="mb-2">
<v-col cols="12" md="6">
<v-text-field
v-model="title.relation"
label="Relation"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="title.context"
label="Context"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model.number="title.priority"
label="Priority"
type="number"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
</div>
</div>
</div>
</template>
<style scoped>
.person-editor-section {
margin-bottom: 1.5rem;
}
</style>

View File

@@ -0,0 +1,117 @@
<script setup lang="ts">
import { useClipboard } from '@KTXC/composables/useClipboard'
interface Props {
mode: 'edit' | 'view'
virtualLocations: Record<string, any>
}
defineProps<Props>()
const emit = defineEmits<{
'add-virtual-location': []
'remove-virtual-location': [key: string]
}>()
const { copiedKey, copyToClipboard } = useClipboard<string>()
</script>
<template>
<div v-if="Object.keys(virtualLocations || {}).length > 0 || mode === 'edit'" class="person-editor-section">
<div class="d-flex align-center justify-space-between mb-2">
<div class="text-subtitle-1">Virtual Locations</div>
<v-btn v-if="mode === 'edit'"
size="small"
variant="outlined"
@click="$emit('add-virtual-location')">
<v-icon left>mdi-plus</v-icon>
Add Virtual Location
</v-btn>
</div>
<!-- Read-only view -->
<div v-if="mode === 'view'">
<div v-for="(virtualLocation, key) in virtualLocations" :key="key" class="mb-2 pa-2 border rounded">
<div class="d-flex align-center justify-space-between mb-1">
<div class="d-flex align-center">
<v-icon icon="mdi-web" size="small" class="mr-2" />
<strong>{{ virtualLocation.location || 'No location' }}</strong>
</div>
<v-btn
:icon="copiedKey === key ? 'mdi-check' : 'mdi-content-copy'"
size="x-small"
variant="text"
:color="copiedKey === key ? 'success' : undefined"
@click="copyToClipboard(virtualLocation.location || '', key as string)"
/>
</div>
<div class="text-caption text-grey">
Label: {{ virtualLocation.label || 'Not set' }} |
Context: {{ virtualLocation.context || 'Not set' }} |
Priority: {{ virtualLocation.priority || 'Not set' }}
</div>
</div>
</div>
<!-- Edit view -->
<div v-else>
<div v-for="(virtualLocation, index) in virtualLocations" :key="index" class="mb-4 pa-3 border rounded">
<div class="d-flex align-center justify-space-between mb-2">
<strong>Virtual Location</strong>
<v-btn
icon="mdi-delete"
size="small"
color="error"
variant="text"
@click="$emit('remove-virtual-location', index)">
</v-btn>
</div>
<v-row class="mb-2">
<v-col cols="12" md="6">
<v-text-field
v-model="virtualLocation.location"
label="Location (URL/Handle)"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model="virtualLocation.label"
label="Label"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
<v-row>
<v-col cols="12" md="6">
<v-text-field
v-model="virtualLocation.context"
label="Context"
variant="outlined"
density="compact"
/>
</v-col>
<v-col cols="12" md="6">
<v-text-field
v-model.number="virtualLocation.priority"
label="Priority"
type="number"
variant="outlined"
density="compact"
/>
</v-col>
</v-row>
</div>
</div>
</div>
</template>
<style scoped>
.person-editor-section {
margin-bottom: 1.5rem;
}
</style>

15
src/integrations.ts Normal file
View File

@@ -0,0 +1,15 @@
import type { ModuleIntegrations } from "@KTXC/types/moduleTypes";
const integrations: ModuleIntegrations = {
app_menu: [
{
id: 'people',
label: 'People',
path: '/people',
icon: 'mdi-account-multiple',
priority: 20,
},
],
};
export default integrations;

7
src/main.ts Normal file
View File

@@ -0,0 +1,7 @@
import routes from '@/routes'
import integrations from '@/integrations'
// CSS filename is injected by the vite plugin at build time
export const css = ['__CSS_FILENAME_PLACEHOLDER__']
export { routes, integrations }

392
src/pages/PeoplePage.vue Normal file
View File

@@ -0,0 +1,392 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useDisplay } from 'vuetify'
import { useModuleStore } from '@KTXC/stores/moduleStore'
import { useCollectionsStore } from '@PeopleManager/stores/collectionsStore'
import { useEntitiesStore } from '@PeopleManager/stores/entitiesStore'
import { ServiceObject } from '@PeopleManager/models/service';
import { CollectionObject } from '@PeopleManager/models/collection';
import { EntityObject } from '@PeopleManager/models/entity';
import CollectionList from '@/components/CollectionList.vue'
import CollectionEditor from '@/components/CollectionEditor.vue'
import PersonList from '@/components/PersonList.vue'
import PersonEditor from '@/components/PersonEditor.vue'
// Vuetify display
const display = useDisplay()
// View state
const sidebarVisible = ref(true)
// Check if people manager is available
const moduleStore = useModuleStore()
const isPeopleManagerAvailable = computed(() => {
return moduleStore.has('people_manager') || moduleStore.has('PeopleManager')
})
// Local state for UI selections
const collectionsStore = useCollectionsStore()
const entitiesStore = useEntitiesStore()
const selectedCollection = ref<CollectionObject | null>(null)
const selectedEntity = ref<EntityObject | null>(null)
const entityEditorMode = ref<'edit' | 'view'>('view')
// Collection editor state
const showCollectionEditor = ref(false)
const editingCollection = ref<CollectionObject | null>(null)
const collectionEditorMode = ref<'create' | 'edit'>('create')
const CollectionListRef = ref<InstanceType<typeof CollectionList> | null>(null)
// Handlers
const handleEditorEdit = () => {
console.log('[People] - Editor editing started')
entityEditorMode.value = 'edit'
}
const handleEditorCancel = () => {
console.log('[People] - Editor editing cancelled')
entityEditorMode.value = 'view'
}
const handleEditorClose = () => {
console.log('[People] - Editor closed')
selectedEntity.value = null
entityEditorMode.value = 'view'
}
const handleCollectionSelect = (collection: CollectionObject) => {
console.log('[People] - Collection selected', collection)
selectedCollection.value = collection ?? null
selectedEntity.value = null
}
const handleCollectionEdit = (collection: CollectionObject) => {
console.log('[People] - Collection edit', collection)
editingCollection.value = collection
collectionEditorMode.value = 'edit'
showCollectionEditor.value = true
}
const onCollectionFresh = () => {
console.log('[People] - Collection new')
editingCollection.value = collectionsStore.fresh()
collectionEditorMode.value = 'create'
showCollectionEditor.value = true
}
const handleCollectionSave = async (collection: CollectionObject, service: ServiceObject) => {
console.log('[People] - Collection save', collection)
if (collectionEditorMode.value === 'create') {
await handleCollectionCreate(collection, service)
} else {
await handleCollectionModify(collection)
}
await CollectionListRef.value?.refresh()
}
const handleCollectionCreate = async (collection: CollectionObject, service: ServiceObject) => {
console.log('[People] - Collection created', collection)
const result = await collectionsStore.create(service, collection)
if (result instanceof CollectionObject) {
selectedCollection.value = result
} else {
selectedCollection.value = null
}
selectedEntity.value = null
}
const handleCollectionModify = async (collection: CollectionObject) => {
console.log('[People] - Collection modified', collection)
await collectionsStore.modify(collection)
}
const handleCollectionDelete = async (collection: CollectionObject) => {
console.log('[People] - Collection deleted', collection)
await collectionsStore.destroy(collection)
selectedCollection.value = null
selectedEntity.value = null
await CollectionListRef.value?.refresh()
}
const handleEntitySelect = (entity: EntityObject) => {
console.log('[People] - Entity selected', entity)
selectedEntity.value = entity
entityEditorMode.value = 'view'
}
const onEntityFresh = (type: string) => {
console.log('[People] - Entity create', type)
selectedEntity.value = entitiesStore.fresh(type)
entityEditorMode.value = 'edit'
}
const handleEntitySave = async (entity: EntityObject, collection?: CollectionObject | null) => {
console.log('[People] - Entity save', entity)
if (!(collection instanceof CollectionObject)) {
collection = selectedCollection.value
}
if (!collection) {
console.warn('[People] - Invalid collection object cannot save entity')
return
}
if (entity.id === null) {
console.log('[People] - Entity create', entity)
entity.data.created = new Date();
selectedEntity.value = await entitiesStore.create(collection, entity)
} else if (entity.id !== null) {
console.log('[People] - Entity modify', entity)
entity.data.modified = new Date();
selectedEntity.value = await entitiesStore.modify(collection, entity)
}
entityEditorMode.value = 'view'
}
const handleEntityDelete = async (entity: EntityObject, collection?: CollectionObject | null) => {
console.log('[People] - Entity deleted', entity)
if (!(collection instanceof CollectionObject)) {
collection = selectedCollection.value
}
if (!collection) {
console.warn('[People] - Invalid collection object cannot delete entity')
return
}
await entitiesStore.destroy(collection, entity)
selectedEntity.value = null
entityEditorMode.value = 'view'
}
</script>
<template>
<div class="people-container">
<!-- Top Navigation Bar -->
<v-app-bar elevation="0" class="people-toolbar border-b">
<template #prepend>
<v-btn
icon="mdi-menu"
variant="text"
@click="sidebarVisible = !sidebarVisible"
></v-btn>
</template>
<v-app-bar-title class="d-flex align-center">
<v-icon size="28" color="primary" class="mr-2">mdi-account-multiple</v-icon>
<span class="text-h6 font-weight-bold">People Manager</span>
</v-app-bar-title>
<v-spacer></v-spacer>
</v-app-bar>
<!-- Main Content Area -->
<div class="people-content">
<!-- Sidebar Navigation Drawer -->
<v-navigation-drawer
v-model="sidebarVisible"
:permanent="display.mdAndUp.value"
:temporary="display.smAndDown.value"
width="280"
class="people-sidebar"
>
<div class="pa-4 d-flex flex-column h-100">
<CollectionList
ref="CollectionListRef"
:selected-collection="selectedCollection"
@select="handleCollectionSelect"
@edit="handleCollectionEdit"
/>
<v-divider class="my-2" />
<div class="sidebar-footer">
<div class="d-flex align-center mb-2">
<v-icon icon="mdi-plus-circle" size="small" class="mr-2" />
<span class="text-subtitle-2 font-weight-bold">Create</span>
</div>
<v-btn
variant="text"
prepend-icon="mdi-account-plus"
size="small"
block
class="justify-start"
:disabled="!selectedCollection"
@click="onEntityFresh('individual')"
>
Individual
</v-btn>
<v-btn
variant="text"
prepend-icon="mdi-domain-plus"
size="small"
block
class="justify-start"
:disabled="!selectedCollection"
@click="onEntityFresh('organization')"
>
Organization
</v-btn>
<v-btn
variant="text"
prepend-icon="mdi-account-group"
size="small"
block
class="justify-start"
:disabled="!selectedCollection"
@click="onEntityFresh('group')"
>
Group
</v-btn>
<v-btn
variant="text"
prepend-icon="mdi-book-plus"
size="small"
block
class="justify-start"
@click="onCollectionFresh"
>
Address Book
</v-btn>
</div>
</div>
</v-navigation-drawer>
<!-- Main View -->
<div class="people-main">
<v-alert
v-if="!isPeopleManagerAvailable"
type="warning"
variant="tonal"
closable
class="mb-4"
>
<v-alert-title class="d-flex align-center">
<v-icon icon="mdi-alert-circle" class="mr-2" />
People Manager Not Available
</v-alert-title>
<div class="mt-2">
<p>
The People Manager module is not installed or enabled.
This module requires the <strong>people_manager</strong> module to function properly.
</p>
<p class="mt-2 mb-0">
Please contact your system administrator to install and enable the
<code>people_manager</code> module.
</p>
</div>
</v-alert>
<div v-if="isPeopleManagerAvailable" class="people-wrapper">
<div class="people-list-panel">
<PersonList
:selected-collection="selectedCollection"
:selected-entity="selectedEntity"
@select="handleEntitySelect"
@fresh="onEntityFresh"
/>
</div>
<div class="people-editor-panel">
<PersonEditor
:mode="entityEditorMode"
:selected-collection="selectedCollection"
:selected-entity="selectedEntity"
@save="handleEntitySave"
@delete="handleEntityDelete"
@edit="handleEditorEdit"
@cancel="handleEditorCancel"
@close="handleEditorClose"
/>
</div>
</div>
</div>
</div>
<!-- Collection Editor Dialog -->
<CollectionEditor
v-model="showCollectionEditor"
:collection="editingCollection"
:mode="collectionEditorMode"
@save="handleCollectionSave"
@delete="handleCollectionDelete"
/>
</div>
</template>
<style scoped lang="scss">
.people-container {
display: flex;
flex-direction: column;
height: 100vh;
isolation: isolate; /* Create stacking context to prevent style leakage */
}
.people-toolbar {
border-bottom: 1px solid rgb(var(--v-border-color)) !important;
}
.people-content {
display: flex;
flex: 1;
overflow: hidden;
position: relative;
}
.people-sidebar {
border-right: 1px solid rgb(var(--v-border-color)) !important;
overflow-y: auto;
}
.people-main {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.people-wrapper {
flex: 1;
display: flex;
overflow: hidden;
}
.people-list-panel {
width: 300px;
min-width: 250px;
max-width: 400px;
border-right: 1px solid rgb(var(--v-border-color));
overflow-y: auto;
display: flex;
flex-direction: column;
background-color: rgba(var(--v-theme-on-surface), 0.04);
}
.people-editor-panel {
flex: 1;
border-left: 1px solid rgb(var(--v-border-color));
overflow: hidden;
display: flex;
flex-direction: column;
background-color: rgba(var(--v-theme-on-surface), 0.04);
min-height: 0;
}
/* Responsive adjustments */
@media (max-width: 960px) {
.people-wrapper {
flex-direction: column;
}
.people-list-panel {
width: 100%;
max-width: none;
height: 40%;
border-right: none;
border-bottom: 1px solid rgb(var(--v-border-color));
}
.people-editor-panel {
border-left: none;
border-top: 1px solid rgb(var(--v-border-color));
}
}
</style>

9
src/routes.ts Normal file
View File

@@ -0,0 +1,9 @@
const routes = [
{
name: 'people',
path: '/people',
component: () => import('@/pages/PeoplePage.vue'),
},
]
export default routes

1
src/style.css Normal file
View File

@@ -0,0 +1 @@
/* people module styles */

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

20
tsconfig.app.json Normal file
View File

@@ -0,0 +1,20 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"paths": {
"@/*": ["./src/*"],
"@KTXC/*": ["../../core/src/*"],
"@PeopleManager/*": ["../people_manager/src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

25
tsconfig.node.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

58
vite.config.ts Normal file
View File

@@ -0,0 +1,58 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
{
name: 'inject-css-filename',
enforce: 'post',
generateBundle(_options, bundle) {
const cssFile = Object.keys(bundle).find(name => name.endsWith('.css'))
if (!cssFile) return
for (const fileName of Object.keys(bundle)) {
const chunk = bundle[fileName]
if (chunk.type === 'chunk' && chunk.code.includes('__CSS_FILENAME_PLACEHOLDER__')) {
chunk.code = chunk.code.replace(/__CSS_FILENAME_PLACEHOLDER__/g, `static/${cssFile}`)
console.log(`Injected CSS filename "static/${cssFile}" into ${fileName}`)
}
}
}
}
],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@KTXC': path.resolve(__dirname, '../../core/src'),
'@PeopleManager': path.resolve(__dirname, '../people_manager/src'),
},
},
build: {
outDir: 'static',
emptyOutDir: true,
sourcemap: true,
lib: {
entry: path.resolve(__dirname, 'src/main.ts'),
formats: ['es'],
fileName: () => 'module.mjs',
},
rollupOptions: {
external: [
'vue',
'vue-router',
'pinia',
],
output: {
assetFileNames: (assetInfo) => {
if (assetInfo.name?.endsWith('.css')) {
return 'people-[hash].css'
}
return '[name]-[hash][extname]'
}
}
},
},
})