Add Expiry Indicator functionality with configuration and command set

- Implemented ExpiryItemService to manage expiry date logic for list items.
- Created ExpiryModels to define types and structures for expiry configurations and rules.
- Added RestError utility for handling API response errors.
- Developed ExpiryIndicatorFieldCustomizer to display expiry information in list views.
- Introduced ExpiryIndicatorCommandSet for extending expiry dates and managing settings.
- Added settings dialog for configuring expiry settings.
- Implemented localization support for English and German languages.
- Added TypeScript configuration files for project setup.
- Included TSLint configuration for code quality enforcement.
This commit is contained in:
Torsten Brendgen
2026-07-16 21:55:30 +02:00
commit c1f1d02d9c
33 changed files with 1851 additions and 0 deletions

240
src/common/ExpiryModels.ts Normal file
View File

@@ -0,0 +1,240 @@
export type DurationUnit = 'days' | 'months' | 'years';
export type ColumnRuleOperator = 'lessThan' | 'lessOrEqual' | 'equal' | 'greaterOrEqual' | 'greaterThan';
export interface IExpiryDuration {
value: number;
unit: DurationUnit;
}
export interface IExpiryColumnRule {
daysUntilExpiry: number;
operator: ColumnRuleOperator;
backgroundColor: string;
textColor: string;
label: string;
}
export interface IExpiryBehavior {
lifeTime: IExpiryDuration;
columnRule: IExpiryColumnRule[];
}
export interface IExpiryValueRule extends IExpiryBehavior {
columnName: string;
columnValue: string;
}
export interface IExpiryConfig {
baseField: string;
expiryField: string;
default: IExpiryBehavior;
rules: IExpiryValueRule[];
nullText: string;
confirmExtension: boolean;
}
export interface IExpiryEvaluation {
effectiveExpiryDate: Date;
daysUntilExpiry: number;
matchedRule?: IExpiryColumnRule;
wasCalculated: boolean;
}
export interface IItemDateValues {
id: number;
created: Date;
expiry?: Date;
fieldValues: { [fieldName: string]: any };
}
export interface IUpdateResult {
id: number;
succeeded: boolean;
newExpiryDate?: Date;
error?: string;
}
export function createDefaultConfig(): IExpiryConfig {
return {
baseField: 'Created',
expiryField: 'ExpiryDate',
default: {
lifeTime: { value: 2, unit: 'years' },
columnRule: createDefaultColumnRules()
},
rules: [],
nullText: 'Kein Ablaufdatum',
confirmExtension: true
};
}
export function normalizeConfig(value: any): IExpiryConfig {
const defaults: IExpiryConfig = createDefaultConfig();
const config: any = value || {};
const defaultBehavior: any = config.default || {};
return {
baseField: isSafeInternalName(config.baseField) ? config.baseField : defaults.baseField,
expiryField: isSafeInternalName(config.expiryField) ? config.expiryField : defaults.expiryField,
default: {
lifeTime: normalizeDuration(defaultBehavior.lifeTime, defaults.default.lifeTime),
columnRule: normalizeColumnRules(defaultBehavior.columnRule, defaults.default.columnRule)
},
rules: normalizeValueRules(config.rules),
nullText: typeof config.nullText === 'string' ? config.nullText : defaults.nullText,
confirmExtension: typeof config.confirmExtension === 'boolean' ? config.confirmExtension : defaults.confirmExtension
};
}
export function getRuleFieldNames(config: IExpiryConfig): string[] {
const fields: string[] = [];
config.rules.forEach((rule: IExpiryValueRule): void => {
if (isSafeInternalName(rule.columnName) && fields.indexOf(rule.columnName) < 0) {
fields.push(rule.columnName);
}
});
return fields;
}
export function resolveBehavior(
config: IExpiryConfig,
fieldValues: { [fieldName: string]: any }
): IExpiryBehavior {
for (let index: number = 0; index < config.rules.length; index++) {
const rule: IExpiryValueRule = config.rules[index];
if (!Object.prototype.hasOwnProperty.call(fieldValues, rule.columnName)) {
continue;
}
if (valueMatches(fieldValues[rule.columnName], rule.columnValue)) {
return {
lifeTime: rule.lifeTime,
columnRule: rule.columnRule
};
}
}
return config.default;
}
export function isSafeInternalName(value: string): boolean {
return typeof value === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
}
function createDefaultColumnRules(): IExpiryColumnRule[] {
return [
{
daysUntilExpiry: 0,
operator: 'lessOrEqual',
backgroundColor: '#a4262c',
textColor: '#ffffff',
label: 'Abgelaufen'
},
{
daysUntilExpiry: 30,
operator: 'lessOrEqual',
backgroundColor: '#ffaa44',
textColor: '#000000',
label: 'Läuft bald ab'
},
{
daysUntilExpiry: 90,
operator: 'lessOrEqual',
backgroundColor: '#fff4ce',
textColor: '#000000',
label: 'Beobachten'
}
];
}
function normalizeValueRules(value: any): IExpiryValueRule[] {
if (!Array.isArray(value)) {
return [];
}
const rules: IExpiryValueRule[] = [];
value.forEach((rule: any): void => {
if (!rule || !isSafeInternalName(rule.columnName) || typeof rule.columnValue === 'undefined') {
return;
}
const columnRules: IExpiryColumnRule[] = normalizeColumnRules(rule.columnRule, []);
rules.push({
columnName: rule.columnName,
columnValue: String(rule.columnValue),
lifeTime: normalizeDuration(rule.lifeTime, { value: 1, unit: 'years' }),
columnRule: columnRules
});
});
return rules;
}
function normalizeDuration(value: any, fallback: IExpiryDuration): IExpiryDuration {
if (!value || typeof value.value !== 'number' || !isFinite(value.value)) {
return fallback;
}
if (value.unit !== 'days' && value.unit !== 'months' && value.unit !== 'years') {
return fallback;
}
return { value: Math.floor(value.value), unit: value.unit };
}
function normalizeColumnRules(value: any, fallback: IExpiryColumnRule[]): IExpiryColumnRule[] {
if (!Array.isArray(value)) {
return fallback;
}
const rules: IExpiryColumnRule[] = [];
value.forEach((rule: any): void => {
if (!rule || typeof rule.daysUntilExpiry !== 'number' || !isFinite(rule.daysUntilExpiry)) {
return;
}
if (!isColumnRuleOperator(rule.operator) || !isColor(rule.backgroundColor) || !isColor(rule.textColor)) {
return;
}
rules.push({
daysUntilExpiry: Math.floor(rule.daysUntilExpiry),
operator: rule.operator,
backgroundColor: rule.backgroundColor,
textColor: rule.textColor,
label: typeof rule.label === 'string' ? rule.label : ''
});
});
return rules.length > 0 || fallback.length === 0 ? rules : fallback;
}
function valueMatches(rawValue: any, configuredValue: string): boolean {
if (rawValue === null || typeof rawValue === 'undefined') {
return configuredValue === '';
}
let values: any[];
if (Array.isArray(rawValue)) {
values = rawValue;
} else if (rawValue.results && Array.isArray(rawValue.results)) {
values = rawValue.results;
} else {
values = [rawValue];
}
return values.some((value: any): boolean => {
if (value && typeof value === 'object') {
return String(value.Title || value.LookupValue || value.Label || value.Value || '') === configuredValue;
}
return String(value) === configuredValue;
});
}
function isColumnRuleOperator(value: string): boolean {
return value === 'lessThan' || value === 'lessOrEqual' || value === 'equal' ||
value === 'greaterOrEqual' || value === 'greaterThan';
}
function isColor(value: string): boolean {
return typeof value === 'string' && (
/^#[0-9a-fA-F]{3}$/.test(value) ||
/^#[0-9a-fA-F]{6}$/.test(value) ||
/^rgba?\([0-9.,% ]+\)$/.test(value) ||
/^[a-zA-Z]+$/.test(value)
);
}