Files
catalog/ts_web/elements/sdig-contracteditor/sdig-contracteditor.ts

892 lines
25 KiB
TypeScript

/**
* @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';
import '../sdig-collaboration-sidebar/sdig-collaboration-sidebar.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-ghost.active {
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
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();
}
private handleCommentClick(e: CustomEvent) {
// Navigate to collaboration section and highlight comment
this.store?.setActiveSection('collaboration');
this.dispatchEvent(
new CustomEvent('comment-focus', {
detail: e.detail,
bubbles: true,
composed: true,
})
);
}
private handleSuggestionClick(e: CustomEvent) {
// Navigate to collaboration section and highlight suggestion
this.store?.setActiveSection('collaboration');
this.dispatchEvent(
new CustomEvent('suggestion-focus', {
detail: e.detail,
bubbles: true,
composed: true,
})
);
}
private handleSidebarAddComment(e: CustomEvent) {
this.dispatchEvent(
new CustomEvent('comment-added', {
detail: e.detail,
bubbles: true,
composed: true,
})
);
}
// ============================================================================
// 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 .icon=${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 .icon=${'lucide:Undo2'}></dees-icon>
</button>
<button class="btn btn-ghost" @click=${this.handleRedo} ?disabled=${!this.store?.canRedo()}>
<dees-icon .icon=${'lucide:Redo2'}></dees-icon>
</button>
<button
class="btn btn-ghost ${this.showSidebar ? 'active' : ''}"
@click=${() => (this.showSidebar = !this.showSidebar)}
title="${this.showSidebar ? 'Hide sidebar' : 'Show sidebar'}"
>
<dees-icon .icon=${'lucide:PanelRight'}></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 .icon=${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">
<sdig-collaboration-sidebar
.contract=${contract}
@comment-click=${this.handleCommentClick}
@suggestion-click=${this.handleSuggestionClick}
@add-comment=${this.handleSidebarAddComment}
></sdig-collaboration-sidebar>
</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>
`;
}
}