/** * TEMS Contract Definitions Management Component * * Provides CRUD operations for EDC Contract Definitions, integrated with Hubl. * Uses sib-core's DataspaceConnectorStore for all operations. * * @customElement solid-tems-contracts-management */ import * as utils from "@helpers"; import { localized, msg } from "@lit/localize"; import { OrbitDSPComponent } from "@startinblox/solid-tems-shared"; import { css, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { repeat } from "lit/directives/repeat.js"; interface AssetSelector { "@type": "CriterionDto"; operandLeft: string; operator: string; operandRight: any; } interface ContractDefinition { "@type": "ContractDefinition"; "@id": string; "@context"?: any; accessPolicyId: string; contractPolicyId: string; assetsSelector?: AssetSelector[] | AssetSelector; createdAt?: number; } interface ContractDefinitionInput { "@id": string; accessPolicyId: string; contractPolicyId: string; assetsSelector?: AssetSelector[]; } interface PolicyDefinition { "@id": string; policy?: any; } interface Asset { "@id": string; properties?: Record; } @customElement("solid-tems-contracts-management") @localized() export class TemsContractsManagement extends OrbitDSPComponent { constructor() { super(); utils.setupCacheInvalidation(this, { keywords: ["contracts", "management"], }); } static styles = css` :host { display: block; } .management-container { padding: 1rem; } .toolbar { display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; margin-bottom: 1rem; } .toolbar tems-search-bar { flex: 1; min-width: 200px; } .toolbar-actions { display: flex; gap: 0.5rem; align-items: center; } .sort-select { padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; font-size: 0.875rem; } .contracts-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 1rem; } .contract-card { background: white; border: 1px solid #e0e0e0; border-radius: 8px; padding: 1rem; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); cursor: pointer; transition: box-shadow 0.2s, border-color 0.2s; } .contract-card:hover { border-color: #e91e63; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); } .contract-id { font-size: 1rem; font-weight: 600; color: #333; margin-bottom: 0.5rem; word-break: break-all; } .contract-policies { font-size: 0.875rem; color: #666; margin-bottom: 0.75rem; } .policy-row { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.25rem; } .policy-label { font-weight: 500; min-width: 120px; } .policy-value { color: #e91e63; font-family: monospace; font-size: 0.8rem; } .contract-assets { margin-top: 0.75rem; padding: 0.5rem; background: #f9f9f9; border-radius: 4px; font-size: 0.75rem; } .asset-selector { display: flex; gap: 0.25rem; flex-wrap: wrap; margin-top: 0.25rem; } .selector-badge { display: inline-block; padding: 0.125rem 0.375rem; background: #e3f2fd; color: #1565c0; border-radius: 4px; font-family: monospace; } .contract-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem; padding-top: 0.75rem; border-top: 1px solid #e0e0e0; } .btn-card { padding: 0.25rem 0.5rem; border: none; border-radius: 4px; cursor: pointer; font-size: 0.75rem; transition: background-color 0.2s; } .btn-card-secondary { background-color: #f5f5f5; color: #333; border: 1px solid #ddd; } .btn-card-secondary:hover { background-color: #e0e0e0; } .btn-card-danger { background-color: #f44336; color: white; } .btn-card-danger:hover { background-color: #d32f2f; } .empty-state { text-align: center; padding: 3rem; color: #666; } .empty-icon { font-size: 4rem; margin-bottom: 1rem; } .loading { text-align: center; padding: 2rem; color: #666; } .message { padding: 1rem; border-radius: 4px; margin-bottom: 1rem; } .message-error { background-color: #ffebee; color: #c62828; border: 1px solid #ef9a9a; } .message-success { background-color: #e8f5e9; color: #2e7d32; border: 1px solid #a5d6a7; } .modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background-color: rgba(0, 0, 0, 0.5); display: flex; justify-content: center; align-items: center; z-index: 1000; } .modal-content { background: white; border-radius: 8px; max-width: 600px; width: 90%; max-height: 90vh; overflow-y: auto; } .modal-header { display: flex; justify-content: space-between; align-items: center; padding: 1rem 1.5rem; border-bottom: 1px solid #e0e0e0; } .modal-header h3 { margin: 0; } .modal-close { background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #666; } .modal-body { padding: 1.5rem; } .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } .form-group { display: flex; flex-direction: column; gap: 0.25rem; } .form-group.full-width { grid-column: 1 / -1; } .form-label { font-size: 0.875rem; font-weight: 500; color: #333; } .form-input, .form-select { padding: 0.5rem; border: 1px solid #ddd; border-radius: 4px; font-size: 0.875rem; } .form-hint { font-size: 0.75rem; color: #888; margin-top: 0.25rem; } .form-actions { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid #e0e0e0; } .form-actions tems-button { min-width: 100px; } .detail-section { display: flex; flex-direction: column; gap: 1rem; } .detail-row { display: flex; flex-direction: column; gap: 0.25rem; } .detail-row strong { color: #666; font-size: 0.875rem; } .detail-value { font-family: monospace; background: #f5f5f5; padding: 0.5rem; border-radius: 4px; word-break: break-all; } .created-date { font-size: 0.75rem; color: #888; margin-top: 0.5rem; } `; @property({ attribute: "header", type: String }) header = "Contract Definitions Management"; @state() private contracts: ContractDefinition[] = []; @state() private policies: PolicyDefinition[] = []; @state() private assets: Asset[] = []; @state() private isLoading = false; @state() private showCreateForm = false; @state() private viewingContract: ContractDefinition | null = null; @state() private searchTerm = ""; @state() private sortBy: "id" | "accessPolicy" | "created" = "id"; @state() private sortDirection: "asc" | "desc" = "asc"; @state() private errorMessage = ""; @state() private successMessage = ""; override async _afterAttach() { await super._afterAttach(); if (this.storeService) { this.loadData(); } } private async loadData() { await Promise.all([ this.loadContracts(), this.loadPolicies(), this.loadAssets(), ]); } private async loadContracts() { if (!this.storeService) { this.showError("Store not initialized. Check connector configuration."); return; } this.isLoading = true; this.clearMessages(); try { const contracts = await this.storeService.getAllContractDefinitions(); this.contracts = Array.isArray(contracts) ? contracts : []; } catch (error: any) { console.error("Failed to load contract definitions:", error); this.showError(`Failed to load contract definitions: ${error.message}`); } finally { this.isLoading = false; } } private async loadPolicies() { if (!this.storeService) return; try { const policies = await this.storeService.getAllPolicies(); this.policies = Array.isArray(policies) ? policies : []; } catch (error) { console.error("Failed to load policies:", error); } } private async loadAssets() { if (!this.storeService) return; try { const assets = await this.storeService.getAllAssets(); this.assets = Array.isArray(assets) ? assets : []; } catch (error) { console.error("Failed to load assets:", error); } } private getAssetName(assetId: string): string { const asset = this.assets.find((a) => a["@id"] === assetId); if (asset) { return ( asset.properties?.["name"] || asset.properties?.["dcterms:title"] || assetId ); } return assetId; } private getTargetAssetIds(contract: ContractDefinition): string[] { const selectorData = contract.assetsSelector; if (!selectorData) return []; // Handle both array and single object formats from EDC const selectors = Array.isArray(selectorData) ? selectorData : [selectorData]; return selectors .filter((s) => s?.operandLeft?.includes("id") && s.operator === "=") .map((s) => s.operandRight as string); } private async createContract(contractData: ContractDefinitionInput) { if (!this.storeService) return false; try { await this.storeService.createContractDefinition(contractData); this.showSuccess("Contract definition created successfully!"); this.showCreateForm = false; await this.loadContracts(); this.dispatchEvent( new CustomEvent("contract-created", { detail: { contract: contractData }, bubbles: true, composed: true, }), ); return true; } catch (error: any) { this.showError(`Failed to create contract definition: ${error.message}`); return false; } } private async deleteContract(contractId: string) { if (!this.storeService) return; if ( !confirm( `Are you sure you want to delete contract definition '${contractId}'? This action cannot be undone.`, ) ) { return; } try { await this.storeService.deleteContractDefinition(contractId); this.showSuccess("Contract definition deleted successfully!"); await this.loadContracts(); this.dispatchEvent( new CustomEvent("contract-deleted", { detail: { contractId }, bubbles: true, composed: true, }), ); } catch (error: any) { this.showError(`Failed to delete contract definition: ${error.message}`); } } private handleCreateContract = async (e: Event) => { e.preventDefault(); const form = e.target as HTMLFormElement; const formData = new FormData(form); const contractData: ContractDefinitionInput = { "@id": formData.get("contractId") as string, accessPolicyId: formData.get("accessPolicyId") as string, contractPolicyId: formData.get("contractPolicyId") as string, }; const assetId = formData.get("assetId") as string; if (assetId) { contractData.assetsSelector = [ { "@type": "CriterionDto", operandLeft: "https://w3id.org/edc/v0.0.1/ns/id", operator: "=", operandRight: assetId, }, ]; } await this.createContract(contractData); }; private showError(message: string) { this.errorMessage = message; this.successMessage = ""; setTimeout(() => { this.errorMessage = ""; }, 5000); } private showSuccess(message: string) { this.successMessage = message; this.errorMessage = ""; setTimeout(() => { this.successMessage = ""; }, 3000); } private clearMessages() { this.errorMessage = ""; this.successMessage = ""; } private _handleSearch(e: CustomEvent) { this.searchTerm = e.detail || ""; } private get filteredContracts() { let filtered = [...this.contracts]; if (this.searchTerm) { const term = this.searchTerm.toLowerCase(); filtered = filtered.filter((contract) => { const id = contract["@id"]?.toLowerCase() || ""; const accessPolicy = contract.accessPolicyId?.toLowerCase() || ""; const contractPolicy = contract.contractPolicyId?.toLowerCase() || ""; return ( id.includes(term) || accessPolicy.includes(term) || contractPolicy.includes(term) ); }); } filtered.sort((a, b) => { let aVal: string | number; let bVal: string | number; switch (this.sortBy) { case "id": aVal = a["@id"] || ""; bVal = b["@id"] || ""; break; case "accessPolicy": aVal = a.accessPolicyId || ""; bVal = b.accessPolicyId || ""; break; case "created": aVal = a.createdAt || 0; bVal = b.createdAt || 0; break; default: aVal = a["@id"] || ""; bVal = b["@id"] || ""; } if (typeof aVal === "string" && typeof bVal === "string") { aVal = aVal.toLowerCase(); bVal = bVal.toLowerCase(); } let result = 0; if (aVal < bVal) result = -1; else if (aVal > bVal) result = 1; return this.sortDirection === "desc" ? -result : result; }); return filtered; } render() { return html`
`} @clicked=${() => { this.showCreateForm = true; }} >
${this.renderMessages()}
{ this.sortDirection = this.sortDirection === "asc" ? "desc" : "asc"; }} > `} ?disabled=${this.isLoading} @clicked=${() => this.loadData()} >
${this.showCreateForm ? this.renderCreateForm() : nothing} ${this.viewingContract ? this.renderDetailView() : nothing} ${this.isLoading ? html`
${msg("Loading contract definitions...")}
` : this.renderContractsList()}
`; } private renderMessages() { return html` ${this.errorMessage ? html`
${this.errorMessage}
` : nothing} ${this.successMessage ? html`
${this.successMessage}
` : nothing} `; } private renderContractsList() { const contracts = this.filteredContracts; if (contracts.length === 0) { return html`
📄

${msg("No contract definitions found")}

${msg("Create your first contract definition to get started.")}

`; } return html`
${repeat( contracts, (contract) => contract["@id"], (contract) => this.renderContractCard(contract), )}
`; } private renderContractCard(contract: ContractDefinition) { const targetAssetIds = this.getTargetAssetIds(contract); return html`
{ this.viewingContract = contract; }} >
${contract["@id"]}
${targetAssetIds.length > 0 ? html`
${msg("Target Asset")}:
${targetAssetIds.map( (assetId) => html` ${this.getAssetName(assetId)} `, )}
` : html`
${msg("All assets")}
`}
${msg("Access Policy")}: ${contract.accessPolicyId}
${msg("Contract Policy")}: ${contract.contractPolicyId}
${contract.createdAt ? html`
${msg("Created")}: ${new Date(contract.createdAt).toLocaleString()}
` : nothing}
e.stopPropagation()} >
`; } private renderCreateForm() { return html` `; } private renderDetailView() { if (!this.viewingContract) return nothing; const contract = this.viewingContract; const targetAssetIds = this.getTargetAssetIds(contract); const selectorData = contract.assetsSelector; const selectors: AssetSelector[] = selectorData ? Array.isArray(selectorData) ? selectorData : [selectorData] : []; return html` `; } }