diff --git a/src/domain/Stakeholder.mts b/src/domain/Stakeholder.mts index 5334da54..ed4e2c07 100644 --- a/src/domain/Stakeholder.mts +++ b/src/domain/Stakeholder.mts @@ -14,16 +14,50 @@ export enum StakeholderCategory { } export default class Stakeholder extends Entity { - category: StakeholderCategory; description: string; name: string; segmentation: StakeholderSegmentation; + #influence!: number; + #availability!: number; - constructor({ id, category, description, name, segmentation }: Properties) { - super({ id }); - this.category = category; - this.description = description; - this.name = name; - this.segmentation = segmentation; + constructor(properties: Omit, 'category'>) { + super({ id: properties.id }); + this.description = properties.description; + this.name = properties.name; + this.segmentation = properties.segmentation; + this.influence = properties.influence; + this.availability = properties.availability; + } + + get influence() { + return this.#influence; + } + + set influence(value) { + if (value < 0 || value > 100) + throw new Error('Invalid value for influence. Must be between 0 and 100.'); + + this.#influence = value; + } + + get availability() { + return this.#availability; + } + + set availability(value) { + if (value < 0 || value > 100) + throw new Error('Invalid value for availability. Must be between 0 and 100.'); + this.#availability = value; + } + + get category(): StakeholderCategory { + if (this.influence >= 75 && this.availability >= 75) + return StakeholderCategory.KeyStakeholder; + if (this.influence >= 75 && this.availability < 75) + return StakeholderCategory.ShadowInfluencer; + if (this.influence < 75 && this.availability >= 75) + return StakeholderCategory.FellowTraveler; + + return StakeholderCategory.Observer; } } \ No newline at end of file diff --git a/src/mappers/StakeholderToJsonMapper.mts b/src/mappers/StakeholderToJsonMapper.mts index a312017c..6854befa 100644 --- a/src/mappers/StakeholderToJsonMapper.mts +++ b/src/mappers/StakeholderToJsonMapper.mts @@ -1,31 +1,39 @@ -import Stakeholder, { StakeholderCategory, StakeholderSegmentation } from '~/domain/Stakeholder.mjs'; +import Stakeholder, { StakeholderSegmentation } from '~/domain/Stakeholder.mjs'; import EntityToJsonMapper, { type EntityJson } from './EntityToJsonMapper.mjs'; export interface StakeholderJson extends EntityJson { - category: string; description: string; name: string; segmentation: string; + influence: number; + availability: number; } export default class StakeholderToJsonMapper extends EntityToJsonMapper { override mapFrom(target: StakeholderJson): Stakeholder { - return new Stakeholder({ - category: target.category as StakeholderCategory, - description: target.description, - id: target.id, - name: target.name, - segmentation: target.segmentation as StakeholderSegmentation - }); + const version = target.serializationVersion ?? '{undefined}'; + + if (version.startsWith('0.3.')) + return new Stakeholder({ + description: target.description, + id: target.id, + name: target.name, + segmentation: target.segmentation as StakeholderSegmentation, + influence: target.influence ?? 0, + availability: target.availability ?? 0 + }); + + throw new Error(`Unsupported serialization version: ${version}`); } override mapTo(source: Stakeholder): StakeholderJson { return { ...super.mapTo(source), - category: source.category, description: source.description, name: source.name, - segmentation: source.segmentation + segmentation: source.segmentation, + influence: source.influence, + availability: source.availability }; } } \ No newline at end of file diff --git a/src/presentation/components/DataTable.mts b/src/presentation/components/DataTable.mts index dd5924f3..7898afff 100644 --- a/src/presentation/components/DataTable.mts +++ b/src/presentation/components/DataTable.mts @@ -5,17 +5,25 @@ import { Component } from './Component.mjs'; import buttonTheme from '../theme/buttonTheme.mjs'; import formTheme from '../theme/formTheme.mjs'; -export interface DataColumn { - formType?: 'text' | 'hidden' | 'select'; +export type DataColumn = { readonly?: boolean; headerText: string; required?: boolean; - options?: string[]; -} +} & ({ + formType: 'text' | 'hidden'; +} | { + formType: 'number' | 'range'; + min: number; + max: number; + step: number; +} | { + formType: 'select'; + options: string[]; +}); export type DataColumns = { id: DataColumn } - & { [K in keyof Properties]: DataColumn }; + & { [K in keyof Partial>]: DataColumn }; export interface DataTableOptions { columns?: DataColumns; @@ -49,11 +57,6 @@ export class DataTable extends Component { constructor({ columns, select, onCreate, onDelete, onUpdate }: DataTableOptions) { super({}); - Object.entries(columns ?? {}).forEach(([key, value]) => { - if (value.formType == 'select' && !value.options) - throw new Error(`When formType is "select", options must be specified for column "${key}".`); - }); - this.#columns = Object.freeze(columns ?? {} as DataColumns); this.#select = select ?? (() => Promise.resolve([])); this.#onCreate = onCreate; @@ -258,20 +261,33 @@ export class DataTable extends Component { ...Object.entries(this.#columns).map(([id, col]) => td({ hidden: col.formType == 'hidden' }, [ input({ - type: 'text', + type: col.formType, name: id, required: col.required, form: this.#frmDataTableCreate, - [renderIf]: col.formType != 'select' + [renderIf]: col.formType == 'text' || col.formType == 'hidden' }), select({ - hidden: col.formType != 'select', name: id, form: this.#frmDataTableCreate, [renderIf]: col.formType == 'select' - }, [ - ...(col.options?.map(opt => option({ value: opt }, opt)) ?? []) - ]) + }, + col.formType == 'select' ? + col.options.map(opt => option({ value: opt }, opt)) + : [] + ), + input({ + type: col.formType, + name: id, + min: col.formType == 'number' || col.formType == 'range' ? + `${col.min}` : '0', + max: col.formType == 'number' || col.formType == 'range' ? + `${col.max}` : '0', + step: col.formType == 'number' || col.formType == 'range' ? + `${col.step}` : '1', + form: this.#frmDataTableCreate, + [renderIf]: col.formType == 'number' || col.formType == 'range' + }) ])) ); this.#newItemRow.append(td(button({ @@ -287,34 +303,55 @@ export class DataTable extends Component { span({ 'className': 'view-data', // @ts-expect-error: data-* attributes are valid - 'data-name': id + 'data-name': id, + [renderIf]: col.formType != 'range' }, (item as any)[id]), - col.formType != 'select' ? - input({ - form: this.#frmDataTableUpdate, - type: 'text', - className: 'edit-data', - name: id, - value: (item as any)[id], - required: col.required, - disabled: true, - hidden: true - }) - : '', - col.formType == 'select' ? - select({ - form: this.#frmDataTableUpdate, - className: 'edit-data', - name: id, - disabled: true, - hidden: true - }, [ - ...col.options!.map(opt => option({ - value: opt, - selected: opt == (item as any)[id] - }, opt)) - ]) - : '' + input({ + form: this.#frmDataTableUpdate, + type: col.formType, + className: 'view-data', + name: id, + disabled: true, + [renderIf]: col.formType == 'range', + value: (item as any)[id] + }), + input({ + form: this.#frmDataTableUpdate, + type: col.formType, + className: 'edit-data', + name: id, + value: (item as any)[id], + required: col.required, + disabled: true, + hidden: true, + [renderIf]: col.formType == 'text' || col.formType == 'hidden' + }), + input({ + form: this.#frmDataTableUpdate, + type: col.formType, + className: 'edit-data', + name: id, + min: `${(col as any).min ?? 0}`, + max: `${(col as any).max ?? 0}`, + step: `${(col as any).step ?? 1}`, + disabled: true, + hidden: true, + [renderIf]: col.formType == 'number' || col.formType == 'range', + value: (item as any)[id] + }), + select({ + form: this.#frmDataTableUpdate, + className: 'edit-data', + name: id, + disabled: true, + hidden: true, + [renderIf]: col.formType == 'select' + }, + ((col as any).options ?? []).map((opt: string) => option({ + value: opt, + selected: opt == (item as any)[id] + }, opt)) + ) ])), td([ button({ diff --git a/src/presentation/pages/environments/Glossary.mts b/src/presentation/pages/environments/Glossary.mts index fa8f0081..96581716 100644 --- a/src/presentation/pages/environments/Glossary.mts +++ b/src/presentation/pages/environments/Glossary.mts @@ -23,8 +23,8 @@ export class Glossary extends SlugPage { const dataTable = new DataTable({ columns: { id: { headerText: 'ID', readonly: true, formType: 'hidden' }, - term: { headerText: 'Term', required: true }, - definition: { headerText: 'Definition' } + term: { headerText: 'Term', required: true, formType: 'text' }, + definition: { headerText: 'Definition', formType: 'text' } }, select: async () => { if (!this.#environment) diff --git a/src/presentation/pages/goals/Functionality.mts b/src/presentation/pages/goals/Functionality.mts index 6ef51043..1069604c 100644 --- a/src/presentation/pages/goals/Functionality.mts +++ b/src/presentation/pages/goals/Functionality.mts @@ -29,7 +29,7 @@ export class Functionality extends SlugPage { const dataTable = new DataTable({ columns: { id: { headerText: 'ID', readonly: true, formType: 'hidden' }, - statement: { headerText: 'Statement', required: true } + statement: { headerText: 'Statement', required: true, formType: 'text' } }, select: async () => { if (!this.#goals) diff --git a/src/presentation/pages/goals/Stakeholders.mts b/src/presentation/pages/goals/Stakeholders.mts index aef4244c..c471e7e1 100644 --- a/src/presentation/pages/goals/Stakeholders.mts +++ b/src/presentation/pages/goals/Stakeholders.mts @@ -37,10 +37,11 @@ export class Stakeholders extends SlugPage { const dataTable = new DataTable({ columns: { id: { headerText: 'ID', readonly: true, formType: 'hidden' }, - name: { headerText: 'Name', required: true }, - description: { headerText: 'Description', required: true }, + name: { headerText: 'Name', required: true, formType: 'text' }, + description: { headerText: 'Description', required: true, formType: 'text' }, segmentation: { headerText: 'Segmentation', formType: 'select', options: Object.values(StakeholderSegmentation) }, - category: { headerText: 'Category', formType: 'select', options: Object.values(StakeholderCategory) } + influence: { headerText: 'Influence', formType: 'range', min: 0, max: 100, step: 1 }, + availability: { headerText: 'Availability', formType: 'range', min: 0, max: 100, step: 1 }, }, select: async () => { if (!this.#goals) @@ -105,38 +106,23 @@ export class Stakeholders extends SlugPage { await this.#stakeholderRepository.getAll(), ({ segmentation }) => segmentation ), - clientGroups = groupBy( - stakeholders[StakeholderSegmentation.Client] ?? [], - ({ category }) => category - ), - vendorGroups = groupBy( - stakeholders[StakeholderSegmentation.Vendor] ?? [], - ({ category }) => category - ), - chartDefinition = (groups: Record, category: StakeholderSegmentation) => ` + clientGroup = stakeholders[StakeholderSegmentation.Client] ?? [], + vendorGroup = stakeholders[StakeholderSegmentation.Vendor] ?? [], + chartDefinition = (stakeholders: Stakeholder[], category: StakeholderSegmentation) => ` quadrantChart title ${category} x-axis Low Availability --> High Availability y-axis Low Infuence --> High Influence - quadrant-1 "Shadow Influencers (Manage)" - quadrant-2 "Key Stakeholders (Satisfy)" - quadrant-3 "Fellow Travelers (Monitor)" - quadrant-4 "Observers (Inform)" - ${(groups[StakeholderCategory.ShadowInfluencer] ?? []) - .map(({ name }) => `"${name}": [0.75, 0.75]`)?.join('\n') - } - ${(groups[StakeholderCategory.KeyStakeholder] ?? []) - .map(({ name }) => `"${name}": [0.25, 0.75]`)?.join('\n') - } - ${(groups[StakeholderCategory.FellowTraveler] ?? []) - .map(({ name }) => `"${name}": [0.25, 0.25]`)?.join('\n') - } - ${(groups[StakeholderCategory.Observer] ?? []) - .map(({ name }) => `"${name}": [0.75, 0.25]`)?.join('\n') + quadrant-1 "${StakeholderCategory.KeyStakeholder} (Satisfy)" + quadrant-2 "${StakeholderCategory.ShadowInfluencer} (Manage)" + quadrant-3 "${StakeholderCategory.Observer} (Inform)" + quadrant-4 "${StakeholderCategory.FellowTraveler} (Monitor)" + ${stakeholders.map(({ name, availability, influence }) => + `"${name}": [${availability / 100}, ${influence / 100}]`)?.join('\n') } `, - { svg: svgClient } = await mermaid.render('clientMap', chartDefinition(clientGroups, StakeholderSegmentation.Client)), - { svg: svgVendor } = await mermaid.render('vendorMap', chartDefinition(vendorGroups, StakeholderSegmentation.Vendor)); + { svg: svgClient } = await mermaid.render('clientMap', chartDefinition(clientGroup, StakeholderSegmentation.Client)), + { svg: svgVendor } = await mermaid.render('vendorMap', chartDefinition(vendorGroup, StakeholderSegmentation.Vendor)); mermaidContainer.innerHTML = `${svgClient}
${svgVendor}`; } } \ No newline at end of file