feat(catalog): add ContractEditor and many editor subcomponents; implement SignPad and SignBox; update README and bump dependencies

This commit is contained in:
2025-12-18 15:27:22 +00:00
parent 6d53259b75
commit 56c087bc3a
35 changed files with 10914 additions and 1959 deletions

View File

@@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@signature.digital/catalog',
version: '1.0.59',
version: '1.1.0',
description: 'A comprehensive catalog of customizable web components designed for building and managing e-signature applications.'
}

View File

@@ -1,3 +1,17 @@
export * from './sdig-contracteditor.js';
export * from './sdig-signbox.js';
export * from './sdig-signpad.js';
// Contract Editor (main module)
export * from './sdig-contracteditor/index.js';
// Contract sub-components
export * from './sdig-contract-header/index.js';
export * from './sdig-contract-metadata/index.js';
export * from './sdig-contract-parties/index.js';
export * from './sdig-contract-content/index.js';
export * from './sdig-contract-terms/index.js';
export * from './sdig-contract-signatures/index.js';
export * from './sdig-contract-attachments/index.js';
export * from './sdig-contract-collaboration/index.js';
export * from './sdig-contract-audit/index.js';
// Signature components
export * from './sdig-signbox/index.js';
export * from './sdig-signpad/index.js';

View File

@@ -0,0 +1 @@
export * from './sdig-contract-attachments.js';

View File

@@ -0,0 +1,806 @@
/**
* @file sdig-contract-attachments.ts
* @description Contract attachments and prior contracts manager
*/
import {
DeesElement,
property,
html,
customElement,
type TemplateResult,
css,
cssManager,
state,
} from '@design.estate/dees-element';
import * as plugins from '../../plugins.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-contract-attachments': SdigContractAttachments;
}
}
// Attachment interface
interface IAttachment {
id: string;
name: string;
type: 'document' | 'image' | 'spreadsheet' | 'pdf' | 'other';
mimeType: string;
size: number;
uploadedAt: number;
uploadedBy: string;
description?: string;
url?: string;
}
// File type configuration
const FILE_TYPES = {
document: { icon: 'lucide:file-text', color: '#3b82f6', label: 'Document' },
image: { icon: 'lucide:image', color: '#10b981', label: 'Image' },
spreadsheet: { icon: 'lucide:sheet', color: '#22c55e', label: 'Spreadsheet' },
pdf: { icon: 'lucide:file-type', color: '#ef4444', label: 'PDF' },
other: { icon: 'lucide:file', color: '#6b7280', label: 'File' },
};
@customElement('sdig-contract-attachments')
export class SdigContractAttachments extends DeesElement {
// ============================================================================
// STATIC
// ============================================================================
public static demo = () => html`
<sdig-contract-attachments
.contract=${plugins.sdDemodata.demoContract}
></sdig-contract-attachments>
`;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
}
.attachments-container {
display: flex;
flex-direction: column;
gap: 24px;
}
/* Section card */
.section-card {
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 12px;
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.section-title dees-icon {
font-size: 18px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.section-count {
font-size: 13px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.section-content {
padding: 20px;
}
/* Upload zone */
.upload-zone {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
border: 2px dashed ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
border-radius: 12px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
cursor: pointer;
transition: all 0.15s ease;
}
.upload-zone:hover {
border-color: ${cssManager.bdTheme('#9ca3af', '#52525b')};
background: ${cssManager.bdTheme('#f3f4f6', '#18181b')};
}
.upload-zone.dragging {
border-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
background: ${cssManager.bdTheme('#eff6ff', '#172554')};
}
.upload-zone-icon {
width: 64px;
height: 64px;
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
margin-bottom: 16px;
}
.upload-zone-title {
font-size: 16px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
margin-bottom: 8px;
}
.upload-zone-subtitle {
font-size: 14px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
margin-bottom: 16px;
}
.upload-zone-hint {
font-size: 12px;
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
/* Attachments list */
.attachments-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.attachment-item {
display: flex;
align-items: center;
gap: 14px;
padding: 14px 16px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 10px;
transition: all 0.15s ease;
}
.attachment-item:hover {
border-color: ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
}
.attachment-icon {
width: 44px;
height: 44px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
}
.attachment-info {
flex: 1;
min-width: 0;
}
.attachment-name {
font-size: 14px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.attachment-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 12px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.attachment-meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.attachment-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
/* Prior contracts */
.prior-contracts-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.prior-contract-item {
display: flex;
align-items: center;
gap: 14px;
padding: 16px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 10px;
cursor: pointer;
transition: all 0.15s ease;
}
.prior-contract-item:hover {
border-color: ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
background: ${cssManager.bdTheme('#f3f4f6', '#18181b')};
}
.prior-contract-icon {
width: 48px;
height: 48px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
background: ${cssManager.bdTheme('#dbeafe', '#1e3a5f')};
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
flex-shrink: 0;
}
.prior-contract-info {
flex: 1;
min-width: 0;
}
.prior-contract-title {
font-size: 15px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
margin-bottom: 4px;
}
.prior-contract-context {
font-size: 13px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.prior-contract-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 20px;
text-align: center;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.empty-state dees-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-state h4 {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.empty-state p {
margin: 0;
font-size: 14px;
}
/* Storage summary */
.storage-summary {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 10px;
margin-bottom: 20px;
}
.storage-info {
flex: 1;
}
.storage-label {
font-size: 13px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
margin-bottom: 6px;
}
.storage-bar {
height: 6px;
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
border-radius: 3px;
overflow: hidden;
}
.storage-fill {
height: 100%;
background: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
transition: width 0.3s ease;
}
.storage-text {
font-size: 14px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
white-space: nowrap;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-sm {
padding: 6px 10px;
font-size: 12px;
}
.btn-primary {
background: ${cssManager.bdTheme('#111111', '#fafafa')};
color: ${cssManager.bdTheme('#ffffff', '#09090b')};
}
.btn-primary:hover {
background: ${cssManager.bdTheme('#333333', '#e5e5e5')};
}
.btn-secondary {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.btn-secondary:hover {
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
}
.btn-ghost {
background: transparent;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
padding: 6px;
}
.btn-ghost:hover {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.btn-danger {
color: ${cssManager.bdTheme('#dc2626', '#f87171')};
}
.btn-danger:hover {
background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
}
/* Type badge */
.type-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
`,
];
// ============================================================================
// PROPERTIES
// ============================================================================
@property({ type: Object })
public accessor contract: plugins.sdInterfaces.IPortableContract | null = null;
@property({ type: Boolean })
public accessor readonly: boolean = false;
// ============================================================================
// STATE
// ============================================================================
@state()
private accessor isDragging: boolean = false;
// Demo attachments data
@state()
private accessor attachments: IAttachment[] = [
{
id: '1',
name: 'Employment_Terms_v2.pdf',
type: 'pdf',
mimeType: 'application/pdf',
size: 245760,
uploadedAt: Date.now() - 86400000 * 3,
uploadedBy: 'employer',
description: 'Original employment terms document',
},
{
id: '2',
name: 'ID_Verification.png',
type: 'image',
mimeType: 'image/png',
size: 1024000,
uploadedAt: Date.now() - 86400000,
uploadedBy: 'employee',
},
{
id: '3',
name: 'Tax_Information.xlsx',
type: 'spreadsheet',
mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
size: 52480,
uploadedAt: Date.now() - 86400000 * 2,
uploadedBy: 'employer',
},
];
// ============================================================================
// EVENT HANDLERS
// ============================================================================
private handleFieldChange(path: string, value: unknown) {
this.dispatchEvent(
new CustomEvent('field-change', {
detail: { path, value },
bubbles: true,
composed: true,
})
);
}
private handleDragEnter(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
this.isDragging = true;
}
private handleDragLeave(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
this.isDragging = false;
}
private handleDragOver(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
}
private handleDrop(e: DragEvent) {
e.preventDefault();
e.stopPropagation();
this.isDragging = false;
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
this.handleFiles(files);
}
}
private handleFileSelect() {
const input = document.createElement('input');
input.type = 'file';
input.multiple = true;
input.onchange = () => {
if (input.files && input.files.length > 0) {
this.handleFiles(input.files);
}
};
input.click();
}
private handleFiles(files: FileList) {
// Demo: just add to list
Array.from(files).forEach((file) => {
const newAttachment: IAttachment = {
id: `att-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
name: file.name,
type: this.getFileType(file.type),
mimeType: file.type,
size: file.size,
uploadedAt: Date.now(),
uploadedBy: 'user',
};
this.attachments = [...this.attachments, newAttachment];
});
}
private handleDeleteAttachment(attachmentId: string) {
this.attachments = this.attachments.filter((a) => a.id !== attachmentId);
}
private handleAddPriorContract() {
// TODO: Open prior contract picker modal
}
private handleRemovePriorContract(index: number) {
if (!this.contract) return;
const updatedPriorContracts = [...this.contract.priorContracts];
updatedPriorContracts.splice(index, 1);
this.handleFieldChange('priorContracts', updatedPriorContracts);
}
// ============================================================================
// HELPERS
// ============================================================================
private getFileType(mimeType: string): IAttachment['type'] {
if (mimeType.includes('pdf')) return 'pdf';
if (mimeType.includes('image')) return 'image';
if (mimeType.includes('spreadsheet') || mimeType.includes('excel')) return 'spreadsheet';
if (mimeType.includes('document') || mimeType.includes('word')) return 'document';
return 'other';
}
private getFileTypeConfig(type: IAttachment['type']) {
return FILE_TYPES[type] || FILE_TYPES.other;
}
private formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
private formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
private getTotalSize(): number {
return this.attachments.reduce((sum, a) => sum + a.size, 0);
}
private getPartyName(roleId: string): string {
const role = this.contract?.availableRoles.find((r) => r.id === roleId);
return role?.name || roleId;
}
// ============================================================================
// RENDER
// ============================================================================
public render(): TemplateResult {
if (!this.contract) {
return html`<div>No contract loaded</div>`;
}
const totalSize = this.getTotalSize();
const maxSize = 50 * 1024 * 1024; // 50MB demo limit
const usagePercent = Math.min((totalSize / maxSize) * 100, 100);
return html`
<div class="attachments-container">
<!-- Attachments Section -->
<div class="section-card">
<div class="section-header">
<div class="section-title">
<dees-icon .iconFA=${'lucide:paperclip'}></dees-icon>
Attachments
</div>
<span class="section-count">${this.attachments.length} files</span>
</div>
<div class="section-content">
<!-- Storage summary -->
<div class="storage-summary">
<div class="storage-info">
<div class="storage-label">Storage used</div>
<div class="storage-bar">
<div class="storage-fill" style="width: ${usagePercent}%"></div>
</div>
</div>
<div class="storage-text">
${this.formatFileSize(totalSize)} / ${this.formatFileSize(maxSize)}
</div>
</div>
<!-- Upload zone -->
${!this.readonly
? html`
<div
class="upload-zone ${this.isDragging ? 'dragging' : ''}"
@dragenter=${this.handleDragEnter}
@dragleave=${this.handleDragLeave}
@dragover=${this.handleDragOver}
@drop=${this.handleDrop}
@click=${this.handleFileSelect}
>
<div class="upload-zone-icon">
<dees-icon .iconFA=${'lucide:upload-cloud'}></dees-icon>
</div>
<div class="upload-zone-title">Drop files here or click to upload</div>
<div class="upload-zone-subtitle">Add supporting documents, images, or spreadsheets</div>
<div class="upload-zone-hint">PDF, DOCX, XLSX, PNG, JPG up to 10MB each</div>
</div>
`
: ''}
<!-- Attachments list -->
${this.attachments.length > 0
? html`
<div class="attachments-list" style="margin-top: 20px;">
${this.attachments.map((attachment) => this.renderAttachmentItem(attachment))}
</div>
`
: html`
<div class="empty-state" style="margin-top: 20px;">
<dees-icon .iconFA=${'lucide:file-x'}></dees-icon>
<h4>No Attachments</h4>
<p>Upload files to attach them to this contract</p>
</div>
`}
</div>
</div>
<!-- Prior Contracts Section -->
<div class="section-card">
<div class="section-header">
<div class="section-title">
<dees-icon .iconFA=${'lucide:files'}></dees-icon>
Prior Contracts
</div>
${!this.readonly
? html`
<button class="btn btn-secondary" @click=${this.handleAddPriorContract}>
<dees-icon .iconFA=${'lucide:plus'}></dees-icon>
Link Contract
</button>
`
: ''}
</div>
<div class="section-content">
${this.contract.priorContracts.length > 0
? html`
<div class="prior-contracts-list">
${this.contract.priorContracts.map((priorContract, index) =>
this.renderPriorContractItem(priorContract, index)
)}
</div>
`
: html`
<div class="empty-state">
<dees-icon .iconFA=${'lucide:link'}></dees-icon>
<h4>No Prior Contracts</h4>
<p>Link related or predecessor contracts here</p>
</div>
`}
</div>
</div>
</div>
`;
}
private renderAttachmentItem(attachment: IAttachment): TemplateResult {
const typeConfig = this.getFileTypeConfig(attachment.type);
return html`
<div class="attachment-item">
<div class="attachment-icon" style="background: ${typeConfig.color}20; color: ${typeConfig.color}">
<dees-icon .iconFA=${typeConfig.icon}></dees-icon>
</div>
<div class="attachment-info">
<div class="attachment-name">${attachment.name}</div>
<div class="attachment-meta">
<span class="type-badge">${typeConfig.label}</span>
<span class="attachment-meta-item">
${this.formatFileSize(attachment.size)}
</span>
<span class="attachment-meta-item">
<dees-icon .iconFA=${'lucide:calendar'}></dees-icon>
${this.formatDate(attachment.uploadedAt)}
</span>
<span class="attachment-meta-item">
<dees-icon .iconFA=${'lucide:user'}></dees-icon>
${this.getPartyName(attachment.uploadedBy)}
</span>
</div>
</div>
<div class="attachment-actions">
<button class="btn btn-ghost" title="Download">
<dees-icon .iconFA=${'lucide:download'}></dees-icon>
</button>
<button class="btn btn-ghost" title="Preview">
<dees-icon .iconFA=${'lucide:eye'}></dees-icon>
</button>
${!this.readonly
? html`
<button
class="btn btn-ghost btn-danger"
title="Delete"
@click=${() => this.handleDeleteAttachment(attachment.id)}
>
<dees-icon .iconFA=${'lucide:trash-2'}></dees-icon>
</button>
`
: ''}
</div>
</div>
`;
}
private renderPriorContractItem(priorContract: plugins.sdInterfaces.IPortableContract, index: number): TemplateResult {
return html`
<div class="prior-contract-item">
<div class="prior-contract-icon">
<dees-icon .iconFA=${'lucide:file-text'}></dees-icon>
</div>
<div class="prior-contract-info">
<div class="prior-contract-title">${priorContract.title}</div>
<div class="prior-contract-context">${priorContract.context || 'No description'}</div>
</div>
<div class="prior-contract-actions">
<button class="btn btn-secondary btn-sm">
<dees-icon .iconFA=${'lucide:external-link'}></dees-icon>
View
</button>
${!this.readonly
? html`
<button
class="btn btn-ghost btn-danger"
@click=${() => this.handleRemovePriorContract(index)}
>
<dees-icon .iconFA=${'lucide:unlink'}></dees-icon>
</button>
`
: ''}
</div>
</div>
`;
}
}

View File

@@ -0,0 +1 @@
export * from './sdig-contract-audit.js';

View File

@@ -0,0 +1,772 @@
/**
* @file sdig-contract-audit.ts
* @description Contract audit log and lifecycle history component
*/
import {
DeesElement,
property,
html,
customElement,
type TemplateResult,
css,
cssManager,
state,
} from '@design.estate/dees-element';
import * as plugins from '../../plugins.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-contract-audit': SdigContractAudit;
}
}
// Audit event interface
interface IAuditEvent {
id: string;
timestamp: number;
type: 'created' | 'updated' | 'status_change' | 'signature' | 'comment' | 'attachment' | 'viewed' | 'shared';
userId: string;
userName: string;
userColor: string;
description: string;
details?: {
field?: string;
oldValue?: string;
newValue?: string;
attachmentName?: string;
signatureStatus?: string;
};
}
// Status workflow configuration
const STATUS_WORKFLOW = [
{ id: 'draft', label: 'Draft', icon: 'lucide:file-edit', color: '#f59e0b' },
{ id: 'review', label: 'Review', icon: 'lucide:eye', color: '#3b82f6' },
{ id: 'pending', label: 'Pending Signatures', icon: 'lucide:pen-tool', color: '#8b5cf6' },
{ id: 'signed', label: 'Signed', icon: 'lucide:check-circle', color: '#10b981' },
{ id: 'executed', label: 'Executed', icon: 'lucide:shield-check', color: '#059669' },
];
// Event type configuration
const EVENT_TYPES = {
created: { icon: 'lucide:plus-circle', color: '#10b981', label: 'Created' },
updated: { icon: 'lucide:pencil', color: '#3b82f6', label: 'Updated' },
status_change: { icon: 'lucide:arrow-right-circle', color: '#8b5cf6', label: 'Status Changed' },
signature: { icon: 'lucide:pen-tool', color: '#10b981', label: 'Signature' },
comment: { icon: 'lucide:message-circle', color: '#f59e0b', label: 'Comment' },
attachment: { icon: 'lucide:paperclip', color: '#6366f1', label: 'Attachment' },
viewed: { icon: 'lucide:eye', color: '#6b7280', label: 'Viewed' },
shared: { icon: 'lucide:share-2', color: '#ec4899', label: 'Shared' },
};
@customElement('sdig-contract-audit')
export class SdigContractAudit extends DeesElement {
// ============================================================================
// STATIC
// ============================================================================
public static demo = () => html`
<sdig-contract-audit
.contract=${plugins.sdDemodata.demoContract}
></sdig-contract-audit>
`;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
}
.audit-container {
display: flex;
flex-direction: column;
gap: 24px;
}
/* Lifecycle status */
.lifecycle-card {
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 12px;
padding: 24px;
}
.lifecycle-title {
font-size: 16px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
margin-bottom: 20px;
}
.status-workflow {
display: flex;
align-items: center;
gap: 8px;
overflow-x: auto;
padding-bottom: 8px;
}
.status-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
min-width: 100px;
}
.status-icon {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
transition: all 0.2s ease;
}
.status-step.completed .status-icon {
background: ${cssManager.bdTheme('#d1fae5', '#064e3b')};
color: ${cssManager.bdTheme('#059669', '#34d399')};
}
.status-step.current .status-icon {
background: ${cssManager.bdTheme('#dbeafe', '#1e3a5f')};
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
box-shadow: 0 0 0 4px ${cssManager.bdTheme('rgba(59, 130, 246, 0.2)', 'rgba(96, 165, 250, 0.2)')};
}
.status-label {
font-size: 12px;
font-weight: 500;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
text-align: center;
}
.status-step.completed .status-label,
.status-step.current .status-label {
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.status-connector {
flex: 1;
height: 2px;
background: ${cssManager.bdTheme('#e5e5e5', '#27272a')};
min-width: 40px;
}
.status-connector.completed {
background: ${cssManager.bdTheme('#10b981', '#34d399')};
}
/* Section card */
.section-card {
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 12px;
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.section-title dees-icon {
font-size: 18px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.section-content {
padding: 20px;
}
/* Filter controls */
.filter-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.filter-select {
padding: 8px 12px;
font-size: 13px;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
border-radius: 6px;
outline: none;
cursor: pointer;
}
.search-input {
flex: 1;
padding: 8px 12px;
font-size: 13px;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
border-radius: 6px;
outline: none;
}
.search-input:focus {
border-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
}
/* Timeline */
.timeline {
position: relative;
padding-left: 32px;
}
.timeline::before {
content: '';
position: absolute;
left: 11px;
top: 0;
bottom: 0;
width: 2px;
background: ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.timeline-item {
position: relative;
padding-bottom: 24px;
}
.timeline-item:last-child {
padding-bottom: 0;
}
.timeline-dot {
position: absolute;
left: -32px;
top: 0;
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 2px solid;
}
.timeline-content {
padding: 16px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 10px;
}
.timeline-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.timeline-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.timeline-time {
font-size: 12px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.timeline-user {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.timeline-avatar {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 600;
color: white;
}
.timeline-username {
font-size: 13px;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.timeline-description {
font-size: 13px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.timeline-details {
margin-top: 10px;
padding: 10px;
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border-radius: 6px;
font-size: 12px;
font-family: 'Roboto Mono', monospace;
}
.detail-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.detail-row:last-child {
margin-bottom: 0;
}
.detail-label {
font-weight: 500;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.detail-value {
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.detail-old {
text-decoration: line-through;
color: ${cssManager.bdTheme('#ef4444', '#f87171')};
}
.detail-new {
color: ${cssManager.bdTheme('#10b981', '#34d399')};
}
/* Event type badge */
.event-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
}
/* Stats row */
.stats-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
padding: 16px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 10px;
text-align: center;
}
.stat-value {
font-size: 28px;
font-weight: 700;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
margin-bottom: 4px;
}
.stat-label {
font-size: 13px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 20px;
text-align: center;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.empty-state dees-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-state h4 {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.empty-state p {
margin: 0;
font-size: 14px;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-secondary {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.btn-secondary:hover {
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
}
`,
];
// ============================================================================
// PROPERTIES
// ============================================================================
@property({ type: Object })
public accessor contract: plugins.sdInterfaces.IPortableContract | null = null;
// ============================================================================
// STATE
// ============================================================================
@state()
private accessor filterType: string = 'all';
@state()
private accessor searchQuery: string = '';
// Demo audit events
@state()
private accessor auditEvents: IAuditEvent[] = [
{
id: '1',
timestamp: Date.now() - 3600000,
type: 'signature',
userId: '1',
userName: 'Alice Smith',
userColor: '#3b82f6',
description: 'Signed the contract',
details: { signatureStatus: 'completed' },
},
{
id: '2',
timestamp: Date.now() - 7200000,
type: 'status_change',
userId: '2',
userName: 'Bob Johnson',
userColor: '#10b981',
description: 'Changed status from Review to Pending Signatures',
details: { field: 'status', oldValue: 'review', newValue: 'pending' },
},
{
id: '3',
timestamp: Date.now() - 86400000,
type: 'updated',
userId: '2',
userName: 'Bob Johnson',
userColor: '#10b981',
description: 'Updated compensation amount',
details: { field: 'paragraphs.2.content', oldValue: '[Salary Amount]', newValue: '€520/month' },
},
{
id: '4',
timestamp: Date.now() - 86400000 * 2,
type: 'comment',
userId: '1',
userName: 'Alice Smith',
userColor: '#3b82f6',
description: 'Added a comment on Compensation section',
},
{
id: '5',
timestamp: Date.now() - 86400000 * 3,
type: 'attachment',
userId: '3',
userName: 'Carol Davis',
userColor: '#f59e0b',
description: 'Uploaded ID verification document',
details: { attachmentName: 'ID_Verification.pdf' },
},
{
id: '6',
timestamp: Date.now() - 86400000 * 5,
type: 'created',
userId: '2',
userName: 'Bob Johnson',
userColor: '#10b981',
description: 'Created the contract',
},
];
// ============================================================================
// HELPERS
// ============================================================================
private getEventConfig(type: IAuditEvent['type']) {
return EVENT_TYPES[type] || EVENT_TYPES.updated;
}
private getFilteredEvents(): IAuditEvent[] {
let events = this.auditEvents;
if (this.filterType !== 'all') {
events = events.filter((e) => e.type === this.filterType);
}
if (this.searchQuery) {
const query = this.searchQuery.toLowerCase();
events = events.filter(
(e) =>
e.description.toLowerCase().includes(query) ||
e.userName.toLowerCase().includes(query)
);
}
return events;
}
private formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
private formatTimeAgo(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
private getCurrentStatusIndex(): number {
// Demo: Return a fixed position
return 2; // Pending Signatures
}
private getEventStats() {
const total = this.auditEvents.length;
const updates = this.auditEvents.filter((e) => e.type === 'updated').length;
const signatures = this.auditEvents.filter((e) => e.type === 'signature').length;
const comments = this.auditEvents.filter((e) => e.type === 'comment').length;
return { total, updates, signatures, comments };
}
// ============================================================================
// RENDER
// ============================================================================
public render(): TemplateResult {
if (!this.contract) {
return html`<div>No contract loaded</div>`;
}
const currentStatusIndex = this.getCurrentStatusIndex();
const filteredEvents = this.getFilteredEvents();
const stats = this.getEventStats();
return html`
<div class="audit-container">
<!-- Lifecycle Status -->
<div class="lifecycle-card">
<div class="lifecycle-title">Contract Lifecycle</div>
<div class="status-workflow">
${STATUS_WORKFLOW.map((status, index) => html`
<div class="status-step ${index < currentStatusIndex ? 'completed' : ''} ${index === currentStatusIndex ? 'current' : ''}">
<div class="status-icon">
<dees-icon .iconFA=${status.icon}></dees-icon>
</div>
<div class="status-label">${status.label}</div>
</div>
${index < STATUS_WORKFLOW.length - 1
? html`<div class="status-connector ${index < currentStatusIndex ? 'completed' : ''}"></div>`
: ''}
`)}
</div>
</div>
<!-- Stats -->
<div class="stats-row">
<div class="stat-card">
<div class="stat-value">${stats.total}</div>
<div class="stat-label">Total Events</div>
</div>
<div class="stat-card">
<div class="stat-value">${stats.updates}</div>
<div class="stat-label">Updates</div>
</div>
<div class="stat-card">
<div class="stat-value">${stats.signatures}</div>
<div class="stat-label">Signatures</div>
</div>
<div class="stat-card">
<div class="stat-value">${stats.comments}</div>
<div class="stat-label">Comments</div>
</div>
</div>
<!-- Audit Log -->
<div class="section-card">
<div class="section-header">
<div class="section-title">
<dees-icon .iconFA=${'lucide:history'}></dees-icon>
Activity Log
</div>
<button class="btn btn-secondary">
<dees-icon .iconFA=${'lucide:download'}></dees-icon>
Export
</button>
</div>
<div class="section-content">
<!-- Filters -->
<div class="filter-row">
<select
class="filter-select"
.value=${this.filterType}
@change=${(e: Event) => (this.filterType = (e.target as HTMLSelectElement).value)}
>
<option value="all">All Events</option>
${Object.entries(EVENT_TYPES).map(
([key, config]) => html`<option value=${key}>${config.label}</option>`
)}
</select>
<input
type="text"
class="search-input"
placeholder="Search events..."
.value=${this.searchQuery}
@input=${(e: Event) => (this.searchQuery = (e.target as HTMLInputElement).value)}
/>
</div>
<!-- Timeline -->
${filteredEvents.length > 0
? html`
<div class="timeline">
${filteredEvents.map((event) => this.renderTimelineItem(event))}
</div>
`
: html`
<div class="empty-state">
<dees-icon .iconFA=${'lucide:clock'}></dees-icon>
<h4>No Events Found</h4>
<p>No activity matches your current filters</p>
</div>
`}
</div>
</div>
</div>
`;
}
private renderTimelineItem(event: IAuditEvent): TemplateResult {
const config = this.getEventConfig(event.type);
return html`
<div class="timeline-item">
<div class="timeline-dot" style="border-color: ${config.color}; color: ${config.color}">
<dees-icon .iconFA=${config.icon}></dees-icon>
</div>
<div class="timeline-content">
<div class="timeline-header">
<div class="timeline-title">
<span class="event-badge" style="background: ${config.color}20; color: ${config.color}">
${config.label}
</span>
</div>
<span class="timeline-time" title="${this.formatDate(event.timestamp)}">
${this.formatTimeAgo(event.timestamp)}
</span>
</div>
<div class="timeline-user">
<div class="timeline-avatar" style="background: ${event.userColor}">
${event.userName.charAt(0)}
</div>
<span class="timeline-username">${event.userName}</span>
</div>
<div class="timeline-description">${event.description}</div>
${event.details
? html`
<div class="timeline-details">
${event.details.field
? html`
<div class="detail-row">
<span class="detail-label">Field:</span>
<span class="detail-value">${event.details.field}</span>
</div>
`
: ''}
${event.details.oldValue && event.details.newValue
? html`
<div class="detail-row">
<span class="detail-old">${event.details.oldValue}</span>
<span>→</span>
<span class="detail-new">${event.details.newValue}</span>
</div>
`
: ''}
${event.details.attachmentName
? html`
<div class="detail-row">
<span class="detail-label">File:</span>
<span class="detail-value">${event.details.attachmentName}</span>
</div>
`
: ''}
</div>
`
: ''}
</div>
</div>
`;
}
}

View File

@@ -0,0 +1 @@
export * from './sdig-contract-collaboration.js';

View File

@@ -0,0 +1,972 @@
/**
* @file sdig-contract-collaboration.ts
* @description Contract collaboration - comments, suggestions, and presence
*/
import {
DeesElement,
property,
html,
customElement,
type TemplateResult,
css,
cssManager,
state,
} from '@design.estate/dees-element';
import * as plugins from '../../plugins.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-contract-collaboration': SdigContractCollaboration;
}
}
// Comment interface
interface IComment {
id: string;
userId: string;
userName: string;
userColor: string;
content: string;
createdAt: number;
updatedAt?: number;
anchorPath?: string;
anchorText?: string;
resolved: boolean;
replies: IComment[];
}
// Suggestion interface
interface ISuggestion {
id: string;
userId: string;
userName: string;
userColor: string;
originalText: string;
suggestedText: string;
path: string;
status: 'pending' | 'accepted' | 'rejected';
createdAt: number;
}
// Presence interface
interface IPresence {
userId: string;
userName: string;
userColor: string;
currentSection: string;
cursorPosition?: { path: string; offset: number };
lastActive: number;
}
@customElement('sdig-contract-collaboration')
export class SdigContractCollaboration extends DeesElement {
// ============================================================================
// STATIC
// ============================================================================
public static demo = () => html`
<sdig-contract-collaboration
.contract=${plugins.sdDemodata.demoContract}
></sdig-contract-collaboration>
`;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
}
.collaboration-container {
display: flex;
flex-direction: column;
gap: 24px;
}
/* Presence bar */
.presence-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 12px;
}
.presence-info {
display: flex;
align-items: center;
gap: 12px;
}
.presence-label {
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.presence-avatars {
display: flex;
align-items: center;
}
.presence-avatar {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
color: white;
margin-left: -8px;
border: 2px solid ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
cursor: pointer;
position: relative;
}
.presence-avatar:first-child {
margin-left: 0;
}
.presence-avatar .status-dot {
position: absolute;
bottom: 0;
right: 0;
width: 10px;
height: 10px;
border-radius: 50%;
background: #10b981;
border: 2px solid ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
}
.presence-avatar .status-dot.away {
background: #f59e0b;
}
.presence-count {
display: flex;
align-items: center;
justify-content: center;
min-width: 36px;
height: 36px;
border-radius: 50%;
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
font-size: 13px;
font-weight: 600;
margin-left: -8px;
border: 2px solid ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
}
.share-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: ${cssManager.bdTheme('#111111', '#fafafa')};
color: ${cssManager.bdTheme('#ffffff', '#09090b')};
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.share-btn:hover {
background: ${cssManager.bdTheme('#333333', '#e5e5e5')};
}
/* Section card */
.section-card {
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 12px;
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.section-title dees-icon {
font-size: 18px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.section-badge {
padding: 2px 8px;
border-radius: 9999px;
font-size: 12px;
font-weight: 600;
background: ${cssManager.bdTheme('#dbeafe', '#1e3a5f')};
color: ${cssManager.bdTheme('#1e40af', '#93c5fd')};
}
.section-content {
padding: 20px;
}
/* Comments list */
.comments-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.comment-thread {
padding: 16px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 10px;
}
.comment-thread.resolved {
opacity: 0.6;
}
.comment-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.comment-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
color: white;
flex-shrink: 0;
}
.comment-meta {
flex: 1;
}
.comment-author {
font-size: 14px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.comment-time {
font-size: 12px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.comment-anchor {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: ${cssManager.bdTheme('#fef3c7', '#422006')};
border-radius: 4px;
font-size: 12px;
color: ${cssManager.bdTheme('#92400e', '#fcd34d')};
margin-bottom: 10px;
cursor: pointer;
}
.comment-anchor:hover {
background: ${cssManager.bdTheme('#fde68a', '#713f12')};
}
.comment-content {
font-size: 14px;
line-height: 1.6;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
margin-bottom: 12px;
}
.comment-actions {
display: flex;
align-items: center;
gap: 8px;
}
.comment-replies {
margin-top: 16px;
padding-left: 16px;
border-left: 2px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.reply-item {
padding: 12px;
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border-radius: 8px;
margin-bottom: 8px;
}
.reply-item:last-child {
margin-bottom: 0;
}
/* Suggestions list */
.suggestions-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.suggestion-card {
padding: 16px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 10px;
}
.suggestion-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.suggestion-user {
display: flex;
align-items: center;
gap: 8px;
}
.suggestion-status {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
}
.suggestion-status.pending {
background: ${cssManager.bdTheme('#fef3c7', '#422006')};
color: ${cssManager.bdTheme('#92400e', '#fcd34d')};
}
.suggestion-status.accepted {
background: ${cssManager.bdTheme('#d1fae5', '#064e3b')};
color: ${cssManager.bdTheme('#065f46', '#6ee7b7')};
}
.suggestion-status.rejected {
background: ${cssManager.bdTheme('#fee2e2', '#450a0a')};
color: ${cssManager.bdTheme('#991b1b', '#fca5a5')};
}
.suggestion-diff {
padding: 12px;
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 8px;
font-family: 'Roboto Mono', monospace;
font-size: 13px;
line-height: 1.5;
}
.diff-removed {
background: ${cssManager.bdTheme('#fee2e2', '#450a0a')};
color: ${cssManager.bdTheme('#991b1b', '#fca5a5')};
text-decoration: line-through;
padding: 2px 4px;
border-radius: 2px;
}
.diff-added {
background: ${cssManager.bdTheme('#d1fae5', '#064e3b')};
color: ${cssManager.bdTheme('#065f46', '#6ee7b7')};
padding: 2px 4px;
border-radius: 2px;
}
.suggestion-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
/* New comment input */
.new-comment {
display: flex;
gap: 12px;
padding: 16px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 10px;
}
.new-comment-input {
flex: 1;
padding: 12px;
font-size: 14px;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
border-radius: 8px;
outline: none;
resize: none;
min-height: 80px;
font-family: inherit;
}
.new-comment-input:focus {
border-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(96, 165, 250, 0.1)')};
}
.new-comment-input::placeholder {
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 20px;
text-align: center;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.empty-state dees-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-state h4 {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.empty-state p {
margin: 0;
font-size: 14px;
}
/* Filter tabs */
.filter-tabs {
display: flex;
gap: 4px;
margin-bottom: 16px;
}
.filter-tab {
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.filter-tab:hover {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.filter-tab.active {
background: ${cssManager.bdTheme('#111111', '#fafafa')};
color: ${cssManager.bdTheme('#ffffff', '#09090b')};
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-sm {
padding: 6px 10px;
font-size: 12px;
}
.btn-primary {
background: ${cssManager.bdTheme('#111111', '#fafafa')};
color: ${cssManager.bdTheme('#ffffff', '#09090b')};
}
.btn-primary:hover {
background: ${cssManager.bdTheme('#333333', '#e5e5e5')};
}
.btn-secondary {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.btn-secondary:hover {
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
}
.btn-ghost {
background: transparent;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.btn-ghost:hover {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.btn-success {
background: ${cssManager.bdTheme('#10b981', '#059669')};
color: white;
}
.btn-success:hover {
background: ${cssManager.bdTheme('#059669', '#047857')};
}
.btn-danger {
background: ${cssManager.bdTheme('#fee2e2', '#450a0a')};
color: ${cssManager.bdTheme('#dc2626', '#f87171')};
}
.btn-danger:hover {
background: ${cssManager.bdTheme('#fecaca', '#7f1d1d')};
}
`,
];
// ============================================================================
// PROPERTIES
// ============================================================================
@property({ type: Object })
public accessor contract: plugins.sdInterfaces.IPortableContract | null = null;
@property({ type: Boolean })
public accessor readonly: boolean = false;
// ============================================================================
// STATE
// ============================================================================
@state()
private accessor activeTab: 'comments' | 'suggestions' = 'comments';
@state()
private accessor commentFilter: 'all' | 'open' | 'resolved' = 'all';
@state()
private accessor newCommentText: string = '';
// Demo presence data
@state()
private accessor presenceList: IPresence[] = [
{ userId: '1', userName: 'Alice Smith', userColor: '#3b82f6', currentSection: 'content', lastActive: Date.now() },
{ userId: '2', userName: 'Bob Johnson', userColor: '#10b981', currentSection: 'parties', lastActive: Date.now() - 60000 },
{ userId: '3', userName: 'Carol Davis', userColor: '#f59e0b', currentSection: 'terms', lastActive: Date.now() - 300000 },
];
// Demo comments data
@state()
private accessor comments: IComment[] = [
{
id: '1',
userId: '1',
userName: 'Alice Smith',
userColor: '#3b82f6',
content: 'Can we clarify the payment terms in paragraph 3? The current wording seems ambiguous.',
createdAt: Date.now() - 3600000,
anchorPath: 'paragraphs.2',
anchorText: 'Compensation',
resolved: false,
replies: [
{
id: '1-1',
userId: '2',
userName: 'Bob Johnson',
userColor: '#10b981',
content: 'Good point. I\'ll update the wording to be more specific.',
createdAt: Date.now() - 1800000,
resolved: false,
replies: [],
},
],
},
{
id: '2',
userId: '3',
userName: 'Carol Davis',
userColor: '#f59e0b',
content: 'The termination clause needs to comply with the latest regulations.',
createdAt: Date.now() - 86400000,
resolved: true,
replies: [],
},
];
// Demo suggestions data
@state()
private accessor suggestions: ISuggestion[] = [
{
id: '1',
userId: '1',
userName: 'Alice Smith',
userColor: '#3b82f6',
originalText: 'monthly salary',
suggestedText: 'monthly gross salary',
path: 'paragraphs.2.content',
status: 'pending',
createdAt: Date.now() - 7200000,
},
];
// ============================================================================
// EVENT HANDLERS
// ============================================================================
private handleFieldChange(path: string, value: unknown) {
this.dispatchEvent(
new CustomEvent('field-change', {
detail: { path, value },
bubbles: true,
composed: true,
})
);
}
private handleAddComment() {
if (!this.newCommentText.trim()) return;
const newComment: IComment = {
id: `comment-${Date.now()}`,
userId: 'current-user',
userName: 'You',
userColor: '#6366f1',
content: this.newCommentText,
createdAt: Date.now(),
resolved: false,
replies: [],
};
this.comments = [newComment, ...this.comments];
this.newCommentText = '';
}
private handleResolveComment(commentId: string) {
this.comments = this.comments.map((c) =>
c.id === commentId ? { ...c, resolved: !c.resolved } : c
);
}
private handleAcceptSuggestion(suggestionId: string) {
this.suggestions = this.suggestions.map((s) =>
s.id === suggestionId ? { ...s, status: 'accepted' as const } : s
);
}
private handleRejectSuggestion(suggestionId: string) {
this.suggestions = this.suggestions.map((s) =>
s.id === suggestionId ? { ...s, status: 'rejected' as const } : s
);
}
// ============================================================================
// HELPERS
// ============================================================================
private getFilteredComments(): IComment[] {
if (this.commentFilter === 'all') return this.comments;
if (this.commentFilter === 'open') return this.comments.filter((c) => !c.resolved);
return this.comments.filter((c) => c.resolved);
}
private formatTimeAgo(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
private getActivePresence(): IPresence[] {
const fiveMinutesAgo = Date.now() - 300000;
return this.presenceList.filter((p) => p.lastActive > fiveMinutesAgo);
}
// ============================================================================
// RENDER
// ============================================================================
public render(): TemplateResult {
if (!this.contract) {
return html`<div>No contract loaded</div>`;
}
const activePresence = this.getActivePresence();
const openComments = this.comments.filter((c) => !c.resolved).length;
const pendingSuggestions = this.suggestions.filter((s) => s.status === 'pending').length;
return html`
<div class="collaboration-container">
<!-- Presence Bar -->
<div class="presence-bar">
<div class="presence-info">
<span class="presence-label">Currently viewing:</span>
<div class="presence-avatars">
${activePresence.slice(0, 4).map(
(p) => html`
<div
class="presence-avatar"
style="background: ${p.userColor}"
title="${p.userName} - ${p.currentSection}"
>
${p.userName.charAt(0)}
<span class="status-dot ${Date.now() - p.lastActive > 60000 ? 'away' : ''}"></span>
</div>
`
)}
${activePresence.length > 4
? html`<div class="presence-count">+${activePresence.length - 4}</div>`
: ''}
</div>
</div>
<button class="share-btn">
<dees-icon .iconFA=${'lucide:share-2'}></dees-icon>
Share
</button>
</div>
<!-- Comments Section -->
<div class="section-card">
<div class="section-header">
<div class="section-title">
<dees-icon .iconFA=${'lucide:message-circle'}></dees-icon>
Comments
${openComments > 0 ? html`<span class="section-badge">${openComments} open</span>` : ''}
</div>
</div>
<div class="section-content">
<!-- Filter tabs -->
<div class="filter-tabs">
<button
class="filter-tab ${this.commentFilter === 'all' ? 'active' : ''}"
@click=${() => (this.commentFilter = 'all')}
>
All (${this.comments.length})
</button>
<button
class="filter-tab ${this.commentFilter === 'open' ? 'active' : ''}"
@click=${() => (this.commentFilter = 'open')}
>
Open (${openComments})
</button>
<button
class="filter-tab ${this.commentFilter === 'resolved' ? 'active' : ''}"
@click=${() => (this.commentFilter = 'resolved')}
>
Resolved (${this.comments.length - openComments})
</button>
</div>
<!-- New comment input -->
${!this.readonly
? html`
<div class="new-comment">
<textarea
class="new-comment-input"
placeholder="Add a comment..."
.value=${this.newCommentText}
@input=${(e: Event) => (this.newCommentText = (e.target as HTMLTextAreaElement).value)}
></textarea>
<button class="btn btn-primary" @click=${this.handleAddComment}>
<dees-icon .iconFA=${'lucide:send'}></dees-icon>
Comment
</button>
</div>
`
: ''}
<!-- Comments list -->
${this.getFilteredComments().length > 0
? html`
<div class="comments-list" style="margin-top: 16px;">
${this.getFilteredComments().map((comment) => this.renderComment(comment))}
</div>
`
: html`
<div class="empty-state" style="margin-top: 16px;">
<dees-icon .iconFA=${'lucide:message-square'}></dees-icon>
<h4>No Comments</h4>
<p>Start a discussion by adding a comment</p>
</div>
`}
</div>
</div>
<!-- Suggestions Section -->
<div class="section-card">
<div class="section-header">
<div class="section-title">
<dees-icon .iconFA=${'lucide:git-pull-request'}></dees-icon>
Suggestions
${pendingSuggestions > 0 ? html`<span class="section-badge">${pendingSuggestions} pending</span>` : ''}
</div>
</div>
<div class="section-content">
${this.suggestions.length > 0
? html`
<div class="suggestions-list">
${this.suggestions.map((suggestion) => this.renderSuggestion(suggestion))}
</div>
`
: html`
<div class="empty-state">
<dees-icon .iconFA=${'lucide:edit-3'}></dees-icon>
<h4>No Suggestions</h4>
<p>Suggested changes will appear here</p>
</div>
`}
</div>
</div>
</div>
`;
}
private renderComment(comment: IComment): TemplateResult {
return html`
<div class="comment-thread ${comment.resolved ? 'resolved' : ''}">
<div class="comment-header">
<div class="comment-avatar" style="background: ${comment.userColor}">
${comment.userName.charAt(0)}
</div>
<div class="comment-meta">
<div class="comment-author">${comment.userName}</div>
<div class="comment-time">${this.formatTimeAgo(comment.createdAt)}</div>
</div>
${!this.readonly
? html`
<button
class="btn btn-ghost btn-sm"
@click=${() => this.handleResolveComment(comment.id)}
>
<dees-icon .iconFA=${comment.resolved ? 'lucide:rotate-ccw' : 'lucide:check'}></dees-icon>
${comment.resolved ? 'Reopen' : 'Resolve'}
</button>
`
: ''}
</div>
${comment.anchorText
? html`
<div class="comment-anchor">
<dees-icon .iconFA=${'lucide:link'}></dees-icon>
${comment.anchorText}
</div>
`
: ''}
<div class="comment-content">${comment.content}</div>
${comment.replies.length > 0
? html`
<div class="comment-replies">
${comment.replies.map(
(reply) => html`
<div class="reply-item">
<div class="comment-header">
<div class="comment-avatar" style="background: ${reply.userColor}; width: 28px; height: 28px; font-size: 11px;">
${reply.userName.charAt(0)}
</div>
<div class="comment-meta">
<div class="comment-author" style="font-size: 13px;">${reply.userName}</div>
<div class="comment-time">${this.formatTimeAgo(reply.createdAt)}</div>
</div>
</div>
<div class="comment-content" style="font-size: 13px; margin-bottom: 0;">${reply.content}</div>
</div>
`
)}
</div>
`
: ''}
</div>
`;
}
private renderSuggestion(suggestion: ISuggestion): TemplateResult {
return html`
<div class="suggestion-card">
<div class="suggestion-header">
<div class="suggestion-user">
<div class="comment-avatar" style="background: ${suggestion.userColor}; width: 28px; height: 28px; font-size: 11px;">
${suggestion.userName.charAt(0)}
</div>
<div>
<div class="comment-author" style="font-size: 13px;">${suggestion.userName}</div>
<div class="comment-time">${this.formatTimeAgo(suggestion.createdAt)}</div>
</div>
</div>
<div class="suggestion-status ${suggestion.status}">
<dees-icon .iconFA=${suggestion.status === 'pending' ? 'lucide:clock' : suggestion.status === 'accepted' ? 'lucide:check' : 'lucide:x'}></dees-icon>
${suggestion.status.charAt(0).toUpperCase() + suggestion.status.slice(1)}
</div>
</div>
<div class="suggestion-diff">
<span class="diff-removed">${suggestion.originalText}</span>
<span> → </span>
<span class="diff-added">${suggestion.suggestedText}</span>
</div>
${suggestion.status === 'pending' && !this.readonly
? html`
<div class="suggestion-actions">
<button class="btn btn-success btn-sm" @click=${() => this.handleAcceptSuggestion(suggestion.id)}>
<dees-icon .iconFA=${'lucide:check'}></dees-icon>
Accept
</button>
<button class="btn btn-danger btn-sm" @click=${() => this.handleRejectSuggestion(suggestion.id)}>
<dees-icon .iconFA=${'lucide:x'}></dees-icon>
Reject
</button>
</div>
`
: ''}
</div>
`;
}
}

View File

@@ -0,0 +1 @@
export * from './sdig-contract-content.js';

View File

@@ -0,0 +1,920 @@
/**
* @file sdig-contract-content.ts
* @description Contract content/paragraphs editor component
*/
import {
DeesElement,
property,
html,
customElement,
type TemplateResult,
css,
cssManager,
state,
} from '@design.estate/dees-element';
import * as plugins from '../../plugins.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-contract-content': SdigContractContent;
}
}
// Paragraph type configuration
const PARAGRAPH_TYPES = [
{ value: 'section', label: 'Section', icon: 'lucide:heading' },
{ value: 'clause', label: 'Clause', icon: 'lucide:file-text' },
{ value: 'definition', label: 'Definition', icon: 'lucide:book-open' },
{ value: 'obligation', label: 'Obligation', icon: 'lucide:check-square' },
{ value: 'condition', label: 'Condition', icon: 'lucide:git-branch' },
{ value: 'schedule', label: 'Schedule', icon: 'lucide:calendar' },
];
@customElement('sdig-contract-content')
export class SdigContractContent extends DeesElement {
// ============================================================================
// STATIC
// ============================================================================
public static demo = () => html`
<sdig-contract-content
.contract=${plugins.sdDemodata.demoContract}
></sdig-contract-content>
`;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
}
.content-container {
display: flex;
flex-direction: column;
gap: 16px;
}
/* Toolbar */
.content-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 10px;
}
.toolbar-left {
display: flex;
align-items: center;
gap: 12px;
}
.toolbar-right {
display: flex;
align-items: center;
gap: 8px;
}
.search-box {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: ${cssManager.bdTheme('#f3f4f6', '#18181b')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 8px;
}
.search-box input {
border: none;
background: transparent;
font-size: 14px;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
outline: none;
width: 200px;
}
.search-box input::placeholder {
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
.search-box dees-icon {
font-size: 16px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
/* Section card */
.section-card {
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 12px;
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.section-title dees-icon {
font-size: 18px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.paragraph-count {
font-size: 13px;
font-weight: 500;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.section-content {
padding: 0;
}
/* Paragraph list */
.paragraphs-list {
display: flex;
flex-direction: column;
}
.paragraph-item {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 20px;
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
cursor: pointer;
transition: background 0.15s ease;
}
.paragraph-item:last-child {
border-bottom: none;
}
.paragraph-item:hover {
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
}
.paragraph-item.selected {
background: ${cssManager.bdTheme('#eff6ff', '#172554')};
border-color: ${cssManager.bdTheme('#bfdbfe', '#1e40af')};
}
.paragraph-item.editing {
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
}
.paragraph-drag-handle {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
cursor: grab;
flex-shrink: 0;
margin-top: 2px;
}
.paragraph-drag-handle:hover {
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.paragraph-number {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
flex-shrink: 0;
}
.paragraph-content {
flex: 1;
min-width: 0;
}
.paragraph-title-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.paragraph-title {
font-size: 16px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.paragraph-title-input {
flex: 1;
font-size: 16px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
border-radius: 6px;
padding: 8px 12px;
outline: none;
}
.paragraph-title-input:focus {
border-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(96, 165, 250, 0.1)')};
}
.paragraph-type-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.paragraph-body {
font-size: 14px;
line-height: 1.6;
color: ${cssManager.bdTheme('#4b5563', '#9ca3af')};
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.paragraph-body.expanded {
-webkit-line-clamp: unset;
overflow: visible;
}
.paragraph-body-textarea {
width: 100%;
min-height: 150px;
font-size: 14px;
line-height: 1.6;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
border-radius: 6px;
padding: 12px;
outline: none;
resize: vertical;
font-family: inherit;
}
.paragraph-body-textarea:focus {
border-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(96, 165, 250, 0.1)')};
}
.paragraph-meta {
display: flex;
align-items: center;
gap: 16px;
margin-top: 12px;
font-size: 12px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.paragraph-meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.paragraph-actions {
display: flex;
flex-direction: column;
gap: 4px;
flex-shrink: 0;
}
.paragraph-edit-actions {
display: flex;
gap: 8px;
margin-top: 16px;
}
/* Variable highlighting */
.variable {
display: inline;
padding: 2px 6px;
border-radius: 4px;
font-family: 'Roboto Mono', monospace;
font-size: 13px;
background: ${cssManager.bdTheme('#fef3c7', '#422006')};
color: ${cssManager.bdTheme('#92400e', '#fcd34d')};
}
/* Child paragraphs */
.child-paragraphs {
margin-left: 48px;
border-left: 2px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
padding-left: 16px;
}
.child-paragraphs .paragraph-item {
padding: 16px;
}
.child-paragraphs .paragraph-number {
width: 28px;
height: 28px;
font-size: 12px;
}
/* Add paragraph button */
.add-paragraph-row {
display: flex;
align-items: center;
justify-content: center;
padding: 16px 20px;
border-top: 1px dashed ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.add-paragraph-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: transparent;
border: 2px dashed ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
border-radius: 8px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.add-paragraph-btn:hover {
border-color: ${cssManager.bdTheme('#9ca3af', '#52525b')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
background: ${cssManager.bdTheme('#f9fafb', '#18181b')};
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 60px 20px;
text-align: center;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.empty-state dees-icon {
font-size: 64px;
margin-bottom: 20px;
opacity: 0.5;
}
.empty-state h4 {
margin: 0 0 8px;
font-size: 18px;
font-weight: 600;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.empty-state p {
margin: 0 0 24px;
font-size: 14px;
max-width: 400px;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-sm {
padding: 6px 10px;
font-size: 12px;
}
.btn-primary {
background: ${cssManager.bdTheme('#111111', '#fafafa')};
color: ${cssManager.bdTheme('#ffffff', '#09090b')};
}
.btn-primary:hover {
background: ${cssManager.bdTheme('#333333', '#e5e5e5')};
}
.btn-secondary {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.btn-secondary:hover {
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
}
.btn-ghost {
background: transparent;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
padding: 6px;
}
.btn-ghost:hover {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.btn-danger {
background: ${cssManager.bdTheme('#fef2f2', '#450a0a')};
color: ${cssManager.bdTheme('#dc2626', '#fca5a5')};
}
.btn-danger:hover {
background: ${cssManager.bdTheme('#fee2e2', '#7f1d1d')};
}
/* View mode toggle */
.view-toggle {
display: flex;
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
border-radius: 6px;
padding: 2px;
}
.view-toggle-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 6px 10px;
background: transparent;
border: none;
border-radius: 4px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
cursor: pointer;
transition: all 0.15s ease;
}
.view-toggle-btn.active {
background: ${cssManager.bdTheme('#ffffff', '#3f3f46')};
color: ${cssManager.bdTheme('#111111', '#fafafa')};
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.view-toggle-btn dees-icon {
font-size: 16px;
}
`,
];
// ============================================================================
// PROPERTIES
// ============================================================================
@property({ type: Object })
public accessor contract: plugins.sdInterfaces.IPortableContract | null = null;
@property({ type: Boolean })
public accessor readonly: boolean = false;
// ============================================================================
// STATE
// ============================================================================
@state()
private accessor selectedParagraphId: string | null = null;
@state()
private accessor editingParagraphId: string | null = null;
@state()
private accessor searchQuery: string = '';
@state()
private accessor viewMode: 'list' | 'outline' = 'list';
@state()
private accessor expandedParagraphs: Set<string> = new Set();
// Editing state
@state()
private accessor editTitle: string = '';
@state()
private accessor editContent: string = '';
// ============================================================================
// EVENT HANDLERS
// ============================================================================
private handleFieldChange(path: string, value: unknown) {
this.dispatchEvent(
new CustomEvent('field-change', {
detail: { path, value },
bubbles: true,
composed: true,
})
);
}
private handleSelectParagraph(paragraphId: string) {
this.selectedParagraphId = this.selectedParagraphId === paragraphId ? null : paragraphId;
this.dispatchEvent(
new CustomEvent('paragraph-select', {
detail: { paragraphId: this.selectedParagraphId },
bubbles: true,
composed: true,
})
);
}
private handleEditParagraph(paragraph: plugins.sdInterfaces.IParagraph) {
this.editingParagraphId = paragraph.uniqueId;
this.editTitle = paragraph.title;
this.editContent = paragraph.content;
}
private handleSaveEdit() {
if (!this.contract || !this.editingParagraphId) return;
const updatedParagraphs = this.contract.paragraphs.map((p) => {
if (p.uniqueId === this.editingParagraphId) {
return { ...p, title: this.editTitle, content: this.editContent };
}
return p;
});
this.handleFieldChange('paragraphs', updatedParagraphs);
this.editingParagraphId = null;
this.editTitle = '';
this.editContent = '';
}
private handleCancelEdit() {
this.editingParagraphId = null;
this.editTitle = '';
this.editContent = '';
}
private handleAddParagraph(parentId: string | null = null) {
if (!this.contract) return;
const newParagraph: plugins.sdInterfaces.IParagraph = {
uniqueId: `p-${Date.now()}`,
parent: parentId ? this.contract.paragraphs.find((p) => p.uniqueId === parentId) || null : null,
title: 'New Paragraph',
content: 'Enter paragraph content here...',
};
const updatedParagraphs = [...this.contract.paragraphs, newParagraph];
this.handleFieldChange('paragraphs', updatedParagraphs);
// Start editing the new paragraph
this.handleEditParagraph(newParagraph);
}
private handleDeleteParagraph(paragraphId: string) {
if (!this.contract) return;
// Remove the paragraph and any children
const idsToRemove = new Set<string>([paragraphId]);
// Find child paragraphs recursively
const findChildren = (parentId: string) => {
this.contract!.paragraphs.forEach((p) => {
if (p.parent?.uniqueId === parentId) {
idsToRemove.add(p.uniqueId);
findChildren(p.uniqueId);
}
});
};
findChildren(paragraphId);
const updatedParagraphs = this.contract.paragraphs.filter((p) => !idsToRemove.has(p.uniqueId));
this.handleFieldChange('paragraphs', updatedParagraphs);
if (this.selectedParagraphId === paragraphId) {
this.selectedParagraphId = null;
}
}
private handleMoveParagraph(paragraphId: string, direction: 'up' | 'down') {
if (!this.contract) return;
const paragraphs = [...this.contract.paragraphs];
const index = paragraphs.findIndex((p) => p.uniqueId === paragraphId);
if (index === -1) return;
if (direction === 'up' && index === 0) return;
if (direction === 'down' && index === paragraphs.length - 1) return;
const newIndex = direction === 'up' ? index - 1 : index + 1;
[paragraphs[index], paragraphs[newIndex]] = [paragraphs[newIndex], paragraphs[index]];
this.handleFieldChange('paragraphs', paragraphs);
}
private handleSearchChange(e: Event) {
const input = e.target as HTMLInputElement;
this.searchQuery = input.value;
}
private toggleExpanded(paragraphId: string) {
const expanded = new Set(this.expandedParagraphs);
if (expanded.has(paragraphId)) {
expanded.delete(paragraphId);
} else {
expanded.add(paragraphId);
}
this.expandedParagraphs = expanded;
}
// ============================================================================
// HELPERS
// ============================================================================
private getRootParagraphs(): plugins.sdInterfaces.IParagraph[] {
if (!this.contract) return [];
return this.contract.paragraphs.filter((p) => !p.parent);
}
private getChildParagraphs(parentId: string): plugins.sdInterfaces.IParagraph[] {
if (!this.contract) return [];
return this.contract.paragraphs.filter((p) => p.parent?.uniqueId === parentId);
}
private filterParagraphs(paragraphs: plugins.sdInterfaces.IParagraph[]): plugins.sdInterfaces.IParagraph[] {
if (!this.searchQuery) return paragraphs;
const query = this.searchQuery.toLowerCase();
return paragraphs.filter(
(p) =>
p.title.toLowerCase().includes(query) ||
p.content.toLowerCase().includes(query)
);
}
private highlightVariables(content: string): TemplateResult {
// Match {{variableName}} patterns
const parts = content.split(/(\{\{[^}]+\}\})/g);
return html`${parts.map((part) =>
part.startsWith('{{') && part.endsWith('}}')
? html`<span class="variable">${part}</span>`
: part
)}`;
}
private getParagraphNumber(paragraph: plugins.sdInterfaces.IParagraph, index: number): string {
// Simple numbering - can be enhanced for hierarchical numbering
return String(index + 1);
}
// ============================================================================
// RENDER
// ============================================================================
public render(): TemplateResult {
if (!this.contract) {
return html`<div>No contract loaded</div>`;
}
const rootParagraphs = this.getRootParagraphs();
const filteredParagraphs = this.filterParagraphs(rootParagraphs);
return html`
<div class="content-container">
<!-- Toolbar -->
<div class="content-toolbar">
<div class="toolbar-left">
<div class="search-box">
<dees-icon .iconFA=${'lucide:search'}></dees-icon>
<input
type="text"
placeholder="Search paragraphs..."
.value=${this.searchQuery}
@input=${this.handleSearchChange}
/>
</div>
</div>
<div class="toolbar-right">
<div class="view-toggle">
<button
class="view-toggle-btn ${this.viewMode === 'list' ? 'active' : ''}"
@click=${() => (this.viewMode = 'list')}
title="List view"
>
<dees-icon .iconFA=${'lucide:list'}></dees-icon>
</button>
<button
class="view-toggle-btn ${this.viewMode === 'outline' ? 'active' : ''}"
@click=${() => (this.viewMode = 'outline')}
title="Outline view"
>
<dees-icon .iconFA=${'lucide:layout-list'}></dees-icon>
</button>
</div>
${!this.readonly
? html`
<button class="btn btn-primary" @click=${() => this.handleAddParagraph()}>
<dees-icon .iconFA=${'lucide:plus'}></dees-icon>
Add Paragraph
</button>
`
: ''}
</div>
</div>
<!-- Content Section -->
<div class="section-card">
<div class="section-header">
<div class="section-title">
<dees-icon .iconFA=${'lucide:file-text'}></dees-icon>
Contract Content
</div>
<span class="paragraph-count">${this.contract.paragraphs.length} paragraphs</span>
</div>
<div class="section-content">
${filteredParagraphs.length > 0
? html`
<div class="paragraphs-list">
${filteredParagraphs.map((paragraph, index) =>
this.renderParagraph(paragraph, index)
)}
</div>
${!this.readonly
? html`
<div class="add-paragraph-row">
<button class="add-paragraph-btn" @click=${() => this.handleAddParagraph()}>
<dees-icon .iconFA=${'lucide:plus'}></dees-icon>
Add paragraph
</button>
</div>
`
: ''}
`
: html`
<div class="empty-state">
<dees-icon .iconFA=${'lucide:file-plus'}></dees-icon>
<h4>No Paragraphs Yet</h4>
<p>Start building your contract by adding paragraphs. Each paragraph can contain clauses, definitions, or obligations.</p>
${!this.readonly
? html`
<button class="btn btn-primary" @click=${() => this.handleAddParagraph()}>
<dees-icon .iconFA=${'lucide:plus'}></dees-icon>
Add First Paragraph
</button>
`
: ''}
</div>
`}
</div>
</div>
</div>
`;
}
private renderParagraph(paragraph: plugins.sdInterfaces.IParagraph, index: number): TemplateResult {
const isSelected = this.selectedParagraphId === paragraph.uniqueId;
const isEditing = this.editingParagraphId === paragraph.uniqueId;
const isExpanded = this.expandedParagraphs.has(paragraph.uniqueId);
const childParagraphs = this.getChildParagraphs(paragraph.uniqueId);
return html`
<div
class="paragraph-item ${isSelected ? 'selected' : ''} ${isEditing ? 'editing' : ''}"
@click=${() => !isEditing && this.handleSelectParagraph(paragraph.uniqueId)}
>
${!this.readonly
? html`
<div class="paragraph-drag-handle">
<dees-icon .iconFA=${'lucide:grip-vertical'}></dees-icon>
</div>
`
: ''}
<div class="paragraph-number">${this.getParagraphNumber(paragraph, index)}</div>
<div class="paragraph-content">
${isEditing
? html`
<input
type="text"
class="paragraph-title-input"
.value=${this.editTitle}
@input=${(e: Event) => (this.editTitle = (e.target as HTMLInputElement).value)}
@click=${(e: Event) => e.stopPropagation()}
placeholder="Paragraph title"
/>
<textarea
class="paragraph-body-textarea"
.value=${this.editContent}
@input=${(e: Event) => (this.editContent = (e.target as HTMLTextAreaElement).value)}
@click=${(e: Event) => e.stopPropagation()}
placeholder="Paragraph content..."
></textarea>
<div class="paragraph-edit-actions">
<button class="btn btn-primary" @click=${(e: Event) => { e.stopPropagation(); this.handleSaveEdit(); }}>
Save
</button>
<button class="btn btn-secondary" @click=${(e: Event) => { e.stopPropagation(); this.handleCancelEdit(); }}>
Cancel
</button>
</div>
`
: html`
<div class="paragraph-title-row">
<span class="paragraph-title">${paragraph.title}</span>
</div>
<div class="paragraph-body ${isExpanded ? 'expanded' : ''}">${this.highlightVariables(paragraph.content)}</div>
${paragraph.content.length > 200
? html`
<button
class="btn btn-ghost btn-sm"
@click=${(e: Event) => { e.stopPropagation(); this.toggleExpanded(paragraph.uniqueId); }}
style="margin-top: 8px;"
>
${isExpanded ? 'Show less' : 'Show more'}
</button>
`
: ''}
`}
</div>
${!this.readonly && !isEditing
? html`
<div class="paragraph-actions">
<button
class="btn btn-ghost"
@click=${(e: Event) => { e.stopPropagation(); this.handleEditParagraph(paragraph); }}
title="Edit"
>
<dees-icon .iconFA=${'lucide:pencil'}></dees-icon>
</button>
<button
class="btn btn-ghost"
@click=${(e: Event) => { e.stopPropagation(); this.handleMoveParagraph(paragraph.uniqueId, 'up'); }}
title="Move up"
>
<dees-icon .iconFA=${'lucide:chevron-up'}></dees-icon>
</button>
<button
class="btn btn-ghost"
@click=${(e: Event) => { e.stopPropagation(); this.handleMoveParagraph(paragraph.uniqueId, 'down'); }}
title="Move down"
>
<dees-icon .iconFA=${'lucide:chevron-down'}></dees-icon>
</button>
<button
class="btn btn-ghost btn-danger"
@click=${(e: Event) => { e.stopPropagation(); this.handleDeleteParagraph(paragraph.uniqueId); }}
title="Delete"
>
<dees-icon .iconFA=${'lucide:trash-2'}></dees-icon>
</button>
</div>
`
: ''}
</div>
${childParagraphs.length > 0
? html`
<div class="child-paragraphs">
${childParagraphs.map((child, childIndex) => this.renderParagraph(child, childIndex))}
</div>
`
: ''}
`;
}
}

View File

@@ -0,0 +1 @@
export * from './sdig-contract-header.js';

View File

@@ -0,0 +1,558 @@
/**
* @file sdig-contract-header.ts
* @description Contract header component with title, status, and quick actions
*/
import {
DeesElement,
property,
html,
customElement,
type TemplateResult,
css,
cssManager,
state,
} from '@design.estate/dees-element';
import * as plugins from '../../plugins.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-contract-header': SdigContractHeader;
}
}
@customElement('sdig-contract-header')
export class SdigContractHeader extends DeesElement {
// ============================================================================
// STATIC
// ============================================================================
public static demo = () => html`
<sdig-contract-header
.contract=${plugins.sdDemodata.demoContract}
></sdig-contract-header>
`;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
}
.header-card {
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 12px;
padding: 24px;
}
.header-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 20px;
}
.title-section {
flex: 1;
}
.contract-number {
font-size: 13px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
margin-bottom: 8px;
}
.title-input-wrapper {
position: relative;
}
.title-input {
width: 100%;
font-size: 24px;
font-weight: 700;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
background: transparent;
border: none;
padding: 0;
outline: none;
border-bottom: 2px solid transparent;
transition: border-color 0.15s ease;
}
.title-input:focus {
border-bottom-color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.title-input::placeholder {
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
.status-section {
display: flex;
align-items: center;
gap: 12px;
}
/* shadcn-style badge */
.status-badge {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
border: 1px solid transparent;
line-height: 1.4;
}
.status-badge:hover:not(:disabled) {
filter: brightness(0.95);
}
.status-badge:disabled {
cursor: default;
}
.status-badge.draft {
background: ${cssManager.bdTheme('hsl(48 96% 89%)', 'hsl(48 96% 15%)')};
color: ${cssManager.bdTheme('hsl(25 95% 30%)', 'hsl(48 96% 70%)')};
border-color: ${cssManager.bdTheme('hsl(48 96% 76%)', 'hsl(48 96% 25%)')};
}
.status-badge.review {
background: ${cssManager.bdTheme('hsl(214 95% 93%)', 'hsl(214 95% 15%)')};
color: ${cssManager.bdTheme('hsl(214 95% 35%)', 'hsl(214 95% 70%)')};
border-color: ${cssManager.bdTheme('hsl(214 95% 80%)', 'hsl(214 95% 25%)')};
}
.status-badge.pending {
background: ${cssManager.bdTheme('hsl(38 92% 90%)', 'hsl(38 92% 15%)')};
color: ${cssManager.bdTheme('hsl(25 95% 35%)', 'hsl(38 92% 65%)')};
border-color: ${cssManager.bdTheme('hsl(38 92% 75%)', 'hsl(38 92% 25%)')};
}
.status-badge.signed,
.status-badge.active {
background: ${cssManager.bdTheme('hsl(142 76% 90%)', 'hsl(142 76% 15%)')};
color: ${cssManager.bdTheme('hsl(142 76% 28%)', 'hsl(142 76% 65%)')};
border-color: ${cssManager.bdTheme('hsl(142 76% 75%)', 'hsl(142 76% 25%)')};
}
.status-badge.terminated {
background: ${cssManager.bdTheme('hsl(0 84% 92%)', 'hsl(0 84% 15%)')};
color: ${cssManager.bdTheme('hsl(0 84% 35%)', 'hsl(0 84% 65%)')};
border-color: ${cssManager.bdTheme('hsl(0 84% 80%)', 'hsl(0 84% 25%)')};
}
.status-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
opacity: 0.8;
}
/* Meta info row */
.meta-row {
display: flex;
flex-wrap: wrap;
gap: 24px;
padding-top: 20px;
border-top: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.meta-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.meta-label {
font-size: 12px;
font-weight: 500;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
text-transform: uppercase;
letter-spacing: 0.05em;
}
.meta-value {
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.meta-value.clickable {
cursor: pointer;
color: ${cssManager.bdTheme('#2563eb', '#60a5fa')};
}
.meta-value.clickable:hover {
text-decoration: underline;
}
/* Tags */
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
/* Quick actions */
.quick-actions {
display: flex;
gap: 8px;
}
.action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
cursor: pointer;
transition: all 0.15s ease;
}
.action-btn:hover {
background: ${cssManager.bdTheme('#f3f4f6', '#18181b')};
color: ${cssManager.bdTheme('#111111', '#fafafa')};
border-color: ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
}
.action-btn dees-icon {
font-size: 16px;
}
/* Status dropdown */
.status-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
min-width: 200px;
background: ${cssManager.bdTheme('#ffffff', '#18181b')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 100;
overflow: hidden;
}
.status-option {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
font-size: 14px;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
cursor: pointer;
transition: background 0.1s ease;
}
.status-option:hover {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
}
.status-option.selected {
background: ${cssManager.bdTheme('#eff6ff', '#1e3a5f')};
color: ${cssManager.bdTheme('#1e40af', '#93c5fd')};
}
`,
];
// ============================================================================
// PROPERTIES
// ============================================================================
@property({ type: Object })
public accessor contract: plugins.sdInterfaces.IPortableContract | null = null;
@property({ type: Boolean })
public accessor readonly: boolean = false;
// ============================================================================
// STATE
// ============================================================================
@state()
private accessor showStatusDropdown: boolean = false;
@state()
private accessor editingTitle: boolean = false;
// ============================================================================
// STATUS CONFIGURATION
// ============================================================================
private statusOptions: Array<{
value: plugins.sdInterfaces.TContractStatus;
label: string;
category: string;
}> = [
{ value: 'draft', label: 'Draft', category: 'draft' },
{ value: 'internal_review', label: 'Internal Review', category: 'review' },
{ value: 'legal_review', label: 'Legal Review', category: 'review' },
{ value: 'negotiation', label: 'Negotiation', category: 'review' },
{ value: 'pending_approval', label: 'Pending Approval', category: 'pending' },
{ value: 'pending_signature', label: 'Pending Signature', category: 'pending' },
{ value: 'partially_signed', label: 'Partially Signed', category: 'pending' },
{ value: 'signed', label: 'Signed', category: 'signed' },
{ value: 'executed', label: 'Executed', category: 'signed' },
{ value: 'active', label: 'Active', category: 'active' },
{ value: 'expired', label: 'Expired', category: 'terminated' },
{ value: 'terminated', label: 'Terminated', category: 'terminated' },
{ value: 'cancelled', label: 'Cancelled', category: 'terminated' },
{ value: 'voided', label: 'Voided', category: 'terminated' },
];
// ============================================================================
// EVENT HANDLERS
// ============================================================================
private handleTitleChange(e: Event) {
const input = e.target as HTMLInputElement;
this.dispatchEvent(
new CustomEvent('field-change', {
detail: { path: 'title', value: input.value },
bubbles: true,
composed: true,
})
);
}
private handleStatusChange(status: plugins.sdInterfaces.TContractStatus) {
this.showStatusDropdown = false;
this.dispatchEvent(
new CustomEvent('field-change', {
detail: { path: 'lifecycle.currentStatus', value: status },
bubbles: true,
composed: true,
})
);
}
private toggleStatusDropdown() {
if (!this.readonly) {
this.showStatusDropdown = !this.showStatusDropdown;
}
}
private handleExport() {
this.dispatchEvent(
new CustomEvent('action', {
detail: { action: 'export' },
bubbles: true,
composed: true,
})
);
}
private handleDuplicate() {
this.dispatchEvent(
new CustomEvent('action', {
detail: { action: 'duplicate' },
bubbles: true,
composed: true,
})
);
}
private handleShare() {
this.dispatchEvent(
new CustomEvent('action', {
detail: { action: 'share' },
bubbles: true,
composed: true,
})
);
}
// ============================================================================
// HELPERS
// ============================================================================
private getStatusCategory(status: string): string {
const option = this.statusOptions.find((o) => o.value === status);
return option?.category || 'draft';
}
private formatStatus(status: string): string {
const option = this.statusOptions.find((o) => o.value === status);
return option?.label || status.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
private formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
private formatContractType(type: string): string {
return type.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
// ============================================================================
// RENDER
// ============================================================================
public render(): TemplateResult {
if (!this.contract) {
return html`<div class="header-card">No contract loaded</div>`;
}
const status = this.contract.lifecycle?.currentStatus || 'draft';
const statusCategory = this.getStatusCategory(status);
return html`
<div class="header-card">
<div class="header-top">
<div class="title-section">
${this.contract.metadata?.contractNumber
? html`<div class="contract-number">#${this.contract.metadata.contractNumber}</div>`
: ''}
<div class="title-input-wrapper">
<input
type="text"
class="title-input"
.value=${this.contract.title}
placeholder="Contract Title"
?disabled=${this.readonly}
@input=${this.handleTitleChange}
/>
</div>
</div>
<div class="status-section">
<div style="position: relative;">
<button
class="status-badge ${statusCategory}"
@click=${this.toggleStatusDropdown}
?disabled=${this.readonly}
>
<span class="status-dot"></span>
${this.formatStatus(status)}
${!this.readonly
? html`<dees-icon .iconFA=${'lucide:chevron-down'} style="font-size: 14px;"></dees-icon>`
: ''}
</button>
${this.showStatusDropdown
? html`
<div class="status-dropdown">
${this.statusOptions.map(
(option) => html`
<div
class="status-option ${status === option.value ? 'selected' : ''}"
@click=${() => this.handleStatusChange(option.value)}
>
<span
class="status-dot"
style="background: ${option.category === 'draft'
? '#f59e0b'
: option.category === 'review'
? '#3b82f6'
: option.category === 'pending'
? '#f59e0b'
: option.category === 'signed' || option.category === 'active'
? '#10b981'
: '#ef4444'}"
></span>
${option.label}
</div>
`
)}
</div>
`
: ''}
</div>
<div class="quick-actions">
<button class="action-btn" @click=${this.handleExport} title="Export">
<dees-icon .iconFA=${'lucide:download'}></dees-icon>
</button>
<button class="action-btn" @click=${this.handleDuplicate} title="Duplicate">
<dees-icon .iconFA=${'lucide:copy'}></dees-icon>
</button>
<button class="action-btn" @click=${this.handleShare} title="Share">
<dees-icon .iconFA=${'lucide:share-2'}></dees-icon>
</button>
</div>
</div>
</div>
${this.contract.metadata ? html`
<div class="meta-row">
<div class="meta-item">
<span class="meta-label">Type</span>
<span class="meta-value">${this.formatContractType(this.contract.metadata.contractType)}</span>
</div>
<div class="meta-item">
<span class="meta-label">Category</span>
<span class="meta-value">${this.formatContractType(this.contract.metadata.category)}</span>
</div>
<div class="meta-item">
<span class="meta-label">Language</span>
<span class="meta-value">${this.contract.metadata.language?.toUpperCase() || 'N/A'}</span>
</div>
<div class="meta-item">
<span class="meta-label">Jurisdiction</span>
<span class="meta-value">
${this.contract.metadata.governingLaw?.country || 'Not specified'}
${this.contract.metadata.governingLaw?.state
? `, ${this.contract.metadata.governingLaw.state}`
: ''}
</span>
</div>
<div class="meta-item">
<span class="meta-label">Created</span>
<span class="meta-value">${this.formatDate(this.contract.createdAt)}</span>
</div>
<div class="meta-item">
<span class="meta-label">Parties</span>
<span class="meta-value clickable">${this.contract.involvedParties?.length || 0} parties</span>
</div>
${this.contract.metadata.tags?.length > 0
? html`
<div class="meta-item">
<span class="meta-label">Tags</span>
<div class="tags-container">
${this.contract.metadata.tags.map((tag) => html`<span class="tag">${tag}</span>`)}
</div>
</div>
`
: ''}
</div>
` : ''}
</div>
`;
}
}

View File

@@ -0,0 +1 @@
export * from './sdig-contract-metadata.js';

View File

@@ -0,0 +1,820 @@
/**
* @file sdig-contract-metadata.ts
* @description Contract metadata editor component
*/
import {
DeesElement,
property,
html,
customElement,
type TemplateResult,
css,
cssManager,
state,
} from '@design.estate/dees-element';
import * as plugins from '../../plugins.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-contract-metadata': SdigContractMetadata;
}
}
// Type-safe options arrays
const CONTRACT_CATEGORIES: Array<{ value: plugins.sdInterfaces.TContractCategory; label: string }> = [
{ value: 'employment', label: 'Employment' },
{ value: 'service', label: 'Service Agreement' },
{ value: 'sales', label: 'Sales' },
{ value: 'lease', label: 'Lease / Rental' },
{ value: 'license', label: 'License' },
{ value: 'partnership', label: 'Partnership' },
{ value: 'confidentiality', label: 'Confidentiality / NDA' },
{ value: 'financial', label: 'Financial' },
{ value: 'real_estate', label: 'Real Estate' },
{ value: 'intellectual_property', label: 'Intellectual Property' },
{ value: 'government', label: 'Government' },
{ value: 'construction', label: 'Construction' },
{ value: 'healthcare', label: 'Healthcare' },
{ value: 'insurance', label: 'Insurance' },
{ value: 'other', label: 'Other' },
];
const CONFIDENTIALITY_LEVELS: Array<{ value: plugins.sdInterfaces.TConfidentialityLevel; label: string }> = [
{ value: 'public', label: 'Public' },
{ value: 'internal', label: 'Internal' },
{ value: 'confidential', label: 'Confidential' },
{ value: 'restricted', label: 'Restricted' },
];
const DISPUTE_RESOLUTIONS: Array<{ value: plugins.sdInterfaces.TDisputeResolution; label: string }> = [
{ value: 'litigation', label: 'Litigation' },
{ value: 'arbitration', label: 'Arbitration' },
{ value: 'mediation', label: 'Mediation' },
{ value: 'negotiation', label: 'Negotiation' },
];
const COMMON_LANGUAGES = [
{ value: 'en', label: 'English' },
{ value: 'de', label: 'German' },
{ value: 'fr', label: 'French' },
{ value: 'es', label: 'Spanish' },
{ value: 'it', label: 'Italian' },
{ value: 'pt', label: 'Portuguese' },
{ value: 'nl', label: 'Dutch' },
{ value: 'pl', label: 'Polish' },
{ value: 'sv', label: 'Swedish' },
{ value: 'da', label: 'Danish' },
{ value: 'fi', label: 'Finnish' },
{ value: 'no', label: 'Norwegian' },
{ value: 'zh', label: 'Chinese' },
{ value: 'ja', label: 'Japanese' },
{ value: 'ko', label: 'Korean' },
{ value: 'ar', label: 'Arabic' },
{ value: 'ru', label: 'Russian' },
];
@customElement('sdig-contract-metadata')
export class SdigContractMetadata extends DeesElement {
// ============================================================================
// STATIC
// ============================================================================
public static demo = () => html`
<sdig-contract-metadata
.contract=${plugins.sdDemodata.demoContract}
></sdig-contract-metadata>
`;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
}
.metadata-container {
display: flex;
flex-direction: column;
gap: 24px;
}
/* Section cards */
.section-card {
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 12px;
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.section-title dees-icon {
font-size: 18px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.section-content {
padding: 20px;
}
/* Form grid */
.form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-group.full-width {
grid-column: 1 / -1;
}
.form-label {
font-size: 13px;
font-weight: 500;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.form-label .required {
color: #ef4444;
margin-left: 2px;
}
.form-description {
font-size: 12px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
margin-top: 4px;
}
/* Input styles */
.form-input,
.form-select,
.form-textarea {
width: 100%;
padding: 10px 12px;
font-size: 14px;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
background: ${cssManager.bdTheme('#ffffff', '#18181b')};
border: 1px solid ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
border-radius: 8px;
outline: none;
transition: all 0.15s ease;
}
.form-input:focus,
.form-select:focus,
.form-textarea:focus {
border-color: ${cssManager.bdTheme('#111111', '#fafafa')};
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(0,0,0,0.05)', 'rgba(255,255,255,0.05)')};
}
.form-input::placeholder,
.form-textarea::placeholder {
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
.form-input:disabled,
.form-select:disabled,
.form-textarea:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.form-select {
appearance: none;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-position: right 10px center;
background-repeat: no-repeat;
background-size: 16px;
padding-right: 36px;
}
.form-textarea {
min-height: 80px;
resize: vertical;
}
/* Radio group */
.radio-group {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.radio-option {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.radio-option input[type="radio"] {
width: 18px;
height: 18px;
accent-color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.radio-option span {
font-size: 14px;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
/* Tags input */
.tags-input-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 8px 12px;
min-height: 44px;
background: ${cssManager.bdTheme('#ffffff', '#18181b')};
border: 1px solid ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
border-radius: 8px;
cursor: text;
}
.tags-input-container:focus-within {
border-color: ${cssManager.bdTheme('#111111', '#fafafa')};
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(0,0,0,0.05)', 'rgba(255,255,255,0.05)')};
}
.tag-item {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
border-radius: 6px;
font-size: 13px;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.tag-remove {
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 50%;
background: transparent;
border: none;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
cursor: pointer;
font-size: 14px;
line-height: 1;
}
.tag-remove:hover {
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.tags-input {
flex: 1;
min-width: 120px;
border: none;
background: transparent;
padding: 4px 0;
font-size: 14px;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
outline: none;
}
.tags-input::placeholder {
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
/* Divider */
.section-divider {
border-top: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
margin: 20px 0;
}
/* Collapsible sections */
.collapsible-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
cursor: pointer;
user-select: none;
}
.collapsible-header:hover .collapse-icon {
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.collapse-icon {
font-size: 16px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
transition: transform 0.2s ease;
}
.collapse-icon.expanded {
transform: rotate(180deg);
}
.collapsible-content {
overflow: hidden;
max-height: 0;
opacity: 0;
transition: all 0.3s ease;
}
.collapsible-content.expanded {
max-height: 1000px;
opacity: 1;
}
`,
];
// ============================================================================
// PROPERTIES
// ============================================================================
@property({ type: Object })
public accessor contract: plugins.sdInterfaces.IPortableContract | null = null;
@property({ type: Boolean })
public accessor readonly: boolean = false;
// ============================================================================
// STATE
// ============================================================================
@state()
private accessor showArbitrationFields: boolean = false;
@state()
private accessor newTag: string = '';
// ============================================================================
// LIFECYCLE
// ============================================================================
public updated(changedProperties: Map<string, unknown>) {
if (changedProperties.has('contract') && this.contract?.metadata?.governingLaw) {
this.showArbitrationFields = this.contract.metadata.governingLaw.disputeResolution === 'arbitration';
}
}
// ============================================================================
// EVENT HANDLERS
// ============================================================================
private handleFieldChange(path: string, value: unknown) {
this.dispatchEvent(
new CustomEvent('field-change', {
detail: { path, value },
bubbles: true,
composed: true,
})
);
}
private handleInputChange(path: string, e: Event) {
const input = e.target as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
this.handleFieldChange(path, input.value);
}
private handleRadioChange(path: string, value: string) {
this.handleFieldChange(path, value);
// Special handling for dispute resolution
if (path === 'metadata.governingLaw.disputeResolution') {
this.showArbitrationFields = value === 'arbitration';
}
}
private handleTagAdd(e: KeyboardEvent) {
if (e.key === 'Enter' && this.newTag.trim()) {
e.preventDefault();
const currentTags = this.contract?.metadata.tags || [];
if (!currentTags.includes(this.newTag.trim())) {
this.handleFieldChange('metadata.tags', [...currentTags, this.newTag.trim()]);
}
this.newTag = '';
}
}
private handleTagRemove(tag: string) {
const currentTags = this.contract?.metadata.tags || [];
this.handleFieldChange(
'metadata.tags',
currentTags.filter((t) => t !== tag)
);
}
// ============================================================================
// RENDER
// ============================================================================
public render(): TemplateResult {
if (!this.contract) {
return html`<div>No contract loaded</div>`;
}
if (!this.contract.metadata) {
return html`<div class="metadata-container"><div class="section-card">Contract metadata not available</div></div>`;
}
const metadata = this.contract.metadata;
return html`
<div class="metadata-container">
<!-- Basic Information -->
<div class="section-card">
<div class="section-header">
<div class="section-title">
<dees-icon .iconFA=${'lucide:info'}></dees-icon>
Basic Information
</div>
</div>
<div class="section-content">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Contract Number</label>
<input
type="text"
class="form-input"
.value=${metadata.contractNumber || ''}
placeholder="e.g., CNT-2024-001"
?disabled=${this.readonly}
@input=${(e: Event) => this.handleInputChange('metadata.contractNumber', e)}
/>
</div>
<div class="form-group">
<label class="form-label">Category <span class="required">*</span></label>
<select
class="form-select"
.value=${metadata.category}
?disabled=${this.readonly}
@change=${(e: Event) => this.handleInputChange('metadata.category', e)}
>
${CONTRACT_CATEGORIES.map(
(cat) => html`
<option value=${cat.value} ?selected=${metadata.category === cat.value}>
${cat.label}
</option>
`
)}
</select>
</div>
<div class="form-group">
<label class="form-label">Contract Type <span class="required">*</span></label>
<input
type="text"
class="form-input"
.value=${metadata.contractType}
placeholder="e.g., employment_minijob"
?disabled=${this.readonly}
@input=${(e: Event) => this.handleInputChange('metadata.contractType', e)}
/>
<p class="form-description">Specific contract type identifier</p>
</div>
<div class="form-group">
<label class="form-label">Confidentiality Level</label>
<div class="radio-group">
${CONFIDENTIALITY_LEVELS.map(
(level) => html`
<label class="radio-option">
<input
type="radio"
name="confidentiality"
.value=${level.value}
?checked=${metadata.confidentialityLevel === level.value}
?disabled=${this.readonly}
@change=${() => this.handleRadioChange('metadata.confidentialityLevel', level.value)}
/>
<span>${level.label}</span>
</label>
`
)}
</div>
</div>
<div class="form-group full-width">
<label class="form-label">Tags</label>
<div class="tags-input-container">
${metadata.tags.map(
(tag) => html`
<span class="tag-item">
${tag}
${!this.readonly
? html`
<button class="tag-remove" @click=${() => this.handleTagRemove(tag)}>×</button>
`
: ''}
</span>
`
)}
${!this.readonly
? html`
<input
type="text"
class="tags-input"
.value=${this.newTag}
placeholder="Add tag and press Enter"
@input=${(e: Event) => (this.newTag = (e.target as HTMLInputElement).value)}
@keydown=${this.handleTagAdd}
/>
`
: ''}
</div>
</div>
</div>
</div>
</div>
<!-- Language Settings -->
<div class="section-card">
<div class="section-header">
<div class="section-title">
<dees-icon .iconFA=${'lucide:globe'}></dees-icon>
Language Settings
</div>
</div>
<div class="section-content">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Primary Language <span class="required">*</span></label>
<select
class="form-select"
.value=${metadata.language}
?disabled=${this.readonly}
@change=${(e: Event) => this.handleInputChange('metadata.language', e)}
>
${COMMON_LANGUAGES.map(
(lang) => html`
<option value=${lang.value} ?selected=${metadata.language === lang.value}>
${lang.label}
</option>
`
)}
</select>
</div>
<div class="form-group">
<label class="form-label">Binding Language</label>
<select
class="form-select"
.value=${metadata.bindingLanguage || ''}
?disabled=${this.readonly}
@change=${(e: Event) => this.handleInputChange('metadata.bindingLanguage', e)}
>
<option value="">Same as primary</option>
${COMMON_LANGUAGES.map(
(lang) => html`
<option value=${lang.value} ?selected=${metadata.bindingLanguage === lang.value}>
${lang.label}
</option>
`
)}
</select>
<p class="form-description">Language that takes precedence in case of conflicts</p>
</div>
</div>
</div>
</div>
<!-- Governing Law -->
<div class="section-card">
<div class="section-header">
<div class="section-title">
<dees-icon .iconFA=${'lucide:scale'}></dees-icon>
Governing Law & Jurisdiction
</div>
</div>
<div class="section-content">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Country <span class="required">*</span></label>
<input
type="text"
class="form-input"
.value=${metadata.governingLaw.country}
placeholder="e.g., Germany"
?disabled=${this.readonly}
@input=${(e: Event) => this.handleInputChange('metadata.governingLaw.country', e)}
/>
</div>
<div class="form-group">
<label class="form-label">State / Province</label>
<input
type="text"
class="form-input"
.value=${metadata.governingLaw.state || ''}
placeholder="e.g., Bavaria"
?disabled=${this.readonly}
@input=${(e: Event) => this.handleInputChange('metadata.governingLaw.state', e)}
/>
</div>
<div class="form-group">
<label class="form-label">Dispute Jurisdiction</label>
<input
type="text"
class="form-input"
.value=${metadata.governingLaw.disputeJurisdiction || ''}
placeholder="e.g., Munich Courts"
?disabled=${this.readonly}
@input=${(e: Event) => this.handleInputChange('metadata.governingLaw.disputeJurisdiction', e)}
/>
</div>
<div class="form-group full-width">
<label class="form-label">Dispute Resolution Method</label>
<div class="radio-group">
${DISPUTE_RESOLUTIONS.map(
(res) => html`
<label class="radio-option">
<input
type="radio"
name="disputeResolution"
.value=${res.value}
?checked=${metadata.governingLaw.disputeResolution === res.value}
?disabled=${this.readonly}
@change=${() => this.handleRadioChange('metadata.governingLaw.disputeResolution', res.value)}
/>
<span>${res.label}</span>
</label>
`
)}
</div>
</div>
${this.showArbitrationFields
? html`
<div class="section-divider full-width"></div>
<div class="form-group">
<label class="form-label">Arbitration Institution</label>
<input
type="text"
class="form-input"
.value=${metadata.governingLaw.arbitrationInstitution || ''}
placeholder="e.g., ICC, LCIA, AAA"
?disabled=${this.readonly}
@input=${(e: Event) => this.handleInputChange('metadata.governingLaw.arbitrationInstitution', e)}
/>
</div>
<div class="form-group">
<label class="form-label">Arbitration Rules</label>
<input
type="text"
class="form-input"
.value=${metadata.governingLaw.arbitrationRules || ''}
placeholder="e.g., ICC Rules of Arbitration"
?disabled=${this.readonly}
@input=${(e: Event) => this.handleInputChange('metadata.governingLaw.arbitrationRules', e)}
/>
</div>
<div class="form-group">
<label class="form-label">Seat of Arbitration</label>
<input
type="text"
class="form-input"
.value=${metadata.governingLaw.arbitrationSeat || ''}
placeholder="e.g., Paris, London"
?disabled=${this.readonly}
@input=${(e: Event) => this.handleInputChange('metadata.governingLaw.arbitrationSeat', e)}
/>
</div>
<div class="form-group">
<label class="form-label">Number of Arbitrators</label>
<select
class="form-select"
.value=${String(metadata.governingLaw.numberOfArbitrators || 1)}
?disabled=${this.readonly}
@change=${(e: Event) =>
this.handleFieldChange(
'metadata.governingLaw.numberOfArbitrators',
parseInt((e.target as HTMLSelectElement).value, 10)
)}
>
<option value="1">1 Arbitrator</option>
<option value="3">3 Arbitrators</option>
<option value="5">5 Arbitrators</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Proceedings Language</label>
<select
class="form-select"
.value=${metadata.governingLaw.proceedingsLanguage || ''}
?disabled=${this.readonly}
@change=${(e: Event) => this.handleInputChange('metadata.governingLaw.proceedingsLanguage', e)}
>
<option value="">Select language</option>
${COMMON_LANGUAGES.map(
(lang) => html`
<option
value=${lang.value}
?selected=${metadata.governingLaw.proceedingsLanguage === lang.value}
>
${lang.label}
</option>
`
)}
</select>
</div>
`
: ''}
</div>
</div>
</div>
<!-- References -->
<div class="section-card">
<div class="section-header">
<div class="section-title">
<dees-icon .iconFA=${'lucide:link'}></dees-icon>
References & Integration
</div>
</div>
<div class="section-content">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Internal Reference</label>
<input
type="text"
class="form-input"
.value=${metadata.internalReference || ''}
placeholder="Internal tracking reference"
?disabled=${this.readonly}
@input=${(e: Event) => this.handleInputChange('metadata.internalReference', e)}
/>
</div>
<div class="form-group">
<label class="form-label">External Reference</label>
<input
type="text"
class="form-input"
.value=${metadata.externalReference || ''}
placeholder="External system reference"
?disabled=${this.readonly}
@input=${(e: Event) => this.handleInputChange('metadata.externalReference', e)}
/>
</div>
<div class="form-group">
<label class="form-label">Template ID</label>
<input
type="text"
class="form-input"
.value=${metadata.templateId || ''}
placeholder="Source template ID"
?disabled=${this.readonly}
@input=${(e: Event) => this.handleInputChange('metadata.templateId', e)}
/>
</div>
<div class="form-group">
<label class="form-label">Parent Contract ID</label>
<input
type="text"
class="form-input"
.value=${metadata.parentContractId || ''}
placeholder="Parent/master contract"
?disabled=${this.readonly}
@input=${(e: Event) => this.handleInputChange('metadata.parentContractId', e)}
/>
</div>
</div>
</div>
</div>
</div>
`;
}
}

View File

@@ -0,0 +1 @@
export * from './sdig-contract-parties.js';

View File

@@ -0,0 +1,736 @@
/**
* @file sdig-contract-parties.ts
* @description Contract parties and roles management component
*/
import {
DeesElement,
property,
html,
customElement,
type TemplateResult,
css,
cssManager,
state,
} from '@design.estate/dees-element';
import * as plugins from '../../plugins.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-contract-parties': SdigContractParties;
}
}
// Party role display configuration
const PARTY_ROLES: Array<{ value: plugins.sdInterfaces.TPartyRole; label: string; icon: string }> = [
{ value: 'signer', label: 'Signer', icon: 'lucide:pen-tool' },
{ value: 'witness', label: 'Witness', icon: 'lucide:eye' },
{ value: 'notary', label: 'Notary', icon: 'lucide:stamp' },
{ value: 'cc', label: 'CC (Copy)', icon: 'lucide:mail' },
{ value: 'approver', label: 'Approver', icon: 'lucide:check-circle' },
{ value: 'guarantor', label: 'Guarantor', icon: 'lucide:shield' },
{ value: 'beneficiary', label: 'Beneficiary', icon: 'lucide:user-check' },
];
const SIGNING_DEPENDENCIES: Array<{ value: plugins.sdInterfaces.TSigningDependency; label: string }> = [
{ value: 'none', label: 'No dependency' },
{ value: 'sequential', label: 'Sequential (in order)' },
{ value: 'parallel', label: 'Parallel (any order)' },
{ value: 'after_specific', label: 'After specific parties' },
];
@customElement('sdig-contract-parties')
export class SdigContractParties extends DeesElement {
// ============================================================================
// STATIC
// ============================================================================
public static demo = () => html`
<sdig-contract-parties
.contract=${plugins.sdDemodata.demoContract}
></sdig-contract-parties>
`;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
}
.parties-container {
display: flex;
flex-direction: column;
gap: 24px;
}
/* Section cards */
.section-card {
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 12px;
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.section-title dees-icon {
font-size: 18px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.section-content {
padding: 20px;
}
/* Roles list */
.roles-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.role-card {
display: flex;
flex-direction: column;
padding: 16px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 10px;
transition: all 0.15s ease;
}
.role-card:hover {
border-color: ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
}
.role-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.role-name {
display: flex;
align-items: center;
gap: 8px;
font-size: 15px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.role-name dees-icon {
font-size: 16px;
padding: 6px;
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
border-radius: 6px;
}
.role-badge {
font-size: 11px;
font-weight: 500;
padding: 2px 8px;
border-radius: 4px;
background: ${cssManager.bdTheme('#dbeafe', '#1e3a5f')};
color: ${cssManager.bdTheme('#1e40af', '#93c5fd')};
}
.role-description {
font-size: 13px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
margin-bottom: 12px;
}
.role-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
font-size: 12px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.role-meta-item {
display: flex;
align-items: center;
gap: 4px;
}
/* Parties list */
.parties-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.party-card {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 16px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 10px;
transition: all 0.15s ease;
}
.party-card:hover {
border-color: ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
}
.party-card.selected {
border-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
background: ${cssManager.bdTheme('#eff6ff', '#172554')};
}
.party-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
color: white;
flex-shrink: 0;
}
.party-info {
flex: 1;
min-width: 0;
}
.party-name {
font-size: 15px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
margin-bottom: 4px;
}
.party-role-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
margin-bottom: 8px;
}
.party-details {
display: flex;
flex-wrap: wrap;
gap: 16px;
font-size: 13px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.party-detail {
display: flex;
align-items: center;
gap: 6px;
}
.party-detail dees-icon {
font-size: 14px;
}
.party-status {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 8px;
}
.signature-status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
}
.signature-status.pending {
background: ${cssManager.bdTheme('#fef3c7', '#422006')};
color: ${cssManager.bdTheme('#92400e', '#fcd34d')};
}
.signature-status.signed {
background: ${cssManager.bdTheme('#d1fae5', '#064e3b')};
color: ${cssManager.bdTheme('#065f46', '#6ee7b7')};
}
.signature-status.declined {
background: ${cssManager.bdTheme('#fee2e2', '#450a0a')};
color: ${cssManager.bdTheme('#991b1b', '#fca5a5')};
}
.signing-order {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.order-number {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
/* Add button */
.add-button {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 20px;
background: transparent;
border: 2px dashed ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
border-radius: 10px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease;
}
.add-button:hover {
border-color: ${cssManager.bdTheme('#9ca3af', '#52525b')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
background: ${cssManager.bdTheme('#f9fafb', '#18181b')};
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 40px 20px;
text-align: center;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.empty-state dees-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-state h4 {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.empty-state p {
margin: 0;
font-size: 14px;
}
/* Action buttons */
.action-buttons {
display: flex;
gap: 8px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-sm {
padding: 6px 10px;
font-size: 12px;
}
.btn-primary {
background: ${cssManager.bdTheme('#111111', '#fafafa')};
color: ${cssManager.bdTheme('#ffffff', '#09090b')};
}
.btn-primary:hover {
background: ${cssManager.bdTheme('#333333', '#e5e5e5')};
}
.btn-secondary {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.btn-secondary:hover {
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
}
.btn-ghost {
background: transparent;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.btn-ghost:hover {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
`,
];
// ============================================================================
// PROPERTIES
// ============================================================================
@property({ type: Object })
public accessor contract: plugins.sdInterfaces.IPortableContract | null = null;
@property({ type: Boolean })
public accessor readonly: boolean = false;
// ============================================================================
// STATE
// ============================================================================
@state()
private accessor selectedPartyId: string | null = null;
@state()
private accessor showRoleEditor: boolean = false;
@state()
private accessor showPartyEditor: boolean = false;
// ============================================================================
// EVENT HANDLERS
// ============================================================================
private handleFieldChange(path: string, value: unknown) {
this.dispatchEvent(
new CustomEvent('field-change', {
detail: { path, value },
bubbles: true,
composed: true,
})
);
}
private handleSelectParty(partyId: string) {
this.selectedPartyId = this.selectedPartyId === partyId ? null : partyId;
this.dispatchEvent(
new CustomEvent('party-select', {
detail: { partyId: this.selectedPartyId },
bubbles: true,
composed: true,
})
);
}
private handleAddRole() {
this.showRoleEditor = true;
// TODO: Open role editor modal
}
private handleAddParty() {
this.showPartyEditor = true;
// TODO: Open party editor modal
}
private handleRemoveParty(partyId: string) {
if (!this.contract) return;
const updatedParties = this.contract.involvedParties.filter((p) => p.partyId !== partyId);
this.handleFieldChange('involvedParties', updatedParties);
}
// ============================================================================
// HELPERS
// ============================================================================
private getPartyDisplayName(party: plugins.sdInterfaces.IInvolvedParty): string {
if (!party) return 'Unknown Party';
const contact = party.contact;
if (!contact) return party.deliveryEmail || 'Unknown Party';
if ('name' in contact && contact.name) {
return contact.name as string;
}
if ('firstName' in contact && 'lastName' in contact) {
return `${contact.firstName || ''} ${contact.lastName || ''}`.trim() || party.deliveryEmail || 'Unknown Party';
}
return party.deliveryEmail || 'Unknown Party';
}
private getPartyInitials(party: plugins.sdInterfaces.IInvolvedParty): string {
const name = this.getPartyDisplayName(party);
if (!name || name.length === 0) return '??';
const parts = name.split(' ');
if (parts.length >= 2 && parts[0].length > 0 && parts[parts.length - 1].length > 0) {
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
return name.substring(0, Math.min(2, name.length)).toUpperCase();
}
private getPartyColor(party: plugins.sdInterfaces.IInvolvedParty): string {
// Generate a consistent color based on party ID
const colors = [
'#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6',
'#ec4899', '#06b6d4', '#84cc16', '#f97316', '#6366f1',
];
const idStr = party?.partyId || 'default';
const hash = idStr.split('').reduce((a, b) => a + b.charCodeAt(0), 0);
return colors[hash % colors.length];
}
private getRoleName(roleId: string): string {
if (!roleId) return 'Unknown Role';
const role = this.contract?.availableRoles.find((r) => r.id === roleId);
return role?.name || roleId.charAt(0).toUpperCase() + roleId.slice(1);
}
private getSignatureStatusClass(status: string): string {
if (status === 'signed') return 'signed';
if (status === 'declined') return 'declined';
return 'pending';
}
private formatSignatureStatus(status: string): string {
return status.charAt(0).toUpperCase() + status.slice(1);
}
// ============================================================================
// RENDER
// ============================================================================
public render(): TemplateResult {
if (!this.contract) {
return html`<div>No contract loaded</div>`;
}
const roles = this.contract.availableRoles;
const parties = this.contract.involvedParties;
return html`
<div class="parties-container">
<!-- Roles Section -->
<div class="section-card">
<div class="section-header">
<div class="section-title">
<dees-icon .iconFA=${'lucide:users-2'}></dees-icon>
Available Roles
</div>
${!this.readonly
? html`
<button class="btn btn-secondary btn-sm" @click=${this.handleAddRole}>
<dees-icon .iconFA=${'lucide:plus'}></dees-icon>
Add Role
</button>
`
: ''}
</div>
<div class="section-content">
${roles.length > 0
? html`
<div class="roles-grid">
${roles.map((role) => this.renderRoleCard(role))}
</div>
`
: html`
<div class="empty-state">
<dees-icon .iconFA=${'lucide:users'}></dees-icon>
<h4>No Roles Defined</h4>
<p>Add roles to define the types of parties in this contract</p>
</div>
`}
</div>
</div>
<!-- Parties Section -->
<div class="section-card">
<div class="section-header">
<div class="section-title">
<dees-icon .iconFA=${'lucide:user-plus'}></dees-icon>
Involved Parties (${parties.length})
</div>
${!this.readonly
? html`
<button class="btn btn-primary btn-sm" @click=${this.handleAddParty}>
<dees-icon .iconFA=${'lucide:plus'}></dees-icon>
Add Party
</button>
`
: ''}
</div>
<div class="section-content">
${parties.length > 0
? html`
<div class="parties-list">
${parties.map((party) => this.renderPartyCard(party))}
</div>
`
: html`
<div class="empty-state">
<dees-icon .iconFA=${'lucide:user-plus'}></dees-icon>
<h4>No Parties Added</h4>
<p>Add parties who will be involved in this contract</p>
</div>
`}
</div>
</div>
</div>
`;
}
private renderRoleCard(role: plugins.sdInterfaces.IRole): TemplateResult {
return html`
<div class="role-card">
<div class="role-header">
<div class="role-name">
<dees-icon
.iconFA=${role.icon || 'lucide:user'}
style="color: ${role.displayColor || 'inherit'}"
></dees-icon>
${role.name}
</div>
<span class="role-badge">${role.category}</span>
</div>
<div class="role-description">${role.description || 'No description'}</div>
<div class="role-meta">
${role.signatureRequired
? html`
<span class="role-meta-item">
<dees-icon .iconFA=${'lucide:pen-tool'}></dees-icon>
Signature required
</span>
`
: ''}
${role.defaultSigningOrder > 0
? html`
<span class="role-meta-item">
<dees-icon .iconFA=${'lucide:list-ordered'}></dees-icon>
Order: ${role.defaultSigningOrder}
</span>
`
: ''}
${role.minParties
? html`
<span class="role-meta-item">
<dees-icon .iconFA=${'lucide:users'}></dees-icon>
Min: ${role.minParties}${role.maxParties ? `, Max: ${role.maxParties}` : ''}
</span>
`
: ''}
</div>
</div>
`;
}
private renderPartyCard(party: plugins.sdInterfaces.IInvolvedParty): TemplateResult {
// Handle both full IInvolvedParty and minimal demo data
const partyId = (party as any).partyId || (party as any).role || 'unknown';
const roleId = (party as any).roleId || (party as any).role || '';
const partyRole = (party as any).partyRole || 'signer';
const signatureStatus = (party as any).signature?.status || 'pending';
const signingOrder = (party as any).signingOrder || 0;
const deliveryEmail = (party as any).deliveryEmail;
const deliveryPhone = (party as any).deliveryPhone;
const actingAsProxy = (party as any).actingAsProxy;
const isSelected = this.selectedPartyId === partyId;
return html`
<div
class="party-card ${isSelected ? 'selected' : ''}"
@click=${() => this.handleSelectParty(partyId)}
>
<div
class="party-avatar"
style="background: ${this.getPartyColor(party)}"
>
${this.getPartyInitials(party)}
</div>
<div class="party-info">
<div class="party-name">${this.getPartyDisplayName(party)}</div>
<div class="party-role-tag">
${this.getRoleName(roleId)} (${PARTY_ROLES.find((r) => r.value === partyRole)?.label || partyRole})
</div>
<div class="party-details">
${deliveryEmail
? html`
<div class="party-detail">
<dees-icon .iconFA=${'lucide:mail'}></dees-icon>
${deliveryEmail}
</div>
`
: ''}
${deliveryPhone
? html`
<div class="party-detail">
<dees-icon .iconFA=${'lucide:phone'}></dees-icon>
${deliveryPhone}
</div>
`
: ''}
${actingAsProxy
? html`
<div class="party-detail">
<dees-icon .iconFA=${'lucide:users'}></dees-icon>
Acting as proxy
</div>
`
: ''}
</div>
</div>
<div class="party-status">
<span class="signature-status ${this.getSignatureStatusClass(signatureStatus)}">
${this.formatSignatureStatus(signatureStatus)}
</span>
${signingOrder > 0
? html`
<div class="signing-order">
<span class="order-number">${signingOrder}</span>
<span>Signing order</span>
</div>
`
: ''}
</div>
</div>
`;
}
}

View File

@@ -0,0 +1 @@
export * from './sdig-contract-signatures.js';

View File

@@ -0,0 +1,840 @@
/**
* @file sdig-contract-signatures.ts
* @description Contract signature fields manager component
*/
import {
DeesElement,
property,
html,
customElement,
type TemplateResult,
css,
cssManager,
state,
} from '@design.estate/dees-element';
import * as plugins from '../../plugins.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-contract-signatures': SdigContractSignatures;
}
}
// Signature field interface (for future interface updates)
interface ISignatureField {
id: string;
name: string;
assignedPartyId: string | null;
roleId: string;
type: 'signature' | 'initials' | 'date' | 'text';
required: boolean;
status: 'pending' | 'ready' | 'signed' | 'declined';
signedAt?: number;
signatureData?: any;
position: {
paragraphId?: string;
pageNumber?: number;
x: number;
y: number;
};
}
// Signature status configuration
const SIGNATURE_STATUSES = [
{ value: 'pending', label: 'Pending', color: '#f59e0b', icon: 'lucide:clock' },
{ value: 'ready', label: 'Ready to Sign', color: '#3b82f6', icon: 'lucide:pen-tool' },
{ value: 'signed', label: 'Signed', color: '#10b981', icon: 'lucide:check-circle' },
{ value: 'declined', label: 'Declined', color: '#ef4444', icon: 'lucide:x-circle' },
];
const FIELD_TYPES = [
{ value: 'signature', label: 'Full Signature', icon: 'lucide:pen-tool' },
{ value: 'initials', label: 'Initials', icon: 'lucide:type' },
{ value: 'date', label: 'Date', icon: 'lucide:calendar' },
{ value: 'text', label: 'Text Field', icon: 'lucide:text-cursor' },
];
@customElement('sdig-contract-signatures')
export class SdigContractSignatures extends DeesElement {
// ============================================================================
// STATIC
// ============================================================================
public static demo = () => html`
<sdig-contract-signatures
.contract=${plugins.sdDemodata.demoContract}
></sdig-contract-signatures>
`;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
}
.signatures-container {
display: flex;
flex-direction: column;
gap: 24px;
}
/* Summary cards */
.summary-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 16px;
}
.summary-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 20px;
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 12px;
}
.summary-card-icon {
width: 40px;
height: 40px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
}
.summary-card-icon.pending {
background: ${cssManager.bdTheme('#fef3c7', '#422006')};
color: ${cssManager.bdTheme('#f59e0b', '#fcd34d')};
}
.summary-card-icon.ready {
background: ${cssManager.bdTheme('#dbeafe', '#1e3a5f')};
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
}
.summary-card-icon.signed {
background: ${cssManager.bdTheme('#d1fae5', '#064e3b')};
color: ${cssManager.bdTheme('#10b981', '#34d399')};
}
.summary-card-icon.declined {
background: ${cssManager.bdTheme('#fee2e2', '#450a0a')};
color: ${cssManager.bdTheme('#ef4444', '#f87171')};
}
.summary-card-value {
font-size: 28px;
font-weight: 700;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.summary-card-label {
font-size: 13px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
/* Section card */
.section-card {
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 12px;
overflow: hidden;
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.section-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.section-title dees-icon {
font-size: 18px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.section-content {
padding: 20px;
}
/* Signature fields list */
.fields-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.field-card {
display: flex;
align-items: center;
gap: 16px;
padding: 16px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 10px;
transition: all 0.15s ease;
}
.field-card:hover {
border-color: ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
}
.field-card.selected {
border-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
background: ${cssManager.bdTheme('#eff6ff', '#172554')};
}
.field-icon {
width: 44px;
height: 44px;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
flex-shrink: 0;
}
.field-info {
flex: 1;
min-width: 0;
}
.field-name {
font-size: 15px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
margin-bottom: 4px;
}
.field-meta {
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 13px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.field-meta-item {
display: flex;
align-items: center;
gap: 4px;
}
.field-meta-item dees-icon {
font-size: 14px;
}
.field-status {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
}
.field-status.pending {
background: ${cssManager.bdTheme('#fef3c7', '#422006')};
color: ${cssManager.bdTheme('#92400e', '#fcd34d')};
}
.field-status.ready {
background: ${cssManager.bdTheme('#dbeafe', '#1e3a5f')};
color: ${cssManager.bdTheme('#1e40af', '#93c5fd')};
}
.field-status.signed {
background: ${cssManager.bdTheme('#d1fae5', '#064e3b')};
color: ${cssManager.bdTheme('#065f46', '#6ee7b7')};
}
.field-status.declined {
background: ${cssManager.bdTheme('#fee2e2', '#450a0a')};
color: ${cssManager.bdTheme('#991b1b', '#fca5a5')};
}
.field-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
/* Signer progress */
.signers-section {
margin-top: 24px;
}
.signers-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
}
.signer-card {
display: flex;
align-items: center;
gap: 14px;
padding: 16px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 10px;
}
.signer-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 600;
color: white;
flex-shrink: 0;
}
.signer-info {
flex: 1;
min-width: 0;
}
.signer-name {
font-size: 15px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
margin-bottom: 4px;
}
.signer-role {
font-size: 13px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
margin-bottom: 8px;
}
.signer-progress {
display: flex;
align-items: center;
gap: 8px;
}
.progress-bar {
flex: 1;
height: 6px;
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: ${cssManager.bdTheme('#10b981', '#34d399')};
transition: width 0.3s ease;
}
.progress-text {
font-size: 12px;
font-weight: 500;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
min-width: 36px;
text-align: right;
}
/* Signature preview */
.signature-preview {
position: relative;
padding: 24px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 10px;
text-align: center;
}
.signature-preview-label {
font-size: 12px;
font-weight: 500;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
margin-bottom: 12px;
}
.signature-preview-image {
max-width: 200px;
max-height: 80px;
margin: 0 auto;
}
.signature-preview-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
.signature-preview-placeholder dees-icon {
font-size: 32px;
opacity: 0.5;
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 48px 20px;
text-align: center;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.empty-state dees-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-state h4 {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.empty-state p {
margin: 0 0 20px;
font-size: 14px;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-sm {
padding: 6px 10px;
font-size: 12px;
}
.btn-primary {
background: ${cssManager.bdTheme('#111111', '#fafafa')};
color: ${cssManager.bdTheme('#ffffff', '#09090b')};
}
.btn-primary:hover {
background: ${cssManager.bdTheme('#333333', '#e5e5e5')};
}
.btn-secondary {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.btn-secondary:hover {
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
}
.btn-ghost {
background: transparent;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
padding: 6px;
}
.btn-ghost:hover {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.btn-success {
background: ${cssManager.bdTheme('#10b981', '#059669')};
color: white;
}
.btn-success:hover {
background: ${cssManager.bdTheme('#059669', '#047857')};
}
/* Type badge */
.type-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
background: ${cssManager.bdTheme('#e5e7eb', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
/* Signing order */
.signing-order-badge {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
font-size: 12px;
font-weight: 600;
background: ${cssManager.bdTheme('#dbeafe', '#1e3a5f')};
color: ${cssManager.bdTheme('#1e40af', '#93c5fd')};
}
`,
];
// ============================================================================
// PROPERTIES
// ============================================================================
@property({ type: Object })
public accessor contract: plugins.sdInterfaces.IPortableContract | null = null;
@property({ type: Boolean })
public accessor readonly: boolean = false;
// ============================================================================
// STATE
// ============================================================================
@state()
private accessor selectedFieldId: string | null = null;
// Demo signature fields data
@state()
private accessor signatureFields: ISignatureField[] = [];
// ============================================================================
// LIFECYCLE
// ============================================================================
public async firstUpdated() {
// Generate demo signature fields based on contract parties
if (this.contract && this.contract.involvedParties.length > 0) {
this.signatureFields = this.contract.involvedParties.map((party, index) => ({
id: `sig-${index + 1}`,
name: `Signature - ${this.getPartyRoleName(party.role)}`,
assignedPartyId: null,
roleId: party.role,
type: 'signature' as const,
required: true,
status: index === 0 ? 'signed' : index === 1 ? 'ready' : 'pending',
signedAt: index === 0 ? Date.now() - 86400000 : undefined,
position: { x: 0, y: 0 },
}));
}
}
// ============================================================================
// EVENT HANDLERS
// ============================================================================
private handleFieldChange(path: string, value: unknown) {
this.dispatchEvent(
new CustomEvent('field-change', {
detail: { path, value },
bubbles: true,
composed: true,
})
);
}
private handleSelectField(fieldId: string) {
this.selectedFieldId = this.selectedFieldId === fieldId ? null : fieldId;
}
private handleAddField() {
const newField: ISignatureField = {
id: `sig-${Date.now()}`,
name: 'New Signature Field',
assignedPartyId: null,
roleId: '',
type: 'signature',
required: true,
status: 'pending',
position: { x: 0, y: 0 },
};
this.signatureFields = [...this.signatureFields, newField];
}
private handleDeleteField(fieldId: string) {
this.signatureFields = this.signatureFields.filter((f) => f.id !== fieldId);
if (this.selectedFieldId === fieldId) {
this.selectedFieldId = null;
}
}
// ============================================================================
// HELPERS
// ============================================================================
private getPartyRoleName(roleId: string): string {
const role = this.contract?.availableRoles.find((r) => r.id === roleId);
return role?.name || roleId;
}
private getStatusConfig(status: string) {
return SIGNATURE_STATUSES.find((s) => s.value === status) || SIGNATURE_STATUSES[0];
}
private getFieldTypeConfig(type: string) {
return FIELD_TYPES.find((t) => t.value === type) || FIELD_TYPES[0];
}
private getSignatureStats() {
const total = this.signatureFields.length;
const signed = this.signatureFields.filter((f) => f.status === 'signed').length;
const ready = this.signatureFields.filter((f) => f.status === 'ready').length;
const pending = this.signatureFields.filter((f) => f.status === 'pending').length;
const declined = this.signatureFields.filter((f) => f.status === 'declined').length;
return { total, signed, ready, pending, declined };
}
private getPartyColor(index: number): string {
const colors = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6', '#ec4899'];
return colors[index % colors.length];
}
private formatDate(timestamp: number): string {
return new Date(timestamp).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
// ============================================================================
// RENDER
// ============================================================================
public render(): TemplateResult {
if (!this.contract) {
return html`<div>No contract loaded</div>`;
}
const stats = this.getSignatureStats();
return html`
<div class="signatures-container">
<!-- Summary Cards -->
<div class="summary-row">
<div class="summary-card">
<div class="summary-card-icon pending">
<dees-icon .iconFA=${'lucide:clock'}></dees-icon>
</div>
<div class="summary-card-value">${stats.pending}</div>
<div class="summary-card-label">Pending</div>
</div>
<div class="summary-card">
<div class="summary-card-icon ready">
<dees-icon .iconFA=${'lucide:pen-tool'}></dees-icon>
</div>
<div class="summary-card-value">${stats.ready}</div>
<div class="summary-card-label">Ready to Sign</div>
</div>
<div class="summary-card">
<div class="summary-card-icon signed">
<dees-icon .iconFA=${'lucide:check-circle'}></dees-icon>
</div>
<div class="summary-card-value">${stats.signed}</div>
<div class="summary-card-label">Signed</div>
</div>
<div class="summary-card">
<div class="summary-card-icon declined">
<dees-icon .iconFA=${'lucide:x-circle'}></dees-icon>
</div>
<div class="summary-card-value">${stats.declined}</div>
<div class="summary-card-label">Declined</div>
</div>
</div>
<!-- Signature Fields Section -->
<div class="section-card">
<div class="section-header">
<div class="section-title">
<dees-icon .iconFA=${'lucide:pen-tool'}></dees-icon>
Signature Fields
</div>
${!this.readonly
? html`
<button class="btn btn-primary" @click=${this.handleAddField}>
<dees-icon .iconFA=${'lucide:plus'}></dees-icon>
Add Field
</button>
`
: ''}
</div>
<div class="section-content">
${this.signatureFields.length > 0
? html`
<div class="fields-list">
${this.signatureFields.map((field, index) => this.renderSignatureField(field, index))}
</div>
`
: html`
<div class="empty-state">
<dees-icon .iconFA=${'lucide:pen-tool'}></dees-icon>
<h4>No Signature Fields</h4>
<p>Add signature fields to define where parties should sign the contract</p>
${!this.readonly
? html`
<button class="btn btn-primary" @click=${this.handleAddField}>
<dees-icon .iconFA=${'lucide:plus'}></dees-icon>
Add Signature Field
</button>
`
: ''}
</div>
`}
</div>
</div>
<!-- Signers Progress Section -->
${this.contract.involvedParties.length > 0
? html`
<div class="section-card">
<div class="section-header">
<div class="section-title">
<dees-icon .iconFA=${'lucide:users'}></dees-icon>
Signers Progress
</div>
</div>
<div class="section-content">
<div class="signers-grid">
${this.contract.involvedParties.map((party, index) => this.renderSignerCard(party, index))}
</div>
</div>
</div>
`
: ''}
</div>
`;
}
private renderSignatureField(field: ISignatureField, index: number): TemplateResult {
const isSelected = this.selectedFieldId === field.id;
const statusConfig = this.getStatusConfig(field.status);
const typeConfig = this.getFieldTypeConfig(field.type);
return html`
<div
class="field-card ${isSelected ? 'selected' : ''}"
@click=${() => this.handleSelectField(field.id)}
>
<div class="signing-order-badge">${index + 1}</div>
<div class="field-icon">
<dees-icon .iconFA=${typeConfig.icon}></dees-icon>
</div>
<div class="field-info">
<div class="field-name">${field.name}</div>
<div class="field-meta">
<span class="field-meta-item">
<dees-icon .iconFA=${'lucide:user'}></dees-icon>
${this.getPartyRoleName(field.roleId)}
</span>
<span class="type-badge">
<dees-icon .iconFA=${typeConfig.icon}></dees-icon>
${typeConfig.label}
</span>
${field.required
? html`
<span class="field-meta-item">
<dees-icon .iconFA=${'lucide:asterisk'}></dees-icon>
Required
</span>
`
: ''}
${field.signedAt
? html`
<span class="field-meta-item">
<dees-icon .iconFA=${'lucide:calendar'}></dees-icon>
${this.formatDate(field.signedAt)}
</span>
`
: ''}
</div>
</div>
<div class="field-status ${field.status}">
<dees-icon .iconFA=${statusConfig.icon}></dees-icon>
${statusConfig.label}
</div>
${!this.readonly
? html`
<div class="field-actions">
<button class="btn btn-ghost" @click=${(e: Event) => { e.stopPropagation(); }} title="Edit">
<dees-icon .iconFA=${'lucide:pencil'}></dees-icon>
</button>
<button
class="btn btn-ghost"
@click=${(e: Event) => { e.stopPropagation(); this.handleDeleteField(field.id); }}
title="Delete"
style="color: #ef4444;"
>
<dees-icon .iconFA=${'lucide:trash-2'}></dees-icon>
</button>
</div>
`
: ''}
</div>
`;
}
private renderSignerCard(party: plugins.sdInterfaces.IInvolvedParty, index: number): TemplateResult {
const partyFields = this.signatureFields.filter((f) => f.roleId === party.role);
const signedFields = partyFields.filter((f) => f.status === 'signed').length;
const totalFields = partyFields.length;
const progress = totalFields > 0 ? Math.round((signedFields / totalFields) * 100) : 0;
const roleName = this.getPartyRoleName(party.role);
return html`
<div class="signer-card">
<div class="signer-avatar" style="background: ${this.getPartyColor(index)}">
${roleName.charAt(0).toUpperCase()}
</div>
<div class="signer-info">
<div class="signer-name">${roleName}</div>
<div class="signer-role">${signedFields} of ${totalFields} signatures</div>
<div class="signer-progress">
<div class="progress-bar">
<div class="progress-fill" style="width: ${progress}%"></div>
</div>
<span class="progress-text">${progress}%</span>
</div>
</div>
</div>
`;
}
}

View File

@@ -0,0 +1 @@
export * from './sdig-contract-terms.js';

View File

@@ -0,0 +1,873 @@
/**
* @file sdig-contract-terms.ts
* @description Contract terms editor - tabbed container for financial, time, and obligation terms
*/
import {
DeesElement,
property,
html,
customElement,
type TemplateResult,
css,
cssManager,
state,
} from '@design.estate/dees-element';
import * as plugins from '../../plugins.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-contract-terms': SdigContractTerms;
}
}
// Term types
type TTermTab = 'financial' | 'time' | 'obligations';
interface ITermTabConfig {
id: TTermTab;
label: string;
icon: string;
description: string;
}
const TERM_TABS: ITermTabConfig[] = [
{ id: 'financial', label: 'Financial Terms', icon: 'lucide:banknote', description: 'Payment schedules, rates, and penalties' },
{ id: 'time', label: 'Time Terms', icon: 'lucide:calendar', description: 'Milestones, deadlines, and renewal' },
{ id: 'obligations', label: 'Obligations', icon: 'lucide:check-square', description: 'Deliverables, SLAs, and warranties' },
];
// Extended contract terms interfaces (for future interface updates)
interface IPaymentScheduleItem {
id: string;
description: string;
amount: number;
currency: string;
dueDate: string;
status: 'pending' | 'paid' | 'overdue';
}
interface IMilestone {
id: string;
name: string;
description: string;
dueDate: string;
status: 'pending' | 'in_progress' | 'completed' | 'delayed';
dependencies: string[];
}
interface IObligation {
id: string;
description: string;
responsibleParty: string;
deadline: string;
status: 'pending' | 'completed' | 'waived';
}
@customElement('sdig-contract-terms')
export class SdigContractTerms extends DeesElement {
// ============================================================================
// STATIC
// ============================================================================
public static demo = () => html`
<sdig-contract-terms
.contract=${plugins.sdDemodata.demoContract}
></sdig-contract-terms>
`;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
}
.terms-container {
display: flex;
flex-direction: column;
gap: 16px;
}
/* Section card */
.section-card {
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
border-radius: 12px;
overflow: hidden;
}
/* Tab navigation */
.tabs-nav {
display: flex;
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
}
.tab-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 16px 24px;
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: all 0.15s ease;
}
.tab-btn:hover {
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
background: ${cssManager.bdTheme('#f3f4f6', '#18181b')};
}
.tab-btn.active {
color: ${cssManager.bdTheme('#111111', '#fafafa')};
border-bottom-color: ${cssManager.bdTheme('#111111', '#fafafa')};
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
}
.tab-btn dees-icon {
font-size: 16px;
}
/* Tab content */
.tab-content {
padding: 24px;
}
/* Sub-sections */
.sub-section {
margin-bottom: 24px;
}
.sub-section:last-child {
margin-bottom: 0;
}
.sub-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.sub-section-title {
font-size: 16px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.sub-section-description {
font-size: 13px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
margin-top: 4px;
}
/* Form groups */
.form-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 6px;
}
.form-label {
font-size: 13px;
font-weight: 500;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.form-input {
padding: 10px 12px;
font-size: 14px;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border: 1px solid ${cssManager.bdTheme('#d1d5db', '#3f3f46')};
border-radius: 6px;
outline: none;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.form-input:focus {
border-color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
box-shadow: 0 0 0 3px ${cssManager.bdTheme('rgba(59, 130, 246, 0.1)', 'rgba(96, 165, 250, 0.1)')};
}
.form-input::placeholder {
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
}
select.form-input {
cursor: pointer;
}
/* Data table */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.data-table th {
text-align: left;
padding: 12px 16px;
font-weight: 500;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.data-table td {
padding: 12px 16px;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.data-table tr:last-child td {
border-bottom: none;
}
.data-table tr:hover td {
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
}
/* Status badges */
.status-badge {
display: inline-flex;
align-items: center;
padding: 4px 10px;
border-radius: 9999px;
font-size: 12px;
font-weight: 500;
}
.status-badge.pending {
background: ${cssManager.bdTheme('#fef3c7', '#422006')};
color: ${cssManager.bdTheme('#92400e', '#fcd34d')};
}
.status-badge.paid,
.status-badge.completed {
background: ${cssManager.bdTheme('#d1fae5', '#064e3b')};
color: ${cssManager.bdTheme('#065f46', '#6ee7b7')};
}
.status-badge.overdue,
.status-badge.delayed {
background: ${cssManager.bdTheme('#fee2e2', '#450a0a')};
color: ${cssManager.bdTheme('#991b1b', '#fca5a5')};
}
.status-badge.in_progress {
background: ${cssManager.bdTheme('#dbeafe', '#1e3a5f')};
color: ${cssManager.bdTheme('#1e40af', '#93c5fd')};
}
/* Amount display */
.amount {
font-weight: 600;
font-family: 'Roboto Mono', monospace;
}
.amount.positive {
color: ${cssManager.bdTheme('#059669', '#34d399')};
}
.amount.negative {
color: ${cssManager.bdTheme('#dc2626', '#f87171')};
}
/* Summary card */
.summary-card {
display: flex;
gap: 32px;
padding: 20px;
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
border-radius: 8px;
margin-bottom: 24px;
}
.summary-item {
display: flex;
flex-direction: column;
gap: 4px;
}
.summary-label {
font-size: 12px;
font-weight: 500;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
text-transform: uppercase;
letter-spacing: 0.05em;
}
.summary-value {
font-size: 24px;
font-weight: 700;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.summary-value.currency {
font-family: 'Roboto Mono', monospace;
}
/* Empty state */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 48px 20px;
text-align: center;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.empty-state dees-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.empty-state h4 {
margin: 0 0 8px;
font-size: 16px;
font-weight: 600;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.empty-state p {
margin: 0 0 20px;
font-size: 14px;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
font-size: 13px;
font-weight: 500;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-sm {
padding: 6px 10px;
font-size: 12px;
}
.btn-primary {
background: ${cssManager.bdTheme('#111111', '#fafafa')};
color: ${cssManager.bdTheme('#ffffff', '#09090b')};
}
.btn-primary:hover {
background: ${cssManager.bdTheme('#333333', '#e5e5e5')};
}
.btn-secondary {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.btn-secondary:hover {
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
}
.btn-ghost {
background: transparent;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.btn-ghost:hover {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
/* Add row button */
.add-row {
display: flex;
justify-content: center;
padding: 16px;
border-top: 1px dashed ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
/* Info banner */
.info-banner {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
background: ${cssManager.bdTheme('#eff6ff', '#172554')};
border: 1px solid ${cssManager.bdTheme('#bfdbfe', '#1e40af')};
border-radius: 8px;
margin-bottom: 24px;
}
.info-banner dees-icon {
font-size: 20px;
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
flex-shrink: 0;
}
.info-banner-content {
flex: 1;
}
.info-banner-title {
font-size: 14px;
font-weight: 600;
color: ${cssManager.bdTheme('#1e40af', '#93c5fd')};
margin-bottom: 4px;
}
.info-banner-text {
font-size: 13px;
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
}
`,
];
// ============================================================================
// PROPERTIES
// ============================================================================
@property({ type: Object })
public accessor contract: plugins.sdInterfaces.IPortableContract | null = null;
@property({ type: Boolean })
public accessor readonly: boolean = false;
// ============================================================================
// STATE
// ============================================================================
@state()
private accessor activeTab: TTermTab = 'financial';
// Demo data for terms (will be replaced with actual contract data when interface is extended)
@state()
private accessor paymentSchedule: IPaymentScheduleItem[] = [
{ id: '1', description: 'Initial deposit', amount: 5000, currency: 'EUR', dueDate: '2024-02-01', status: 'paid' },
{ id: '2', description: 'Monthly payment - March', amount: 1000, currency: 'EUR', dueDate: '2024-03-01', status: 'paid' },
{ id: '3', description: 'Monthly payment - April', amount: 1000, currency: 'EUR', dueDate: '2024-04-01', status: 'pending' },
];
@state()
private accessor milestones: IMilestone[] = [
{ id: '1', name: 'Project Kickoff', description: 'Initial planning and setup', dueDate: '2024-02-15', status: 'completed', dependencies: [] },
{ id: '2', name: 'Phase 1 Delivery', description: 'First deliverable milestone', dueDate: '2024-03-15', status: 'in_progress', dependencies: ['1'] },
{ id: '3', name: 'Final Delivery', description: 'Complete project delivery', dueDate: '2024-05-01', status: 'pending', dependencies: ['2'] },
];
@state()
private accessor obligations: IObligation[] = [
{ id: '1', description: 'Provide access credentials', responsibleParty: 'employer', deadline: '2024-02-01', status: 'completed' },
{ id: '2', description: 'Submit monthly reports', responsibleParty: 'employee', deadline: '2024-03-01', status: 'pending' },
{ id: '3', description: 'Conduct quarterly review', responsibleParty: 'employer', deadline: '2024-04-01', status: 'pending' },
];
// ============================================================================
// EVENT HANDLERS
// ============================================================================
private handleFieldChange(path: string, value: unknown) {
this.dispatchEvent(
new CustomEvent('field-change', {
detail: { path, value },
bubbles: true,
composed: true,
})
);
}
private handleTabChange(tab: TTermTab) {
this.activeTab = tab;
}
private handleAddPayment() {
const newPayment: IPaymentScheduleItem = {
id: `pay-${Date.now()}`,
description: 'New payment',
amount: 0,
currency: 'EUR',
dueDate: new Date().toISOString().split('T')[0],
status: 'pending',
};
this.paymentSchedule = [...this.paymentSchedule, newPayment];
}
private handleAddMilestone() {
const newMilestone: IMilestone = {
id: `ms-${Date.now()}`,
name: 'New Milestone',
description: '',
dueDate: new Date().toISOString().split('T')[0],
status: 'pending',
dependencies: [],
};
this.milestones = [...this.milestones, newMilestone];
}
private handleAddObligation() {
const newObligation: IObligation = {
id: `obl-${Date.now()}`,
description: 'New obligation',
responsibleParty: '',
deadline: new Date().toISOString().split('T')[0],
status: 'pending',
};
this.obligations = [...this.obligations, newObligation];
}
// ============================================================================
// HELPERS
// ============================================================================
private formatCurrency(amount: number, currency: string): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: currency,
}).format(amount);
}
private formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}
private getTotalAmount(): number {
return this.paymentSchedule.reduce((sum, p) => sum + p.amount, 0);
}
private getPaidAmount(): number {
return this.paymentSchedule.filter((p) => p.status === 'paid').reduce((sum, p) => sum + p.amount, 0);
}
private getPartyName(roleId: string): string {
const role = this.contract?.availableRoles.find((r) => r.id === roleId);
return role?.name || roleId;
}
// ============================================================================
// RENDER
// ============================================================================
public render(): TemplateResult {
if (!this.contract) {
return html`<div>No contract loaded</div>`;
}
return html`
<div class="terms-container">
<div class="section-card">
<!-- Tabs Navigation -->
<nav class="tabs-nav">
${TERM_TABS.map(
(tab) => html`
<button
class="tab-btn ${this.activeTab === tab.id ? 'active' : ''}"
@click=${() => this.handleTabChange(tab.id)}
>
<dees-icon .iconFA=${tab.icon}></dees-icon>
${tab.label}
</button>
`
)}
</nav>
<!-- Tab Content -->
<div class="tab-content">
${this.activeTab === 'financial'
? this.renderFinancialTerms()
: this.activeTab === 'time'
? this.renderTimeTerms()
: this.renderObligations()}
</div>
</div>
</div>
`;
}
private renderFinancialTerms(): TemplateResult {
const totalAmount = this.getTotalAmount();
const paidAmount = this.getPaidAmount();
const pendingAmount = totalAmount - paidAmount;
return html`
<!-- Summary -->
<div class="summary-card">
<div class="summary-item">
<span class="summary-label">Total Value</span>
<span class="summary-value currency">${this.formatCurrency(totalAmount, 'EUR')}</span>
</div>
<div class="summary-item">
<span class="summary-label">Paid</span>
<span class="summary-value currency" style="color: #059669;">${this.formatCurrency(paidAmount, 'EUR')}</span>
</div>
<div class="summary-item">
<span class="summary-label">Pending</span>
<span class="summary-value currency" style="color: #f59e0b;">${this.formatCurrency(pendingAmount, 'EUR')}</span>
</div>
</div>
<!-- Payment Schedule -->
<div class="sub-section">
<div class="sub-section-header">
<div>
<div class="sub-section-title">Payment Schedule</div>
<div class="sub-section-description">Scheduled payments and their status</div>
</div>
${!this.readonly
? html`
<button class="btn btn-secondary" @click=${this.handleAddPayment}>
<dees-icon .iconFA=${'lucide:plus'}></dees-icon>
Add Payment
</button>
`
: ''}
</div>
${this.paymentSchedule.length > 0
? html`
<table class="data-table">
<thead>
<tr>
<th>Description</th>
<th>Amount</th>
<th>Due Date</th>
<th>Status</th>
${!this.readonly ? html`<th></th>` : ''}
</tr>
</thead>
<tbody>
${this.paymentSchedule.map(
(payment) => html`
<tr>
<td>${payment.description}</td>
<td><span class="amount">${this.formatCurrency(payment.amount, payment.currency)}</span></td>
<td>${this.formatDate(payment.dueDate)}</td>
<td><span class="status-badge ${payment.status}">${payment.status}</span></td>
${!this.readonly
? html`
<td>
<button class="btn btn-ghost btn-sm">
<dees-icon .iconFA=${'lucide:pencil'}></dees-icon>
</button>
</td>
`
: ''}
</tr>
`
)}
</tbody>
</table>
`
: html`
<div class="empty-state">
<dees-icon .iconFA=${'lucide:banknote'}></dees-icon>
<h4>No Payment Schedule</h4>
<p>Add payment terms to track financial obligations</p>
</div>
`}
</div>
`;
}
private renderTimeTerms(): TemplateResult {
const completedCount = this.milestones.filter((m) => m.status === 'completed').length;
const totalCount = this.milestones.length;
return html`
<!-- Summary -->
<div class="summary-card">
<div class="summary-item">
<span class="summary-label">Total Milestones</span>
<span class="summary-value">${totalCount}</span>
</div>
<div class="summary-item">
<span class="summary-label">Completed</span>
<span class="summary-value" style="color: #059669;">${completedCount}</span>
</div>
<div class="summary-item">
<span class="summary-label">Progress</span>
<span class="summary-value">${totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0}%</span>
</div>
</div>
<!-- Milestones -->
<div class="sub-section">
<div class="sub-section-header">
<div>
<div class="sub-section-title">Milestones</div>
<div class="sub-section-description">Key project milestones and deadlines</div>
</div>
${!this.readonly
? html`
<button class="btn btn-secondary" @click=${this.handleAddMilestone}>
<dees-icon .iconFA=${'lucide:plus'}></dees-icon>
Add Milestone
</button>
`
: ''}
</div>
${this.milestones.length > 0
? html`
<table class="data-table">
<thead>
<tr>
<th>Milestone</th>
<th>Description</th>
<th>Due Date</th>
<th>Status</th>
${!this.readonly ? html`<th></th>` : ''}
</tr>
</thead>
<tbody>
${this.milestones.map(
(milestone) => html`
<tr>
<td><strong>${milestone.name}</strong></td>
<td>${milestone.description || '—'}</td>
<td>${this.formatDate(milestone.dueDate)}</td>
<td><span class="status-badge ${milestone.status}">${milestone.status.replace('_', ' ')}</span></td>
${!this.readonly
? html`
<td>
<button class="btn btn-ghost btn-sm">
<dees-icon .iconFA=${'lucide:pencil'}></dees-icon>
</button>
</td>
`
: ''}
</tr>
`
)}
</tbody>
</table>
`
: html`
<div class="empty-state">
<dees-icon .iconFA=${'lucide:calendar'}></dees-icon>
<h4>No Milestones</h4>
<p>Add milestones to track project progress</p>
</div>
`}
</div>
`;
}
private renderObligations(): TemplateResult {
const completedCount = this.obligations.filter((o) => o.status === 'completed').length;
return html`
<!-- Summary -->
<div class="summary-card">
<div class="summary-item">
<span class="summary-label">Total Obligations</span>
<span class="summary-value">${this.obligations.length}</span>
</div>
<div class="summary-item">
<span class="summary-label">Completed</span>
<span class="summary-value" style="color: #059669;">${completedCount}</span>
</div>
<div class="summary-item">
<span class="summary-label">Pending</span>
<span class="summary-value" style="color: #f59e0b;">${this.obligations.length - completedCount}</span>
</div>
</div>
<!-- Info banner -->
<div class="info-banner">
<dees-icon .iconFA=${'lucide:info'}></dees-icon>
<div class="info-banner-content">
<div class="info-banner-title">Contractual Obligations</div>
<div class="info-banner-text">
Track responsibilities assigned to each party. Mark obligations as completed when fulfilled.
</div>
</div>
</div>
<!-- Obligations -->
<div class="sub-section">
<div class="sub-section-header">
<div>
<div class="sub-section-title">Party Obligations</div>
<div class="sub-section-description">Responsibilities and deliverables by party</div>
</div>
${!this.readonly
? html`
<button class="btn btn-secondary" @click=${this.handleAddObligation}>
<dees-icon .iconFA=${'lucide:plus'}></dees-icon>
Add Obligation
</button>
`
: ''}
</div>
${this.obligations.length > 0
? html`
<table class="data-table">
<thead>
<tr>
<th>Obligation</th>
<th>Responsible Party</th>
<th>Deadline</th>
<th>Status</th>
${!this.readonly ? html`<th></th>` : ''}
</tr>
</thead>
<tbody>
${this.obligations.map(
(obligation) => html`
<tr>
<td>${obligation.description}</td>
<td>${this.getPartyName(obligation.responsibleParty)}</td>
<td>${this.formatDate(obligation.deadline)}</td>
<td><span class="status-badge ${obligation.status}">${obligation.status}</span></td>
${!this.readonly
? html`
<td>
<button class="btn btn-ghost btn-sm">
<dees-icon .iconFA=${'lucide:pencil'}></dees-icon>
</button>
</td>
`
: ''}
</tr>
`
)}
</tbody>
</table>
`
: html`
<div class="empty-state">
<dees-icon .iconFA=${'lucide:check-square'}></dees-icon>
<h4>No Obligations</h4>
<p>Add obligations to track party responsibilities</p>
</div>
`}
</div>
`;
}
}

View File

@@ -1,42 +0,0 @@
import {
DeesElement,
property,
html,
customElement,
type TemplateResult,
css,
cssManager,
domtools,
} from '@design.estate/dees-element';
import * as plugins from '../plugins.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-contracteditor': ContractEditor;
}
}
@customElement('sdig-contracteditor')
export class ContractEditor extends DeesElement {
public static demo = () => html` <sdig-contracteditor
.contract=${plugins.sdDemodata.demoContract}
></sdig-contracteditor> `;
// INSTANCE
public localStateInstance = new domtools.plugins.smartstate.Smartstate();
public contractState =
this.localStateInstance.getStatePart<plugins.sdInterfaces.IPortableContract>('contract');
@property({ type: Object })
public contract: plugins.sdInterfaces.IPortableContract;
public async firstUpdated(_changedProperties: Map<string | number | symbol, unknown>) {
super.firstUpdated(_changedProperties);
}
public render(): TemplateResult {
return html` <div class="mainbox"></div> `;
}
}

View File

@@ -0,0 +1,8 @@
/**
* @file index.ts
* @description Export barrel for sdig-contracteditor module
*/
export * from './sdig-contracteditor.js';
export * from './types.js';
export * from './state.js';

View File

@@ -0,0 +1,839 @@
/**
* @file sdig-contracteditor.ts
* @description Main contract editor orchestrator component
*/
import {
DeesElement,
property,
state,
html,
customElement,
type TemplateResult,
css,
cssManager,
} from '@design.estate/dees-element';
import * as plugins from '../../plugins.js';
import { createEditorStore, type TEditorStore } from './state.js';
import {
type TEditorSection,
type IEditorState,
EDITOR_SECTIONS,
type IContractChangeEventDetail,
type ISectionChangeEventDetail,
} from './types.js';
// Import sub-components
import '../sdig-contract-header/sdig-contract-header.js';
import '../sdig-contract-metadata/sdig-contract-metadata.js';
import '../sdig-contract-parties/sdig-contract-parties.js';
import '../sdig-contract-content/sdig-contract-content.js';
import '../sdig-contract-terms/sdig-contract-terms.js';
import '../sdig-contract-signatures/sdig-contract-signatures.js';
import '../sdig-contract-attachments/sdig-contract-attachments.js';
import '../sdig-contract-collaboration/sdig-contract-collaboration.js';
import '../sdig-contract-audit/sdig-contract-audit.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-contracteditor': SdigContracteditor;
}
}
@customElement('sdig-contracteditor')
export class SdigContracteditor extends DeesElement {
// ============================================================================
// STATIC
// ============================================================================
public static demo = () => html`
<sdig-contracteditor
.contract=${plugins.sdDemodata.demoContract}
></sdig-contracteditor>
`;
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
width: 100%;
height: 100%;
min-height: 600px;
}
.editor-container {
display: flex;
flex-direction: column;
height: 100%;
background: ${cssManager.bdTheme('#f8f9fa', '#09090b')};
border-radius: 8px;
overflow: hidden;
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
/* Header */
.editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.header-left {
display: flex;
align-items: center;
gap: 16px;
}
.contract-title {
font-size: 18px;
font-weight: 600;
color: ${cssManager.bdTheme('#111111', '#fafafa')};
margin: 0;
}
/* shadcn-style badge */
.contract-status {
display: inline-flex;
align-items: center;
padding: 2px 10px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
line-height: 1.4;
border: 1px solid transparent;
background: ${cssManager.bdTheme('hsl(214 95% 93%)', 'hsl(214 95% 15%)')};
color: ${cssManager.bdTheme('hsl(214 95% 35%)', 'hsl(214 95% 70%)')};
border-color: ${cssManager.bdTheme('hsl(214 95% 80%)', 'hsl(214 95% 25%)')};
}
.contract-status.draft {
background: ${cssManager.bdTheme('hsl(48 96% 89%)', 'hsl(48 96% 15%)')};
color: ${cssManager.bdTheme('hsl(25 95% 30%)', 'hsl(48 96% 70%)')};
border-color: ${cssManager.bdTheme('hsl(48 96% 76%)', 'hsl(48 96% 25%)')};
}
.contract-status.executed {
background: ${cssManager.bdTheme('hsl(142 76% 90%)', 'hsl(142 76% 15%)')};
color: ${cssManager.bdTheme('hsl(142 76% 28%)', 'hsl(142 76% 65%)')};
border-color: ${cssManager.bdTheme('hsl(142 76% 75%)', 'hsl(142 76% 25%)')};
}
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.dirty-indicator {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.dirty-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #f59e0b;
}
.collaborators {
display: flex;
align-items: center;
gap: -8px;
}
.collaborator-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
border: 2px solid ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
color: white;
margin-left: -8px;
}
.collaborator-avatar:first-child {
margin-left: 0;
}
/* Navigation Tabs */
.editor-nav {
display: flex;
align-items: center;
gap: 4px;
padding: 0 24px;
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
overflow-x: auto;
}
.nav-tab {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
font-size: 14px;
font-weight: 500;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
background: transparent;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.nav-tab:hover {
color: ${cssManager.bdTheme('#111111', '#fafafa')};
background: ${cssManager.bdTheme('#f3f4f6', '#18181b')};
}
.nav-tab.active {
color: ${cssManager.bdTheme('#111111', '#fafafa')};
border-bottom-color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.nav-tab dees-icon {
font-size: 16px;
}
.nav-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
border-radius: 10px;
font-size: 11px;
font-weight: 600;
background: ${cssManager.bdTheme('#ef4444', '#dc2626')};
color: white;
}
/* Main Content Area */
.editor-main {
display: flex;
flex: 1;
overflow: hidden;
}
.editor-content {
flex: 1;
overflow-y: auto;
padding: 24px;
}
.editor-sidebar {
width: 320px;
border-left: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
overflow-y: auto;
}
/* Section placeholder */
.section-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 400px;
text-align: center;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.section-placeholder dees-icon {
font-size: 48px;
margin-bottom: 16px;
opacity: 0.5;
}
.section-placeholder h3 {
margin: 0 0 8px;
font-size: 18px;
font-weight: 600;
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.section-placeholder p {
margin: 0;
font-size: 14px;
}
/* Footer */
.editor-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 24px;
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
border-top: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
}
.footer-left {
display: flex;
align-items: center;
gap: 16px;
font-size: 13px;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.footer-right {
display: flex;
align-items: center;
gap: 12px;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
border-radius: 6px;
border: none;
cursor: pointer;
transition: all 0.15s ease;
}
.btn-primary {
background: ${cssManager.bdTheme('#111111', '#fafafa')};
color: ${cssManager.bdTheme('#ffffff', '#09090b')};
}
.btn-primary:hover {
background: ${cssManager.bdTheme('#333333', '#e5e5e5')};
}
.btn-secondary {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
}
.btn-secondary:hover {
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
}
.btn-ghost {
background: transparent;
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
}
.btn-ghost:hover {
background: ${cssManager.bdTheme('#f3f4f6', '#27272a')};
color: ${cssManager.bdTheme('#111111', '#fafafa')};
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Loading state */
.loading-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: ${cssManager.bdTheme('rgba(255,255,255,0.8)', 'rgba(0,0,0,0.8)')};
z-index: 100;
}
/* Overview section layout */
.overview-section {
display: flex;
flex-direction: column;
gap: 24px;
}
`,
];
// ============================================================================
// PROPERTIES
// ============================================================================
@property({ type: Object })
public accessor contract: plugins.sdInterfaces.IPortableContract | null = null;
@property({ type: Boolean })
public accessor showSidebar: boolean = true;
@property({ type: String })
public accessor initialSection: TEditorSection = 'overview';
// ============================================================================
// STATE
// ============================================================================
@state()
private accessor editorState: IEditorState | null = null;
// ============================================================================
// INSTANCE
// ============================================================================
private store: TEditorStore | null = null;
private unsubscribe: (() => void) | null = null;
private storeReady: Promise<void>;
private resolveStoreReady!: () => void;
constructor() {
super();
this.storeReady = new Promise((resolve) => {
this.resolveStoreReady = resolve;
});
}
// ============================================================================
// LIFECYCLE
// ============================================================================
public connectedCallback() {
super.connectedCallback();
this.initStore();
}
private async initStore() {
this.store = await createEditorStore();
this.unsubscribe = this.store.subscribe((state) => {
this.editorState = state;
});
// Set initial section
this.store.setActiveSection(this.initialSection);
this.resolveStoreReady();
// If contract was already set, apply it now
if (this.contract) {
this.store.setContract(this.contract);
}
}
public disconnectedCallback() {
super.disconnectedCallback();
if (this.unsubscribe) {
this.unsubscribe();
}
}
public async updated(changedProperties: Map<string, unknown>) {
if (changedProperties.has('contract') && this.contract) {
await this.storeReady;
this.store?.setContract(this.contract);
}
}
// ============================================================================
// EVENT HANDLERS
// ============================================================================
private handleSectionChange(section: TEditorSection) {
const previousSection = this.editorState?.activeSection || 'overview';
this.store?.setActiveSection(section);
this.dispatchEvent(
new CustomEvent<ISectionChangeEventDetail>('section-change', {
detail: { section, previousSection },
bubbles: true,
composed: true,
})
);
}
private handleSave() {
if (!this.editorState?.contract) return;
this.store?.setSaving(true);
this.dispatchEvent(
new CustomEvent('contract-save', {
detail: {
contract: this.editorState.contract,
isDraft: this.editorState.contract.lifecycle.currentStatus === 'draft',
},
bubbles: true,
composed: true,
})
);
}
private handleDiscard() {
this.store?.discardChanges();
this.dispatchEvent(
new CustomEvent('contract-discard', {
bubbles: true,
composed: true,
})
);
}
private handleUndo() {
this.store?.undo();
}
private handleRedo() {
this.store?.redo();
}
// ============================================================================
// PUBLIC API
// ============================================================================
/**
* Update a field in the contract
*/
public updateField(path: string, value: unknown, description?: string) {
this.store?.updateContract(path, value, description);
this.dispatchEvent(
new CustomEvent<IContractChangeEventDetail>('contract-change', {
detail: { path, value, source: 'user' },
bubbles: true,
composed: true,
})
);
}
/**
* Get current contract state
*/
public getContract(): plugins.sdInterfaces.IPortableContract | null {
return this.editorState?.contract || null;
}
/**
* Mark contract as saved externally
*/
public markSaved() {
this.store?.markSaved();
}
// ============================================================================
// RENDER HELPERS
// ============================================================================
private getStatusClass(status: string): string {
if (status === 'draft' || status === 'internal_review') return 'draft';
if (status === 'executed' || status === 'active') return 'executed';
return '';
}
private formatStatus(status: string): string {
return status.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
private handleFieldChange(e: CustomEvent<{ path: string; value: unknown }>) {
const { path, value } = e.detail;
this.updateField(path, value);
}
private renderSectionContent(): TemplateResult {
const section = this.editorState?.activeSection || 'overview';
const contract = this.editorState?.contract;
const sectionConfig = EDITOR_SECTIONS.find((s) => s.id === section);
// Render section based on active tab
switch (section) {
case 'overview':
return this.renderOverviewSection();
case 'parties':
return this.renderPartiesSection();
case 'content':
return this.renderContentSection();
case 'terms':
return this.renderTermsSection();
case 'signatures':
return this.renderSignaturesSection();
case 'attachments':
return this.renderAttachmentsSection();
case 'collaboration':
return this.renderCollaborationSection();
case 'audit':
return this.renderAuditSection();
default:
return this.renderPlaceholder(sectionConfig, 'This section is being implemented...');
}
}
private renderOverviewSection(): TemplateResult {
const contract = this.editorState?.contract;
if (!contract) {
return html`<div>No contract loaded</div>`;
}
return html`
<div class="overview-section">
<sdig-contract-header
.contract=${contract}
@field-change=${this.handleFieldChange}
></sdig-contract-header>
<sdig-contract-metadata
.contract=${contract}
@field-change=${this.handleFieldChange}
></sdig-contract-metadata>
</div>
`;
}
private renderPartiesSection(): TemplateResult {
const contract = this.editorState?.contract;
if (!contract) {
return html`<div>No contract loaded</div>`;
}
return html`
<sdig-contract-parties
.contract=${contract}
@field-change=${this.handleFieldChange}
></sdig-contract-parties>
`;
}
private renderContentSection(): TemplateResult {
const contract = this.editorState?.contract;
if (!contract) {
return html`<div>No contract loaded</div>`;
}
return html`
<sdig-contract-content
.contract=${contract}
@field-change=${this.handleFieldChange}
></sdig-contract-content>
`;
}
private renderTermsSection(): TemplateResult {
const contract = this.editorState?.contract;
if (!contract) {
return html`<div>No contract loaded</div>`;
}
return html`
<sdig-contract-terms
.contract=${contract}
@field-change=${this.handleFieldChange}
></sdig-contract-terms>
`;
}
private renderSignaturesSection(): TemplateResult {
const contract = this.editorState?.contract;
if (!contract) {
return html`<div>No contract loaded</div>`;
}
return html`
<sdig-contract-signatures
.contract=${contract}
@field-change=${this.handleFieldChange}
></sdig-contract-signatures>
`;
}
private renderAttachmentsSection(): TemplateResult {
const contract = this.editorState?.contract;
if (!contract) {
return html`<div>No contract loaded</div>`;
}
return html`
<sdig-contract-attachments
.contract=${contract}
@field-change=${this.handleFieldChange}
></sdig-contract-attachments>
`;
}
private renderCollaborationSection(): TemplateResult {
const contract = this.editorState?.contract;
if (!contract) {
return html`<div>No contract loaded</div>`;
}
return html`
<sdig-contract-collaboration
.contract=${contract}
@field-change=${this.handleFieldChange}
></sdig-contract-collaboration>
`;
}
private renderAuditSection(): TemplateResult {
const contract = this.editorState?.contract;
if (!contract) {
return html`<div>No contract loaded</div>`;
}
return html`
<sdig-contract-audit
.contract=${contract}
></sdig-contract-audit>
`;
}
private renderPlaceholder(sectionConfig: typeof EDITOR_SECTIONS[0] | undefined, message: string): TemplateResult {
return html`
<div class="section-placeholder">
<dees-icon .iconFA=${sectionConfig?.icon || 'lucide:file'}></dees-icon>
<h3>${sectionConfig?.label || 'Section'}</h3>
<p>${message}</p>
</div>
`;
}
// ============================================================================
// RENDER
// ============================================================================
public render(): TemplateResult {
const contract = this.editorState?.contract;
const activeSection = this.editorState?.activeSection || 'overview';
const isDirty = this.editorState?.isDirty || false;
const isSaving = this.editorState?.isSaving || false;
const collaborators = this.editorState?.activeCollaborators || [];
return html`
<div class="editor-container">
<!-- Header -->
<div class="editor-header">
<div class="header-left">
<h1 class="contract-title">${contract?.title || 'Untitled Contract'}</h1>
${contract?.lifecycle?.currentStatus
? html`
<span class="contract-status ${this.getStatusClass(contract.lifecycle.currentStatus)}">
${this.formatStatus(contract.lifecycle.currentStatus)}
</span>
`
: ''}
</div>
<div class="header-right">
${isDirty
? html`
<div class="dirty-indicator">
<span class="dirty-dot"></span>
<span>Unsaved changes</span>
</div>
`
: ''}
${collaborators.length > 0
? html`
<div class="collaborators">
${collaborators.slice(0, 3).map(
(c) => html`
<div
class="collaborator-avatar"
style="background: ${c.color}"
title="${c.displayName}"
>
${c.displayName.charAt(0).toUpperCase()}
</div>
`
)}
${collaborators.length > 3
? html`
<div class="collaborator-avatar" style="background: #6b7280">
+${collaborators.length - 3}
</div>
`
: ''}
</div>
`
: ''}
<button class="btn btn-ghost" @click=${this.handleUndo} ?disabled=${!this.store?.canUndo()}>
<dees-icon .iconFA=${'lucide:undo-2'}></dees-icon>
</button>
<button class="btn btn-ghost" @click=${this.handleRedo} ?disabled=${!this.store?.canRedo()}>
<dees-icon .iconFA=${'lucide:redo-2'}></dees-icon>
</button>
</div>
</div>
<!-- Navigation -->
<nav class="editor-nav">
${EDITOR_SECTIONS.map(
(section) => html`
<button
class="nav-tab ${activeSection === section.id ? 'active' : ''}"
@click=${() => this.handleSectionChange(section.id)}
?disabled=${section.disabled}
>
<dees-icon .iconFA=${section.icon}></dees-icon>
<span>${section.label}</span>
${section.badge
? html`<span class="nav-badge">${section.badge}</span>`
: ''}
</button>
`
)}
</nav>
<!-- Main Content -->
<div class="editor-main">
<div class="editor-content">
${this.renderSectionContent()}
</div>
${this.showSidebar
? html`
<aside class="editor-sidebar">
<!-- Sidebar content - collaboration panel -->
</aside>
`
: ''}
</div>
<!-- Footer -->
<div class="editor-footer">
<div class="footer-left">
${contract?.updatedAt
? html`<span>Last updated: ${new Date(contract.updatedAt).toLocaleString()}</span>`
: ''}
${contract?.versionHistory?.currentVersionId
? html`<span>Version: ${contract.versionHistory.currentVersionId}</span>`
: ''}
</div>
<div class="footer-right">
${isDirty
? html`
<button class="btn btn-secondary" @click=${this.handleDiscard}>
Discard
</button>
`
: ''}
<button
class="btn btn-primary"
@click=${this.handleSave}
?disabled=${!isDirty || isSaving}
>
${isSaving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
${this.editorState?.isLoading
? html`
<div class="loading-overlay">
<dees-spinner></dees-spinner>
</div>
`
: ''}
</div>
`;
}
}

View File

@@ -0,0 +1,407 @@
/**
* @file state.ts
* @description Smartstate store for contract editor
*/
import { domtools } from '@design.estate/dees-element';
import type * as sdInterfaces from '@signature.digital/tools/interfaces';
import {
type IEditorState,
type TEditorSection,
type TEditorMode,
type IContractChange,
type IValidationError,
type IEditorUser,
createInitialEditorState,
} from './types.js';
// ============================================================================
// STATE STORE
// ============================================================================
/**
* Create a new editor state store instance
*/
export async function createEditorStore() {
const smartstate = new domtools.plugins.smartstate.Smartstate<{ editor: IEditorState }>();
// Initialize with default state (getStatePart is now async)
const statePart = await smartstate.getStatePart<IEditorState>('editor', createInitialEditorState(), 'soft');
// Create actions for state modifications
const setContractAction = statePart.createAction<{ contract: sdInterfaces.IPortableContract }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
contract: structuredClone(payload.contract),
originalContract: structuredClone(payload.contract),
isDirty: false,
undoStack: [],
redoStack: [],
})
);
const updateContractAction = statePart.createAction<{ path: string; value: unknown; description?: string; userId?: string }>(
async (statePartArg, payload) => {
const state = statePartArg.getState();
if (!state.contract) return state;
const previousValue = getNestedValue(state.contract, payload.path);
const change: IContractChange = {
id: crypto.randomUUID(),
timestamp: Date.now(),
path: payload.path,
previousValue,
newValue: payload.value,
description: payload.description || `Updated ${payload.path}`,
userId: payload.userId,
};
const updatedContract = setNestedValue(
structuredClone(state.contract),
payload.path,
payload.value
);
return {
...state,
contract: updatedContract,
isDirty: true,
undoStack: [...state.undoStack, change],
redoStack: [],
};
}
);
const setActiveSectionAction = statePart.createAction<{ section: TEditorSection }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
activeSection: payload.section,
})
);
const setEditorModeAction = statePart.createAction<{ mode: TEditorMode }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
editorMode: payload.mode,
})
);
const selectParagraphAction = statePart.createAction<{ paragraphId: string | null }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
selectedParagraphId: payload.paragraphId,
})
);
const selectPartyAction = statePart.createAction<{ partyId: string | null }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
selectedPartyId: payload.partyId,
})
);
const selectSignatureFieldAction = statePart.createAction<{ fieldId: string | null }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
selectedSignatureFieldId: payload.fieldId,
})
);
const undoAction = statePart.createAction<void>(
async (statePartArg) => {
const state = statePartArg.getState();
if (state.undoStack.length === 0 || !state.contract) return state;
const change = state.undoStack[state.undoStack.length - 1];
const updatedContract = setNestedValue(
structuredClone(state.contract),
change.path,
change.previousValue
);
return {
...state,
contract: updatedContract,
undoStack: state.undoStack.slice(0, -1),
redoStack: [...state.redoStack, change],
isDirty: state.undoStack.length > 1,
};
}
);
const redoAction = statePart.createAction<void>(
async (statePartArg) => {
const state = statePartArg.getState();
if (state.redoStack.length === 0 || !state.contract) return state;
const change = state.redoStack[state.redoStack.length - 1];
const updatedContract = setNestedValue(
structuredClone(state.contract),
change.path,
change.newValue
);
return {
...state,
contract: updatedContract,
undoStack: [...state.undoStack, change],
redoStack: state.redoStack.slice(0, -1),
isDirty: true,
};
}
);
const setLoadingAction = statePart.createAction<{ isLoading: boolean }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
isLoading: payload.isLoading,
})
);
const setSavingAction = statePart.createAction<{ isSaving: boolean }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
isSaving: payload.isSaving,
})
);
const markSavedAction = statePart.createAction<void>(
async (statePartArg) => {
const state = statePartArg.getState();
return {
...state,
originalContract: state.contract ? structuredClone(state.contract) : null,
isDirty: false,
isSaving: false,
};
}
);
const discardChangesAction = statePart.createAction<void>(
async (statePartArg) => {
const state = statePartArg.getState();
return {
...state,
contract: state.originalContract ? structuredClone(state.originalContract) : null,
isDirty: false,
undoStack: [],
redoStack: [],
};
}
);
const setValidationErrorsAction = statePart.createAction<{ errors: IValidationError[] }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
validationErrors: payload.errors,
})
);
const clearValidationErrorsAction = statePart.createAction<void>(
async (statePartArg) => ({
...statePartArg.getState(),
validationErrors: [],
})
);
const setCurrentUserAction = statePart.createAction<{ user: IEditorUser }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
currentUser: payload.user,
})
);
const setActiveCollaboratorsAction = statePart.createAction<{ collaborators: sdInterfaces.IUserPresence[] }>(
async (statePartArg, payload) => ({
...statePartArg.getState(),
activeCollaborators: payload.collaborators,
})
);
const addCollaboratorAction = statePart.createAction<{ collaborator: sdInterfaces.IUserPresence }>(
async (statePartArg, payload) => {
const state = statePartArg.getState();
if (state.activeCollaborators.find(c => c.userId === payload.collaborator.userId)) {
return state;
}
return {
...state,
activeCollaborators: [...state.activeCollaborators, payload.collaborator],
};
}
);
const removeCollaboratorAction = statePart.createAction<{ userId: string }>(
async (statePartArg, payload) => {
const state = statePartArg.getState();
return {
...state,
activeCollaborators: state.activeCollaborators.filter(c => c.userId !== payload.userId),
};
}
);
const updateCollaboratorAction = statePart.createAction<{ userId: string; updates: Partial<sdInterfaces.IUserPresence> }>(
async (statePartArg, payload) => {
const state = statePartArg.getState();
return {
...state,
activeCollaborators: state.activeCollaborators.map(c =>
c.userId === payload.userId ? { ...c, ...payload.updates } : c
),
};
}
);
return {
smartstate,
statePart,
// Getters
getContract: () => statePart.getState().contract,
getActiveSection: () => statePart.getState().activeSection,
isDirty: () => statePart.getState().isDirty,
isLoading: () => statePart.getState().isLoading,
isSaving: () => statePart.getState().isSaving,
// Contract operations
setContract: (contract: sdInterfaces.IPortableContract) => setContractAction.trigger({ contract }),
updateContract: (path: string, value: unknown, description?: string) => {
const state = statePart.getState();
return updateContractAction.trigger({ path, value, description, userId: state.currentUser?.userId });
},
// Navigation
setActiveSection: (section: TEditorSection) => setActiveSectionAction.trigger({ section }),
setEditorMode: (mode: TEditorMode) => setEditorModeAction.trigger({ mode }),
// Selection
selectParagraph: (paragraphId: string | null) => selectParagraphAction.trigger({ paragraphId }),
selectParty: (partyId: string | null) => selectPartyAction.trigger({ partyId }),
selectSignatureField: (fieldId: string | null) => selectSignatureFieldAction.trigger({ fieldId }),
// Undo/Redo
undo: () => undoAction.trigger(),
redo: () => redoAction.trigger(),
canUndo: () => statePart.getState().undoStack.length > 0,
canRedo: () => statePart.getState().redoStack.length > 0,
// Loading/Saving state
setLoading: (isLoading: boolean) => setLoadingAction.trigger({ isLoading }),
setSaving: (isSaving: boolean) => setSavingAction.trigger({ isSaving }),
markSaved: () => markSavedAction.trigger(),
// Discard changes
discardChanges: () => discardChangesAction.trigger(),
// Validation
setValidationErrors: (errors: IValidationError[]) => setValidationErrorsAction.trigger({ errors }),
clearValidationErrors: () => clearValidationErrorsAction.trigger(),
// User/Collaboration
setCurrentUser: (user: IEditorUser) => setCurrentUserAction.trigger({ user }),
setActiveCollaborators: (collaborators: sdInterfaces.IUserPresence[]) => setActiveCollaboratorsAction.trigger({ collaborators }),
addCollaborator: (collaborator: sdInterfaces.IUserPresence) => addCollaboratorAction.trigger({ collaborator }),
removeCollaborator: (userId: string) => removeCollaboratorAction.trigger({ userId }),
updateCollaborator: (userId: string, updates: Partial<sdInterfaces.IUserPresence>) => updateCollaboratorAction.trigger({ userId, updates }),
// Subscribe to state changes (using new API)
subscribe: (callback: (state: IEditorState) => void) => {
const subscription = statePart.select().subscribe(callback);
return () => subscription.unsubscribe();
},
};
}
// ============================================================================
// UTILITY FUNCTIONS
// ============================================================================
/**
* Get nested value from object by path
*/
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
const keys = path.split('.');
let current: unknown = obj;
for (const key of keys) {
if (current === null || current === undefined) return undefined;
if (typeof current !== 'object') return undefined;
// Handle array index
const arrayMatch = key.match(/^(\w+)\[(\d+)\]$/);
if (arrayMatch) {
const [, arrayKey, index] = arrayMatch;
current = (current as Record<string, unknown>)[arrayKey];
if (Array.isArray(current)) {
current = current[parseInt(index, 10)];
} else {
return undefined;
}
} else {
current = (current as Record<string, unknown>)[key];
}
}
return current;
}
/**
* Set nested value in object by path (immutably)
*/
function setNestedValue<T extends Record<string, unknown>>(
obj: T,
path: string,
value: unknown
): T {
const keys = path.split('.');
const result = { ...obj } as Record<string, unknown>;
let current = result;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
// Handle array index
const arrayMatch = key.match(/^(\w+)\[(\d+)\]$/);
if (arrayMatch) {
const [, arrayKey, index] = arrayMatch;
const arr = [...((current[arrayKey] as unknown[]) || [])];
if (i === keys.length - 2) {
arr[parseInt(index, 10)] = value;
current[arrayKey] = arr;
return result as T;
} else {
arr[parseInt(index, 10)] = { ...(arr[parseInt(index, 10)] as Record<string, unknown> || {}) };
current[arrayKey] = arr;
current = arr[parseInt(index, 10)] as Record<string, unknown>;
}
} else {
if (typeof current[key] !== 'object' || current[key] === null) {
current[key] = {};
} else {
current[key] = { ...(current[key] as Record<string, unknown>) };
}
current = current[key] as Record<string, unknown>;
}
}
const lastKey = keys[keys.length - 1];
const arrayMatch = lastKey.match(/^(\w+)\[(\d+)\]$/);
if (arrayMatch) {
const [, arrayKey, index] = arrayMatch;
const arr = [...((current[arrayKey] as unknown[]) || [])];
arr[parseInt(index, 10)] = value;
current[arrayKey] = arr;
} else {
current[lastKey] = value;
}
return result as T;
}
/**
* Type for editor store
*/
export type TEditorStore = Awaited<ReturnType<typeof createEditorStore>>;

View File

@@ -0,0 +1,228 @@
/**
* @file types.ts
* @description Editor-specific types and event interfaces
*/
import type * as sdInterfaces from '@signature.digital/tools/interfaces';
// ============================================================================
// EDITOR NAVIGATION
// ============================================================================
/**
* Available editor sections/tabs
*/
export type TEditorSection =
| 'overview'
| 'parties'
| 'content'
| 'terms'
| 'signatures'
| 'attachments'
| 'collaboration'
| 'audit';
/**
* Section configuration
*/
export interface IEditorSectionConfig {
id: TEditorSection;
label: string;
icon: string;
badge?: number | string;
disabled?: boolean;
}
/**
* Default section configurations
*/
export const EDITOR_SECTIONS: IEditorSectionConfig[] = [
{ id: 'overview', label: 'Overview', icon: 'lucide:file-text' },
{ id: 'parties', label: 'Parties & Roles', icon: 'lucide:users' },
{ id: 'content', label: 'Content', icon: 'lucide:file-edit' },
{ id: 'terms', label: 'Terms', icon: 'lucide:calculator' },
{ id: 'signatures', label: 'Signatures', icon: 'lucide:pen-tool' },
{ id: 'attachments', label: 'Attachments', icon: 'lucide:paperclip' },
{ id: 'collaboration', label: 'Collaboration', icon: 'lucide:message-circle' },
{ id: 'audit', label: 'Audit & History', icon: 'lucide:history' },
];
// ============================================================================
// EDITOR STATE
// ============================================================================
/**
* Current user in the editor
*/
export interface IEditorUser {
userId: string;
displayName: string;
email: string;
avatarUrl?: string;
color: string;
}
/**
* Editor mode
*/
export type TEditorMode = 'edit' | 'view' | 'review' | 'sign';
/**
* Editor state interface
*/
export interface IEditorState {
// Contract data
contract: sdInterfaces.IPortableContract | null;
originalContract: sdInterfaces.IPortableContract | null;
// UI state
activeSection: TEditorSection;
editorMode: TEditorMode;
isDirty: boolean;
isSaving: boolean;
isLoading: boolean;
// Selection state
selectedParagraphId: string | null;
selectedPartyId: string | null;
selectedSignatureFieldId: string | null;
// Collaboration
currentUser: IEditorUser | null;
activeCollaborators: sdInterfaces.IUserPresence[];
// Validation
validationErrors: IValidationError[];
// History
undoStack: IContractChange[];
redoStack: IContractChange[];
}
/**
* Initial editor state factory
*/
export function createInitialEditorState(): IEditorState {
return {
contract: null,
originalContract: null,
activeSection: 'overview',
editorMode: 'edit',
isDirty: false,
isSaving: false,
isLoading: false,
selectedParagraphId: null,
selectedPartyId: null,
selectedSignatureFieldId: null,
currentUser: null,
activeCollaborators: [],
validationErrors: [],
undoStack: [],
redoStack: [],
};
}
// ============================================================================
// CHANGE TRACKING
// ============================================================================
/**
* Contract change for undo/redo
*/
export interface IContractChange {
id: string;
timestamp: number;
path: string;
previousValue: unknown;
newValue: unknown;
description: string;
userId?: string;
}
/**
* Validation error
*/
export interface IValidationError {
path: string;
message: string;
severity: 'error' | 'warning' | 'info';
fieldLabel?: string;
}
// ============================================================================
// EVENTS
// ============================================================================
/**
* Contract change event detail
*/
export interface IContractChangeEventDetail {
path: string;
value: unknown;
previousValue?: unknown;
source?: 'user' | 'collaboration' | 'system';
}
/**
* Section change event detail
*/
export interface ISectionChangeEventDetail {
section: TEditorSection;
previousSection: TEditorSection;
}
/**
* Save event detail
*/
export interface ISaveEventDetail {
contract: sdInterfaces.IPortableContract;
isDraft: boolean;
}
/**
* Custom event types
*/
export interface IEditorEvents {
'contract-change': CustomEvent<IContractChangeEventDetail>;
'section-change': CustomEvent<ISectionChangeEventDetail>;
'contract-save': CustomEvent<ISaveEventDetail>;
'contract-discard': CustomEvent<void>;
'validation-error': CustomEvent<IValidationError[]>;
}
// ============================================================================
// UTILITY TYPES
// ============================================================================
/**
* Deep path type for nested object access
*/
export type TDeepPath<T, K extends keyof T = keyof T> = K extends string
? T[K] extends Record<string, unknown>
? `${K}` | `${K}.${TDeepPath<T[K]>}`
: `${K}`
: never;
/**
* Contract field path
*/
export type TContractPath = string; // Simplified for runtime use
/**
* Field metadata for UI rendering
*/
export interface IFieldMetadata {
path: TContractPath;
label: string;
description?: string;
required: boolean;
type: 'text' | 'textarea' | 'number' | 'date' | 'select' | 'multiselect' | 'checkbox' | 'custom';
options?: Array<{ value: string; label: string }>;
validation?: {
min?: number;
max?: number;
minLength?: number;
maxLength?: number;
pattern?: string;
};
}

View File

@@ -0,0 +1 @@
export * from './sdig-signbox.js';

View File

@@ -1,5 +1,5 @@
import { DeesElement, property, html, customElement, type TemplateResult, css, cssManager } from '@design.estate/dees-element';
import * as plugins from '../plugins.js';
import * as plugins from '../../plugins.js';
declare global {
interface HTMLElementTagNameMap {

View File

@@ -0,0 +1 @@
export * from './sdig-signpad.js';

View File

@@ -1,5 +1,5 @@
import { DeesElement, property, html, customElement, type TemplateResult, css, cssManager } from '@design.estate/dees-element';
import * as plugins from '../plugins.js';
import * as plugins from '../../plugins.js';
declare global {
interface HTMLElementTagNameMap {