Files
catalog/ts_web/elements/sdig-contract-signatures/sdig-contract-signatures.ts

841 lines
24 KiB
TypeScript

/**
* @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:PenTool' },
{ value: 'signed', label: 'Signed', color: '#10b981', icon: 'lucide:CheckCircle' },
{ value: 'declined', label: 'Declined', color: '#ef4444', icon: 'lucide:XCircle' },
];
const FIELD_TYPES = [
{ value: 'signature', label: 'Full Signature', icon: 'lucide:PenTool' },
{ value: 'initials', label: 'Initials', icon: 'lucide:Type' },
{ value: 'date', label: 'Date', icon: 'lucide:Calendar' },
{ value: 'text', label: 'Text Field', icon: 'lucide:TextCursor' },
];
@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 .icon=${'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 .icon=${'lucide:PenTool'}></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 .icon=${'lucide:CheckCircle'}></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 .icon=${'lucide:XCircle'}></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 .icon=${'lucide:PenTool'}></dees-icon>
Signature Fields
</div>
${!this.readonly
? html`
<button class="btn btn-primary" @click=${this.handleAddField}>
<dees-icon .icon=${'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 .icon=${'lucide:PenTool'}></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 .icon=${'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 .icon=${'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 .icon=${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 .icon=${'lucide:User'}></dees-icon>
${this.getPartyRoleName(field.roleId)}
</span>
<span class="type-badge">
<dees-icon .icon=${typeConfig.icon}></dees-icon>
${typeConfig.label}
</span>
${field.required
? html`
<span class="field-meta-item">
<dees-icon .icon=${'lucide:Asterisk'}></dees-icon>
Required
</span>
`
: ''}
${field.signedAt
? html`
<span class="field-meta-item">
<dees-icon .icon=${'lucide:Calendar'}></dees-icon>
${this.formatDate(field.signedAt)}
</span>
`
: ''}
</div>
</div>
<div class="field-status ${field.status}">
<dees-icon .icon=${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 .icon=${'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 .icon=${'lucide:Trash2'}></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>
`;
}
}