feat(workspace): introduce a responsive signature workspace demo and remove legacy contract editor components
This commit is contained in:
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@signature.digital/catalog',
|
||||
version: '1.2.0',
|
||||
version: '1.3.0',
|
||||
description: 'A comprehensive catalog of customizable web components designed for building and managing e-signature applications.'
|
||||
}
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
// 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';
|
||||
export * from './sdig-collaboration-sidebar/index.js';
|
||||
|
||||
// Signature components
|
||||
export * from './sdig-signbox/index.js';
|
||||
export * from './sdig-signpad/index.js';
|
||||
|
||||
// Product workspace component
|
||||
export * from './sdig-workspace/index.js';
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export * from './sdig-collaboration-sidebar.js';
|
||||
@@ -1,846 +0,0 @@
|
||||
/**
|
||||
* @file sdig-collaboration-sidebar.ts
|
||||
* @description Compact collaboration sidebar for the contract editor
|
||||
*/
|
||||
|
||||
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-collaboration-sidebar': SdigCollaborationSidebar;
|
||||
}
|
||||
}
|
||||
|
||||
// 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-collaboration-sidebar')
|
||||
export class SdigCollaborationSidebar extends DeesElement {
|
||||
// ============================================================================
|
||||
// STATIC
|
||||
// ============================================================================
|
||||
|
||||
public static demo = () => html`
|
||||
<div style="width: 320px; height: 600px; border: 1px solid #ccc;">
|
||||
<sdig-collaboration-sidebar
|
||||
.contract=${plugins.sdDemodata.demoContract}
|
||||
></sdig-collaboration-sidebar>
|
||||
</div>
|
||||
`;
|
||||
|
||||
public static styles = [
|
||||
cssManager.defaultStyles,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Presence bar */
|
||||
.presence-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
|
||||
border-bottom: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
|
||||
}
|
||||
|
||||
.presence-avatars {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.presence-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
margin-left: -6px;
|
||||
border: 2px solid ${cssManager.bdTheme('#f9fafb', '#111111')};
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.presence-avatar:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.presence-avatar .status-dot {
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
right: -1px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #10b981;
|
||||
border: 2px solid ${cssManager.bdTheme('#f9fafb', '#111111')};
|
||||
}
|
||||
|
||||
.presence-avatar .status-dot.away {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.presence-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: ${cssManager.bdTheme('#e5e7eb', '#3f3f46')};
|
||||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
margin-left: -6px;
|
||||
border: 2px solid ${cssManager.bdTheme('#f9fafb', '#111111')};
|
||||
}
|
||||
|
||||
.presence-label {
|
||||
font-size: 12px;
|
||||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
}
|
||||
|
||||
/* Scrollable content */
|
||||
.sidebar-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
/* Collapsible sections */
|
||||
.collapsible-section {
|
||||
margin-bottom: 12px;
|
||||
border: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: ${cssManager.bdTheme('#ffffff', '#0a0a0a')};
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.section-header:hover {
|
||||
background: ${cssManager.bdTheme('#f3f4f6', '#18181b')};
|
||||
}
|
||||
|
||||
.section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
|
||||
}
|
||||
|
||||
.section-title dees-icon {
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
}
|
||||
|
||||
.section-badge {
|
||||
padding: 2px 6px;
|
||||
border-radius: 9999px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
background: ${cssManager.bdTheme('#dbeafe', '#1e3a5f')};
|
||||
color: ${cssManager.bdTheme('#1e40af', '#93c5fd')};
|
||||
}
|
||||
|
||||
.section-chevron {
|
||||
font-size: 14px;
|
||||
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.section-chevron.expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.section-body {
|
||||
padding: 0;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.2s ease, padding 0.2s ease;
|
||||
}
|
||||
|
||||
.section-body.expanded {
|
||||
padding: 12px;
|
||||
max-height: 1000px;
|
||||
}
|
||||
|
||||
/* Compact comment cards */
|
||||
.comment-card {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.comment-card:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.comment-card:hover {
|
||||
background: ${cssManager.bdTheme('#f3f4f6', '#18181b')};
|
||||
}
|
||||
|
||||
.comment-card.resolved {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.comment-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: white;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.comment-body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.comment-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.comment-author {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: ${cssManager.bdTheme('#374151', '#d1d5db')};
|
||||
}
|
||||
|
||||
.comment-time {
|
||||
font-size: 10px;
|
||||
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||||
}
|
||||
|
||||
.comment-preview {
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: ${cssManager.bdTheme('#6b7280', '#9ca3af')};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.comment-replies {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
color: ${cssManager.bdTheme('#3b82f6', '#60a5fa')};
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.comment-replies dees-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Suggestion cards */
|
||||
.suggestion-card {
|
||||
padding: 10px;
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
|
||||
border-radius: 6px;
|
||||
margin-bottom: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.suggestion-card:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.suggestion-card:hover {
|
||||
background: ${cssManager.bdTheme('#f3f4f6', '#18181b')};
|
||||
}
|
||||
|
||||
.suggestion-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.suggestion-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.suggestion-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 9999px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.suggestion-status dees-icon {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
font-family: 'Roboto Mono', monospace;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.diff-removed {
|
||||
background: ${cssManager.bdTheme('#fee2e2', '#450a0a')};
|
||||
color: ${cssManager.bdTheme('#991b1b', '#fca5a5')};
|
||||
text-decoration: line-through;
|
||||
padding: 1px 3px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.diff-added {
|
||||
background: ${cssManager.bdTheme('#d1fae5', '#064e3b')};
|
||||
color: ${cssManager.bdTheme('#065f46', '#6ee7b7')};
|
||||
padding: 1px 3px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
/* Quick add comment */
|
||||
.quick-add {
|
||||
padding: 12px;
|
||||
border-top: 1px solid ${cssManager.bdTheme('#e5e5e5', '#27272a')};
|
||||
background: ${cssManager.bdTheme('#f9fafb', '#111111')};
|
||||
}
|
||||
|
||||
.quick-add-input {
|
||||
width: 100%;
|
||||
padding: 10px 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;
|
||||
resize: none;
|
||||
min-height: 60px;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.quick-add-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)')};
|
||||
}
|
||||
|
||||
.quick-add-input::placeholder {
|
||||
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||||
}
|
||||
|
||||
.quick-add-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
font-size: 12px;
|
||||
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-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
color: ${cssManager.bdTheme('#9ca3af', '#6b7280')};
|
||||
}
|
||||
|
||||
.empty-state dees-icon {
|
||||
font-size: 32px;
|
||||
margin-bottom: 8px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// PROPERTIES
|
||||
// ============================================================================
|
||||
|
||||
@property({ type: Object })
|
||||
public accessor contract: plugins.sdInterfaces.IPortableContract | null = null;
|
||||
|
||||
@property({ type: Boolean })
|
||||
public accessor readonly: boolean = false;
|
||||
|
||||
// ============================================================================
|
||||
// STATE
|
||||
// ============================================================================
|
||||
|
||||
@state()
|
||||
private accessor commentsExpanded: boolean = true;
|
||||
|
||||
@state()
|
||||
private accessor suggestionsExpanded: boolean = true;
|
||||
|
||||
@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: [],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
userId: '2',
|
||||
userName: 'Bob Johnson',
|
||||
userColor: '#10b981',
|
||||
content: 'Should we add an automatic renewal clause?',
|
||||
createdAt: Date.now() - 7200000,
|
||||
resolved: false,
|
||||
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,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
userId: '3',
|
||||
userName: 'Carol Davis',
|
||||
userColor: '#f59e0b',
|
||||
originalText: '30 days',
|
||||
suggestedText: '60 days',
|
||||
path: 'paragraphs.5.content',
|
||||
status: 'pending',
|
||||
createdAt: Date.now() - 3600000,
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// EVENT HANDLERS
|
||||
// ============================================================================
|
||||
|
||||
private handleCommentClick(comment: IComment) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('comment-click', {
|
||||
detail: { comment },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private handleSuggestionClick(suggestion: ISuggestion) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('suggestion-click', {
|
||||
detail: { suggestion },
|
||||
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 = '';
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('add-comment', {
|
||||
detail: { comment: newComment },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// HELPERS
|
||||
// ============================================================================
|
||||
|
||||
private formatTimeAgo(timestamp: number): string {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||
if (seconds < 60) return 'now';
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d`;
|
||||
}
|
||||
|
||||
private getActivePresence(): IPresence[] {
|
||||
const fiveMinutesAgo = Date.now() - 300000;
|
||||
return this.presenceList.filter((p) => p.lastActive > fiveMinutesAgo);
|
||||
}
|
||||
|
||||
private getOpenComments(): IComment[] {
|
||||
return this.comments.filter((c) => !c.resolved);
|
||||
}
|
||||
|
||||
private getPendingSuggestions(): ISuggestion[] {
|
||||
return this.suggestions.filter((s) => s.status === 'pending');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RENDER
|
||||
// ============================================================================
|
||||
|
||||
public render(): TemplateResult {
|
||||
const activePresence = this.getActivePresence();
|
||||
const openComments = this.getOpenComments();
|
||||
const pendingSuggestions = this.getPendingSuggestions();
|
||||
|
||||
return html`
|
||||
<div class="sidebar-container">
|
||||
<!-- Presence Bar -->
|
||||
<div class="presence-bar">
|
||||
<div class="presence-avatars">
|
||||
${activePresence.slice(0, 3).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 > 3
|
||||
? html`<div class="presence-count">+${activePresence.length - 3}</div>`
|
||||
: ''}
|
||||
</div>
|
||||
<span class="presence-label">${activePresence.length} active</span>
|
||||
</div>
|
||||
|
||||
<!-- Scrollable Content -->
|
||||
<div class="sidebar-content">
|
||||
<!-- Comments Section -->
|
||||
<div class="collapsible-section">
|
||||
<div
|
||||
class="section-header"
|
||||
@click=${() => (this.commentsExpanded = !this.commentsExpanded)}
|
||||
>
|
||||
<div class="section-title">
|
||||
<dees-icon .icon=${'lucide:MessageCircle'}></dees-icon>
|
||||
Comments
|
||||
${openComments.length > 0
|
||||
? html`<span class="section-badge">${openComments.length}</span>`
|
||||
: ''}
|
||||
</div>
|
||||
<dees-icon
|
||||
class="section-chevron ${this.commentsExpanded ? 'expanded' : ''}"
|
||||
.icon=${'lucide:ChevronDown'}
|
||||
></dees-icon>
|
||||
</div>
|
||||
<div class="section-body ${this.commentsExpanded ? 'expanded' : ''}">
|
||||
${this.comments.length > 0
|
||||
? this.comments.slice(0, 5).map((comment) => this.renderCommentCard(comment))
|
||||
: html`
|
||||
<div class="empty-state">
|
||||
<dees-icon .icon=${'lucide:MessageSquare'}></dees-icon>
|
||||
<p>No comments yet</p>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Suggestions Section -->
|
||||
<div class="collapsible-section">
|
||||
<div
|
||||
class="section-header"
|
||||
@click=${() => (this.suggestionsExpanded = !this.suggestionsExpanded)}
|
||||
>
|
||||
<div class="section-title">
|
||||
<dees-icon .icon=${'lucide:GitPullRequest'}></dees-icon>
|
||||
Suggestions
|
||||
${pendingSuggestions.length > 0
|
||||
? html`<span class="section-badge">${pendingSuggestions.length}</span>`
|
||||
: ''}
|
||||
</div>
|
||||
<dees-icon
|
||||
class="section-chevron ${this.suggestionsExpanded ? 'expanded' : ''}"
|
||||
.icon=${'lucide:ChevronDown'}
|
||||
></dees-icon>
|
||||
</div>
|
||||
<div class="section-body ${this.suggestionsExpanded ? 'expanded' : ''}">
|
||||
${this.suggestions.length > 0
|
||||
? this.suggestions.slice(0, 5).map((suggestion) => this.renderSuggestionCard(suggestion))
|
||||
: html`
|
||||
<div class="empty-state">
|
||||
<dees-icon .icon=${'lucide:Edit3'}></dees-icon>
|
||||
<p>No suggestions yet</p>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Add Comment -->
|
||||
${!this.readonly
|
||||
? html`
|
||||
<div class="quick-add">
|
||||
<textarea
|
||||
class="quick-add-input"
|
||||
placeholder="Add a comment..."
|
||||
.value=${this.newCommentText}
|
||||
@input=${(e: Event) => (this.newCommentText = (e.target as HTMLTextAreaElement).value)}
|
||||
></textarea>
|
||||
<div class="quick-add-actions">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
@click=${this.handleAddComment}
|
||||
?disabled=${!this.newCommentText.trim()}
|
||||
>
|
||||
<dees-icon .icon=${'lucide:Send'}></dees-icon>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderCommentCard(comment: IComment): TemplateResult {
|
||||
return html`
|
||||
<div
|
||||
class="comment-card ${comment.resolved ? 'resolved' : ''}"
|
||||
@click=${() => this.handleCommentClick(comment)}
|
||||
>
|
||||
<div class="comment-avatar" style="background: ${comment.userColor}">
|
||||
${comment.userName.charAt(0)}
|
||||
</div>
|
||||
<div class="comment-body">
|
||||
<div class="comment-meta">
|
||||
<span class="comment-author">${comment.userName}</span>
|
||||
<span class="comment-time">${this.formatTimeAgo(comment.createdAt)}</span>
|
||||
</div>
|
||||
<div class="comment-preview">${comment.content}</div>
|
||||
${comment.replies.length > 0
|
||||
? html`
|
||||
<div class="comment-replies">
|
||||
<dees-icon .icon=${'lucide:MessageSquare'}></dees-icon>
|
||||
${comment.replies.length} ${comment.replies.length === 1 ? 'reply' : 'replies'}
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSuggestionCard(suggestion: ISuggestion): TemplateResult {
|
||||
return html`
|
||||
<div class="suggestion-card" @click=${() => this.handleSuggestionClick(suggestion)}>
|
||||
<div class="suggestion-header">
|
||||
<div class="suggestion-user">
|
||||
<div class="comment-avatar" style="background: ${suggestion.userColor}; width: 20px; height: 20px; font-size: 9px;">
|
||||
${suggestion.userName.charAt(0)}
|
||||
</div>
|
||||
<span class="comment-author">${suggestion.userName}</span>
|
||||
</div>
|
||||
<div class="suggestion-status ${suggestion.status}">
|
||||
<dees-icon .icon=${suggestion.status === 'pending' ? 'lucide:Clock' : suggestion.status === 'accepted' ? 'lucide:Check' : 'lucide:X'}></dees-icon>
|
||||
${suggestion.status}
|
||||
</div>
|
||||
</div>
|
||||
<div class="suggestion-diff">
|
||||
<span class="diff-removed">${suggestion.originalText}</span>
|
||||
<span> → </span>
|
||||
<span class="diff-added">${suggestion.suggestedText}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './sdig-contract-attachments.js';
|
||||
@@ -1,806 +0,0 @@
|
||||
/**
|
||||
* @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:FileText', color: '#3b82f6', label: 'Document' },
|
||||
image: { icon: 'lucide:Image', color: '#10b981', label: 'Image' },
|
||||
spreadsheet: { icon: 'lucide:Sheet', color: '#22c55e', label: 'Spreadsheet' },
|
||||
pdf: { icon: 'lucide:FileType', 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 .icon=${'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 .icon=${'lucide:UploadCloud'}></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 .icon=${'lucide:FileX'}></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 .icon=${'lucide:Files'}></dees-icon>
|
||||
Prior Contracts
|
||||
</div>
|
||||
${!this.readonly
|
||||
? html`
|
||||
<button class="btn btn-secondary" @click=${this.handleAddPriorContract}>
|
||||
<dees-icon .icon=${'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 .icon=${'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 .icon=${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 .icon=${'lucide:Calendar'}></dees-icon>
|
||||
${this.formatDate(attachment.uploadedAt)}
|
||||
</span>
|
||||
<span class="attachment-meta-item">
|
||||
<dees-icon .icon=${'lucide:User'}></dees-icon>
|
||||
${this.getPartyName(attachment.uploadedBy)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="attachment-actions">
|
||||
<button class="btn btn-ghost" title="Download">
|
||||
<dees-icon .icon=${'lucide:Download'}></dees-icon>
|
||||
</button>
|
||||
<button class="btn btn-ghost" title="Preview">
|
||||
<dees-icon .icon=${'lucide:Eye'}></dees-icon>
|
||||
</button>
|
||||
${!this.readonly
|
||||
? html`
|
||||
<button
|
||||
class="btn btn-ghost btn-danger"
|
||||
title="Delete"
|
||||
@click=${() => this.handleDeleteAttachment(attachment.id)}
|
||||
>
|
||||
<dees-icon .icon=${'lucide:Trash2'}></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 .icon=${'lucide:FileText'}></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 .icon=${'lucide:ExternalLink'}></dees-icon>
|
||||
View
|
||||
</button>
|
||||
${!this.readonly
|
||||
? html`
|
||||
<button
|
||||
class="btn btn-ghost btn-danger"
|
||||
@click=${() => this.handleRemovePriorContract(index)}
|
||||
>
|
||||
<dees-icon .icon=${'lucide:Unlink'}></dees-icon>
|
||||
</button>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './sdig-contract-audit.js';
|
||||
@@ -1,772 +0,0 @@
|
||||
/**
|
||||
* @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:FileEdit', color: '#f59e0b' },
|
||||
{ id: 'review', label: 'Review', icon: 'lucide:Eye', color: '#3b82f6' },
|
||||
{ id: 'pending', label: 'Pending Signatures', icon: 'lucide:PenTool', color: '#8b5cf6' },
|
||||
{ id: 'signed', label: 'Signed', icon: 'lucide:CheckCircle', color: '#10b981' },
|
||||
{ id: 'executed', label: 'Executed', icon: 'lucide:ShieldCheck', color: '#059669' },
|
||||
];
|
||||
|
||||
// Event type configuration
|
||||
const EVENT_TYPES = {
|
||||
created: { icon: 'lucide:PlusCircle', color: '#10b981', label: 'Created' },
|
||||
updated: { icon: 'lucide:Pencil', color: '#3b82f6', label: 'Updated' },
|
||||
status_change: { icon: 'lucide:ArrowRightCircle', color: '#8b5cf6', label: 'Status Changed' },
|
||||
signature: { icon: 'lucide:PenTool', color: '#10b981', label: 'Signature' },
|
||||
comment: { icon: 'lucide:MessageCircle', color: '#f59e0b', label: 'Comment' },
|
||||
attachment: { icon: 'lucide:Paperclip', color: '#6366f1', label: 'Attachment' },
|
||||
viewed: { icon: 'lucide:Eye', color: '#6b7280', label: 'Viewed' },
|
||||
shared: { icon: 'lucide:Share2', 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 .icon=${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 .icon=${'lucide:History'}></dees-icon>
|
||||
Activity Log
|
||||
</div>
|
||||
<button class="btn btn-secondary">
|
||||
<dees-icon .icon=${'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 .icon=${'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 .icon=${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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './sdig-contract-collaboration.js';
|
||||
@@ -1,972 +0,0 @@
|
||||
/**
|
||||
* @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 .icon=${'lucide:Share2'}></dees-icon>
|
||||
Share
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Comments Section -->
|
||||
<div class="section-card">
|
||||
<div class="section-header">
|
||||
<div class="section-title">
|
||||
<dees-icon .icon=${'lucide:MessageCircle'}></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 .icon=${'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 .icon=${'lucide:MessageSquare'}></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 .icon=${'lucide:GitPullRequest'}></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 .icon=${'lucide:Edit3'}></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 .icon=${comment.resolved ? 'lucide:RotateCcw' : 'lucide:Check'}></dees-icon>
|
||||
${comment.resolved ? 'Reopen' : 'Resolve'}
|
||||
</button>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
|
||||
${comment.anchorText
|
||||
? html`
|
||||
<div class="comment-anchor">
|
||||
<dees-icon .icon=${'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 .icon=${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 .icon=${'lucide:Check'}></dees-icon>
|
||||
Accept
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm" @click=${() => this.handleRejectSuggestion(suggestion.id)}>
|
||||
<dees-icon .icon=${'lucide:X'}></dees-icon>
|
||||
Reject
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './sdig-contract-content.js';
|
||||
@@ -1,920 +0,0 @@
|
||||
/**
|
||||
* @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:FileText' },
|
||||
{ value: 'definition', label: 'Definition', icon: 'lucide:BookOpen' },
|
||||
{ value: 'obligation', label: 'Obligation', icon: 'lucide:CheckSquare' },
|
||||
{ value: 'condition', label: 'Condition', icon: 'lucide:GitBranch' },
|
||||
{ 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 .icon=${'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 .icon=${'lucide:List'}></dees-icon>
|
||||
</button>
|
||||
<button
|
||||
class="view-toggle-btn ${this.viewMode === 'outline' ? 'active' : ''}"
|
||||
@click=${() => (this.viewMode = 'outline')}
|
||||
title="Outline view"
|
||||
>
|
||||
<dees-icon .icon=${'lucide:LayoutList'}></dees-icon>
|
||||
</button>
|
||||
</div>
|
||||
${!this.readonly
|
||||
? html`
|
||||
<button class="btn btn-primary" @click=${() => this.handleAddParagraph()}>
|
||||
<dees-icon .icon=${'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 .icon=${'lucide:FileText'}></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 .icon=${'lucide:Plus'}></dees-icon>
|
||||
Add paragraph
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
`
|
||||
: html`
|
||||
<div class="empty-state">
|
||||
<dees-icon .icon=${'lucide:FilePlus'}></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 .icon=${'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 .icon=${'lucide:GripVertical'}></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 .icon=${'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 .icon=${'lucide:ChevronUp'}></dees-icon>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost"
|
||||
@click=${(e: Event) => { e.stopPropagation(); this.handleMoveParagraph(paragraph.uniqueId, 'down'); }}
|
||||
title="Move down"
|
||||
>
|
||||
<dees-icon .icon=${'lucide:ChevronDown'}></dees-icon>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-danger"
|
||||
@click=${(e: Event) => { e.stopPropagation(); this.handleDeleteParagraph(paragraph.uniqueId); }}
|
||||
title="Delete"
|
||||
>
|
||||
<dees-icon .icon=${'lucide:Trash2'}></dees-icon>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
|
||||
${childParagraphs.length > 0
|
||||
? html`
|
||||
<div class="child-paragraphs">
|
||||
${childParagraphs.map((child, childIndex) => this.renderParagraph(child, childIndex))}
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './sdig-contract-header.js';
|
||||
@@ -1,558 +0,0 @@
|
||||
/**
|
||||
* @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 .icon=${'lucide:ChevronDown'} 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 .icon=${'lucide:Download'}></dees-icon>
|
||||
</button>
|
||||
<button class="action-btn" @click=${this.handleDuplicate} title="Duplicate">
|
||||
<dees-icon .icon=${'lucide:Copy'}></dees-icon>
|
||||
</button>
|
||||
<button class="action-btn" @click=${this.handleShare} title="Share">
|
||||
<dees-icon .icon=${'lucide:Share2'}></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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './sdig-contract-metadata.js';
|
||||
@@ -1,820 +0,0 @@
|
||||
/**
|
||||
* @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 .icon=${'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 .icon=${'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 .icon=${'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 .icon=${'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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './sdig-contract-parties.js';
|
||||
@@ -1,736 +0,0 @@
|
||||
/**
|
||||
* @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:PenTool' },
|
||||
{ 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:CheckCircle' },
|
||||
{ value: 'guarantor', label: 'Guarantor', icon: 'lucide:Shield' },
|
||||
{ value: 'beneficiary', label: 'Beneficiary', icon: 'lucide:UserCheck' },
|
||||
];
|
||||
|
||||
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 .icon=${'lucide:Users2'}></dees-icon>
|
||||
Available Roles
|
||||
</div>
|
||||
${!this.readonly
|
||||
? html`
|
||||
<button class="btn btn-secondary btn-sm" @click=${this.handleAddRole}>
|
||||
<dees-icon .icon=${'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 .icon=${'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 .icon=${'lucide:UserPlus'}></dees-icon>
|
||||
Involved Parties (${parties.length})
|
||||
</div>
|
||||
${!this.readonly
|
||||
? html`
|
||||
<button class="btn btn-primary btn-sm" @click=${this.handleAddParty}>
|
||||
<dees-icon .icon=${'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 .icon=${'lucide:UserPlus'}></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
|
||||
.icon=${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 .icon=${'lucide:PenTool'}></dees-icon>
|
||||
Signature required
|
||||
</span>
|
||||
`
|
||||
: ''}
|
||||
${role.defaultSigningOrder > 0
|
||||
? html`
|
||||
<span class="role-meta-item">
|
||||
<dees-icon .icon=${'lucide:ListOrdered'}></dees-icon>
|
||||
Order: ${role.defaultSigningOrder}
|
||||
</span>
|
||||
`
|
||||
: ''}
|
||||
${role.minParties
|
||||
? html`
|
||||
<span class="role-meta-item">
|
||||
<dees-icon .icon=${'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 .icon=${'lucide:Mail'}></dees-icon>
|
||||
${deliveryEmail}
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
${deliveryPhone
|
||||
? html`
|
||||
<div class="party-detail">
|
||||
<dees-icon .icon=${'lucide:Phone'}></dees-icon>
|
||||
${deliveryPhone}
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
${actingAsProxy
|
||||
? html`
|
||||
<div class="party-detail">
|
||||
<dees-icon .icon=${'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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './sdig-contract-signatures.js';
|
||||
@@ -1,840 +0,0 @@
|
||||
/**
|
||||
* @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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from './sdig-contract-terms.js';
|
||||
@@ -1,873 +0,0 @@
|
||||
/**
|
||||
* @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:CheckSquare', 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 .icon=${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 .icon=${'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 .icon=${'lucide:Pencil'}></dees-icon>
|
||||
</button>
|
||||
</td>
|
||||
`
|
||||
: ''}
|
||||
</tr>
|
||||
`
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
`
|
||||
: html`
|
||||
<div class="empty-state">
|
||||
<dees-icon .icon=${'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 .icon=${'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 .icon=${'lucide:Pencil'}></dees-icon>
|
||||
</button>
|
||||
</td>
|
||||
`
|
||||
: ''}
|
||||
</tr>
|
||||
`
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
`
|
||||
: html`
|
||||
<div class="empty-state">
|
||||
<dees-icon .icon=${'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 .icon=${'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 .icon=${'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 .icon=${'lucide:Pencil'}></dees-icon>
|
||||
</button>
|
||||
</td>
|
||||
`
|
||||
: ''}
|
||||
</tr>
|
||||
`
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
`
|
||||
: html`
|
||||
<div class="empty-state">
|
||||
<dees-icon .icon=${'lucide:CheckSquare'}></dees-icon>
|
||||
<h4>No Obligations</h4>
|
||||
<p>Add obligations to track party responsibilities</p>
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
/**
|
||||
* @file index.ts
|
||||
* @description Export barrel for sdig-contracteditor module
|
||||
*/
|
||||
|
||||
export * from './sdig-contracteditor.js';
|
||||
export * from './types.js';
|
||||
export * from './state.js';
|
||||
@@ -1,891 +0,0 @@
|
||||
/**
|
||||
* @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>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -1,407 +0,0 @@
|
||||
/**
|
||||
* @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>>;
|
||||
@@ -1,228 +0,0 @@
|
||||
/**
|
||||
* @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:FileText' },
|
||||
{ id: 'parties', label: 'Parties & Roles', icon: 'lucide:Users' },
|
||||
{ id: 'content', label: 'Content', icon: 'lucide:FileEdit' },
|
||||
{ id: 'terms', label: 'Terms', icon: 'lucide:Calculator' },
|
||||
{ id: 'signatures', label: 'Signatures', icon: 'lucide:PenTool' },
|
||||
{ id: 'attachments', label: 'Attachments', icon: 'lucide:Paperclip' },
|
||||
{ id: 'collaboration', label: 'Collaboration', icon: 'lucide:MessageCircle' },
|
||||
{ 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;
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { DeesElement, property, html, customElement, type TemplateResult, css, cssManager } from '@design.estate/dees-element';
|
||||
import * as plugins from '../../plugins.js';
|
||||
import { DeesElement, html, customElement, type TemplateResult, css, cssManager } from '@design.estate/dees-element';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
@@ -12,6 +11,7 @@ export class SignBox extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<sdig-signbox></sdig-signbox>
|
||||
`;
|
||||
public static demoGroups = ['Signature Digital Primitives'];
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -80,17 +80,19 @@ export class SignBox extends DeesElement {
|
||||
<sdig-signpad></sdig-signpad>
|
||||
<div class="actions">
|
||||
<div class="button" @click=${async () => {
|
||||
await this.shadowRoot.querySelector('sdig-signpad').clear();
|
||||
await this.shadowRoot?.querySelector('sdig-signpad')?.clear();
|
||||
}}>
|
||||
Clear
|
||||
</div>
|
||||
<div class="button" @click=${async () => {
|
||||
await this.shadowRoot.querySelector('sdig-signpad').undo();
|
||||
await this.shadowRoot?.querySelector('sdig-signpad')?.undo();
|
||||
}}>
|
||||
Undo
|
||||
</div>
|
||||
<div class="button" @click=${async () => {
|
||||
const signature = await this.shadowRoot.querySelector('sdig-signpad').toData();
|
||||
const signaturePad = this.shadowRoot?.querySelector('sdig-signpad');
|
||||
if (!signaturePad) return;
|
||||
const signature = await signaturePad.toData();
|
||||
this.dispatchEvent(new CustomEvent('signature', {
|
||||
detail: {
|
||||
signature,
|
||||
@@ -104,4 +106,4 @@ export class SignBox extends DeesElement {
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DeesElement, property, html, customElement, type TemplateResult, css, cssManager } from '@design.estate/dees-element';
|
||||
import { DeesElement, html, customElement, type TemplateResult, css, cssManager } from '@design.estate/dees-element';
|
||||
import * as plugins from '../../plugins.js';
|
||||
|
||||
declare global {
|
||||
@@ -12,6 +12,7 @@ export class SignPad extends DeesElement {
|
||||
public static demo = () => html`
|
||||
<sdig-signpad></sdig-signpad>
|
||||
`;
|
||||
public static demoGroups = ['Signature Digital Primitives'];
|
||||
|
||||
|
||||
constructor() {
|
||||
@@ -59,11 +60,14 @@ export class SignPad extends DeesElement {
|
||||
`;
|
||||
}
|
||||
|
||||
public signaturePad: typeof plugins.signaturePad.prototype;
|
||||
public signaturePad?: typeof plugins.signaturePad.prototype;
|
||||
|
||||
public async firstUpdated() {
|
||||
const domtools = await this.domtoolsPromise;
|
||||
await this.domtoolsPromise;
|
||||
const mainbox = this.shadowRoot?.querySelector('.mainbox');
|
||||
if (!mainbox) return;
|
||||
const canvas = document.createElement('canvas');
|
||||
this.shadowRoot.querySelector('.mainbox').appendChild(canvas);
|
||||
mainbox.appendChild(canvas);
|
||||
await this.resizeCanvas();
|
||||
this.signaturePad = new plugins.signaturePad(canvas, {
|
||||
|
||||
@@ -72,10 +76,11 @@ export class SignPad extends DeesElement {
|
||||
}
|
||||
|
||||
public async resizeCanvas() {
|
||||
const mainbox = this.shadowRoot.querySelector('.mainbox');
|
||||
const mainbox = this.shadowRoot?.querySelector('.mainbox');
|
||||
const canvas = this.shadowRoot?.querySelector('canvas');
|
||||
if (!mainbox || !canvas) return;
|
||||
const mainboxWidth = mainbox.clientWidth;
|
||||
const mainboxHeight = mainbox.clientHeight;
|
||||
const canvas = this.shadowRoot.querySelector('canvas');
|
||||
canvas.width = mainboxWidth;
|
||||
canvas.height = mainboxHeight;
|
||||
if (this.signaturePad) {
|
||||
@@ -84,22 +89,22 @@ export class SignPad extends DeesElement {
|
||||
}
|
||||
|
||||
public async clear() {
|
||||
this.signaturePad.clear();
|
||||
this.signaturePad?.clear();
|
||||
}
|
||||
|
||||
public async toData() {
|
||||
const returnData = this.signaturePad.toData();
|
||||
const returnData = this.signaturePad?.toData() || [];
|
||||
return returnData;
|
||||
}
|
||||
|
||||
public async fromData(dataArrayArg: any[]) {
|
||||
this.signaturePad.fromData(dataArrayArg);
|
||||
this.signaturePad?.fromData(dataArrayArg);
|
||||
}
|
||||
|
||||
public async toSVG() {
|
||||
return this.signaturePad.toSVG({
|
||||
return this.signaturePad?.toSVG({
|
||||
includeBackgroundColor: false,
|
||||
});
|
||||
}) || '';
|
||||
}
|
||||
|
||||
public async undo() {
|
||||
@@ -107,4 +112,4 @@ export class SignPad extends DeesElement {
|
||||
data.pop();
|
||||
await this.fromData(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export * from './sdig-workspace.shared.js';
|
||||
export * from './sdig-workspace-inbox.js';
|
||||
export * from './sdig-workspace-compose.js';
|
||||
export * from './sdig-workspace-sign.js';
|
||||
export * from './sdig-workspace-audit.js';
|
||||
export * from './sdig-workspace-developers.js';
|
||||
export * from './sdig-workspace-placeholder.js';
|
||||
export * from './sdig-workspace.js';
|
||||
@@ -0,0 +1,37 @@
|
||||
import { DeesElement, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
|
||||
import { actionButton, demoRecipients, icon, pill, topBar, workspaceBaseStyles, workspaceDemoFrame } from './sdig-workspace.shared.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sdig-workspace-audit': SdigWorkspaceAudit;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('sdig-workspace-audit')
|
||||
export class SdigWorkspaceAudit extends DeesElement {
|
||||
public static demo = () => workspaceDemoFrame(html`<sdig-workspace-audit></sdig-workspace-audit>`);
|
||||
public static demoGroups = ['Signature Digital Workspace'];
|
||||
|
||||
public static styles = [workspaceBaseStyles, css`
|
||||
.audit-grid { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 20px; }
|
||||
.event-row { display: grid; grid-template-columns: 24px 180px 1fr 200px; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--border-subtle); align-items: center; }
|
||||
@media (max-width: 920px) { .audit-grid { grid-template-columns: 1fr; } .event-row { grid-template-columns: 24px 1fr; } .event-row .hide-mobile { display: none; } }
|
||||
`];
|
||||
|
||||
public render(): TemplateResult {
|
||||
const events = [
|
||||
['2026-05-02 14:32:18 UTC', 'Sarah Chen', 'Document signed', '81.221.4.18 · Brussels, BE', '0x4a7b…f29c', 'success'],
|
||||
['2026-05-02 14:31:54 UTC', 'Sarah Chen', 'Signature adopted (typed)', '81.221.4.18 · Brussels, BE', '0x4a7b…f29c', 'info'],
|
||||
['2026-05-02 14:28:02 UTC', 'Sarah Chen', 'Document opened', '81.221.4.18 · Brussels, BE', '', 'default'],
|
||||
['2026-05-02 11:02:11 UTC', 'Philipp K.', 'Document sent for signature', '92.42.114.7 · Berlin, DE', '0x1c8a…3b6f', 'info'],
|
||||
['2026-05-02 10:54:22 UTC', 'Philipp K.', 'Document created', '92.42.114.7 · Berlin, DE', '0x1c8a…3b6f', 'default'],
|
||||
];
|
||||
return html`
|
||||
${topBar({ breadcrumb: ['signature.digital', 'Inbox', 'doc_8mK3pL', 'Audit Trail'], title: 'Audit Trail', subtitle: pill('completed · cryptographically sealed', 'success', true), actions: html`${actionButton('Certificate (PDF)', 'outline', 'download')}${actionButton('Verify on chain', 'outline', 'hash')}` })}
|
||||
<div class="content-scroll audit-grid">
|
||||
<div class="card"><div style="height: 36px; padding: 0 16px; border-bottom: 1px solid var(--border-subtle); display: flex; align-items: center; justify-content: space-between;"><span style="font-size: 12px; font-weight: 600;">Event log</span><span class="mono" style="font-size: 10px; color: var(--text-muted);">${events.length} events · immutable</span></div>${events.map((event) => html`<div class="event-row"><div><span style="display: block; width: 8px; height: 8px; border-radius: 50%; background: ${event[5] === 'success' ? 'var(--success)' : event[5] === 'info' ? 'var(--accent)' : 'var(--text-dim)'};"></span></div><div class="mono hide-mobile" style="font-size: 11px; color: var(--text-muted);">${event[0]}</div><div><div style="font-size: 12px; font-weight: 500;">${event[2]}</div><div style="font-size: 10px; color: var(--text-muted); margin-top: 2px;">by ${event[1]} ${event[4] ? html`<span class="mono" style="color: var(--accent); margin-left: 8px;">${event[4]}</span>` : ''}</div></div><div class="mono hide-mobile" style="font-size: 10px; color: var(--text-muted); text-align: right;">${event[3]}</div></div>`)}</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 16px;"><div class="card" style="padding: 16px;"><div class="label-upper">Document hash</div><div class="mono" style="font-size: 11px; color: var(--accent); word-break: break-all; line-height: 1.5; padding: 10px; background: var(--bg-el); border-radius: 4px; border: 1px solid var(--border-subtle);">0x4a7b8f29c91e3d2a5b6c8e0f1d3c5a7b9d2e4f6a8c1e3d5f7b9c1e3a5b7d9f0e</div></div><div class="card" style="padding: 16px;"><div class="label-upper">Signers</div>${demoRecipients.map((recipient) => html`<div class="recipient-line"><span class="avatar" style="background: ${recipient.color};">${recipient.name.split(' ').map((part) => part[0]).slice(0, 2).join('')}</span><div style="flex: 1;"><div style="font-size: 12px;">${recipient.name}</div><div class="mono" style="font-size: 10px; color: var(--text-muted);">${recipient.email}</div></div>${icon('check', 12)}</div>`)}</div><div class="card" style="padding: 16px; border-color: rgba(34,197,94,0.2);"><div style="display: inline-flex; align-items: center; gap: 6px; font-size: 11px; color: var(--success); margin-bottom: 6px;">${icon('shield', 13)} eIDAS Qualified · ESIGN Act compliant</div><div style="font-size: 11px; color: var(--text-muted); line-height: 1.55;">Open-source verifier available. Anyone can independently validate this signature against the public ledger.</div></div></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
import { DeesElement, state, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
|
||||
import { actionButton, demoFields, demoRecipients, fakeDocument, icon, pill, topBar, workspaceBaseStyles, workspaceDemoFrame, type IFieldPlacement, type IRecipient } from './sdig-workspace.shared.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sdig-workspace-compose': SdigWorkspaceCompose;
|
||||
}
|
||||
}
|
||||
|
||||
type TFieldDefinition = {
|
||||
type: IFieldPlacement['type'];
|
||||
icon: string;
|
||||
label: string;
|
||||
w: number;
|
||||
h: number;
|
||||
};
|
||||
|
||||
type TResizeHandle = 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w' | 'nw';
|
||||
|
||||
type TFieldInteraction = {
|
||||
fieldId: string;
|
||||
mode: 'move' | 'resize';
|
||||
handle?: TResizeHandle;
|
||||
startClientX: number;
|
||||
startClientY: number;
|
||||
startField: IFieldPlacement;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
};
|
||||
|
||||
const fieldDefinitions: TFieldDefinition[] = [
|
||||
{ type: 'signature', icon: 'sign', label: 'Signature', w: 200, h: 50 },
|
||||
{ type: 'initials', icon: 'type', label: 'Initials', w: 120, h: 32 },
|
||||
{ type: 'date', icon: 'calendar', label: 'Date', w: 120, h: 32 },
|
||||
{ type: 'text', icon: 'type', label: 'Text field', w: 220, h: 32 },
|
||||
{ type: 'check', icon: 'check', label: 'Checkbox', w: 120, h: 32 },
|
||||
];
|
||||
|
||||
const resizeHandles: TResizeHandle[] = ['n', 'ne', 'e', 'se', 's', 'sw', 'w', 'nw'];
|
||||
|
||||
@customElement('sdig-workspace-compose')
|
||||
export class SdigWorkspaceCompose extends DeesElement {
|
||||
public static demo = () => workspaceDemoFrame(html`<sdig-workspace-compose></sdig-workspace-compose>`);
|
||||
public static demoGroups = ['Signature Digital Workspace'];
|
||||
|
||||
@state() private accessor step: number = 2;
|
||||
@state() private accessor activeRecipient: number = 0;
|
||||
@state() private accessor draggedRecipientId: number | null = null;
|
||||
@state() private accessor selectedFieldId: string | null = null;
|
||||
@state() private accessor recipients: IRecipient[] = [...demoRecipients];
|
||||
@state() private accessor fields: IFieldPlacement[] = [...demoFields];
|
||||
private draggedFieldDefinition: TFieldDefinition | null = null;
|
||||
private draggedFieldGrabOffset: { x: number; y: number } | null = null;
|
||||
private fieldInteraction: TFieldInteraction | null = null;
|
||||
|
||||
public static styles = [workspaceBaseStyles, css`
|
||||
.stepper { height: 44px; flex-shrink: 0; border-bottom: 1px solid var(--border-subtle); display: flex; align-items: center; padding: 0 24px; gap: 24px; overflow-x: auto; }
|
||||
.step { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-muted); white-space: nowrap; background: transparent; }
|
||||
.step-number { width: 20px; height: 20px; border-radius: 50%; display: flex; align-items: center; justify-content: center; background: var(--bg-input); border: 1px solid var(--border); color: var(--text-muted); font-size: 10px; font-weight: 700; }
|
||||
.step.active { color: var(--text); font-weight: 500; }
|
||||
.step.active .step-number { background: var(--accent); border-color: var(--accent); color: white; }
|
||||
.step.done .step-number { background: var(--success); border-color: var(--success); color: white; }
|
||||
.compose-workspace { flex: 1; display: flex; overflow: hidden; }
|
||||
.palette { width: 260px; border-right: 1px solid var(--border-subtle); padding: 16px; background: var(--bg-el); overflow-y: auto; flex-shrink: 0; }
|
||||
.right-panel { width: 280px; border-left: 1px solid var(--border-subtle); padding: 16px; background: var(--bg-el); overflow-y: auto; flex-shrink: 0; }
|
||||
.field-tool { width: var(--tool-w); height: var(--tool-h); display: flex; align-items: center; gap: 8px; padding: 0 10px; background: color-mix(in srgb, var(--recipient-color) 8%, var(--bg-card)); border: 1.5px dashed var(--recipient-color); border-radius: 4px; font-size: 12px; color: var(--recipient-color); margin-bottom: 8px; cursor: grab; }
|
||||
.field-tool:active { cursor: grabbing; }
|
||||
.field-tool span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.swatch { width: 10px; height: 10px; border-radius: 2px; background: var(--recipient-color, var(--accent)); flex-shrink: 0; }
|
||||
.document-stage { flex: 1; overflow: auto; background: hsl(0 0% 8%); display: flex; flex-direction: column; align-items: center; padding: 32px; gap: 20px; }
|
||||
:host-context(sdig-workspace[theme='light']) .document-stage { background: hsl(0 0% 92%); }
|
||||
.recipient-line { cursor: grab; }
|
||||
.recipient-line.dragging { opacity: 0.45; border-color: var(--accent); }
|
||||
.page-drop-target { outline: 1px dashed transparent; outline-offset: 8px; }
|
||||
.page-drop-target.drag-over { outline-color: var(--accent); }
|
||||
.field-box { user-select: none; touch-action: none; }
|
||||
.field-box.selected { z-index: 5; cursor: move; }
|
||||
.field-content { width: 100%; height: 100%; display: flex; align-items: center; gap: 6px; pointer-events: none; overflow: hidden; }
|
||||
.field-content span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.resize-handle { position: absolute; z-index: 2; width: 9px; height: 9px; border-radius: 50%; background: var(--bg-card); border: 1.5px solid var(--field-color); box-shadow: 0 0 0 2px var(--bg-card); touch-action: none; }
|
||||
.resize-handle.n { top: -6px; left: 50%; transform: translateX(-50%); cursor: ns-resize; }
|
||||
.resize-handle.ne { top: -6px; right: -6px; cursor: nesw-resize; }
|
||||
.resize-handle.e { right: -6px; top: 50%; transform: translateY(-50%); cursor: ew-resize; }
|
||||
.resize-handle.se { right: -6px; bottom: -6px; cursor: nwse-resize; }
|
||||
.resize-handle.s { bottom: -6px; left: 50%; transform: translateX(-50%); cursor: ns-resize; }
|
||||
.resize-handle.sw { left: -6px; bottom: -6px; cursor: nesw-resize; }
|
||||
.resize-handle.w { left: -6px; top: 50%; transform: translateY(-50%); cursor: ew-resize; }
|
||||
.resize-handle.nw { left: -6px; top: -6px; cursor: nwse-resize; }
|
||||
.field-editor { margin-top: 16px; padding: 12px; }
|
||||
.field-editor-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.field-control { display: flex; flex-direction: column; gap: 4px; font-size: 10px; color: var(--text-muted); }
|
||||
.field-control.full { grid-column: 1 / -1; }
|
||||
.field-control input, .field-control select { width: 100%; height: 30px; padding: 0 8px; border: 1px solid var(--border); border-radius: 5px; background: var(--bg-input); color: var(--text); font-size: 12px; outline: none; }
|
||||
.field-control input:focus, .field-control select:focus { border-color: var(--accent); }
|
||||
@media (max-width: 920px) { .compose-workspace { flex-direction: column; overflow: auto; } .palette, .right-panel { width: 100%; border: 0; border-bottom: 1px solid var(--border-subtle); } .document-page { width: 560px; } }
|
||||
`];
|
||||
|
||||
public disconnectedCallback = async () => {
|
||||
this.stopFieldInteraction();
|
||||
await super.disconnectedCallback();
|
||||
};
|
||||
|
||||
private recipientColor(id: number): string {
|
||||
return this.recipients.find((recipient) => recipient.id === id)?.color || 'var(--accent)';
|
||||
}
|
||||
|
||||
private fieldIcon(type: IFieldPlacement['type']): string {
|
||||
if (type === 'signature') return 'sign';
|
||||
if (type === 'date') return 'calendar';
|
||||
if (type === 'check') return 'check';
|
||||
return 'type';
|
||||
}
|
||||
|
||||
private fieldDefinition(type: IFieldPlacement['type']): TFieldDefinition {
|
||||
return fieldDefinitions.find((definition) => definition.type === type) || fieldDefinitions[0];
|
||||
}
|
||||
|
||||
private clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
private updateField(fieldId: string, patch: Partial<IFieldPlacement>) {
|
||||
this.fields = this.fields.map((field) => field.id === fieldId ? { ...field, ...patch } : field);
|
||||
}
|
||||
|
||||
private updateSelectedField(patch: Partial<IFieldPlacement>) {
|
||||
if (!this.selectedFieldId) return;
|
||||
this.updateField(this.selectedFieldId, patch);
|
||||
}
|
||||
|
||||
private updateSelectedFieldNumber(property: 'x' | 'y' | 'w' | 'h', event: Event) {
|
||||
const value = Number((event.target as HTMLInputElement).value);
|
||||
if (!Number.isFinite(value)) return;
|
||||
const min = property === 'w' || property === 'h' ? 16 : 0;
|
||||
this.updateSelectedField({ [property]: Math.max(min, Math.round(value)) } as Partial<IFieldPlacement>);
|
||||
}
|
||||
|
||||
private resetSelectedFieldSize(field: IFieldPlacement) {
|
||||
const definition = this.fieldDefinition(field.type);
|
||||
this.updateSelectedField({ w: definition.w, h: definition.h });
|
||||
}
|
||||
|
||||
private removeSelectedField() {
|
||||
if (!this.selectedFieldId) return;
|
||||
this.fields = this.fields.filter((field) => field.id !== this.selectedFieldId);
|
||||
this.selectedFieldId = null;
|
||||
}
|
||||
|
||||
private handleDocumentClick = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest('.field-box')) return;
|
||||
this.selectedFieldId = null;
|
||||
};
|
||||
|
||||
private startFieldInteraction(event: PointerEvent, field: IFieldPlacement, mode: TFieldInteraction['mode'], handle?: TResizeHandle) {
|
||||
if (event.button !== 0) return;
|
||||
const page = this.shadowRoot?.querySelector('.document-page') as HTMLElement | null;
|
||||
if (!page) return;
|
||||
const pageRect = page.getBoundingClientRect();
|
||||
this.selectedFieldId = field.id;
|
||||
this.fieldInteraction = {
|
||||
fieldId: field.id,
|
||||
mode,
|
||||
handle,
|
||||
startClientX: event.clientX,
|
||||
startClientY: event.clientY,
|
||||
startField: { ...field },
|
||||
pageWidth: pageRect.width,
|
||||
pageHeight: pageRect.height,
|
||||
};
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
window.addEventListener('pointermove', this.handleFieldPointerMove, { passive: false });
|
||||
window.addEventListener('pointerup', this.stopFieldInteraction);
|
||||
window.addEventListener('pointercancel', this.stopFieldInteraction);
|
||||
}
|
||||
|
||||
private startFieldMove(event: PointerEvent, field: IFieldPlacement) {
|
||||
this.startFieldInteraction(event, field, 'move');
|
||||
}
|
||||
|
||||
private startFieldResize(event: PointerEvent, field: IFieldPlacement, handle: TResizeHandle) {
|
||||
this.startFieldInteraction(event, field, 'resize', handle);
|
||||
}
|
||||
|
||||
private handleFieldPointerMove = (event: PointerEvent) => {
|
||||
if (!this.fieldInteraction) return;
|
||||
event.preventDefault();
|
||||
const interaction = this.fieldInteraction;
|
||||
const dx = event.clientX - interaction.startClientX;
|
||||
const dy = event.clientY - interaction.startClientY;
|
||||
const start = interaction.startField;
|
||||
|
||||
if (interaction.mode === 'move') {
|
||||
this.updateField(interaction.fieldId, {
|
||||
x: Math.round(this.clamp(start.x + dx, 0, interaction.pageWidth - start.w)),
|
||||
y: Math.round(this.clamp(start.y + dy, 0, interaction.pageHeight - start.h)),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const minWidth = 32;
|
||||
const minHeight = 24;
|
||||
let x = start.x;
|
||||
let y = start.y;
|
||||
let w = start.w;
|
||||
let h = start.h;
|
||||
const handle = interaction.handle || 'se';
|
||||
|
||||
if (handle.includes('e')) {
|
||||
w = this.clamp(start.w + dx, minWidth, interaction.pageWidth - start.x);
|
||||
}
|
||||
if (handle.includes('s')) {
|
||||
h = this.clamp(start.h + dy, minHeight, interaction.pageHeight - start.y);
|
||||
}
|
||||
if (handle.includes('w')) {
|
||||
x = this.clamp(start.x + dx, 0, start.x + start.w - minWidth);
|
||||
w = start.x + start.w - x;
|
||||
}
|
||||
if (handle.includes('n')) {
|
||||
y = this.clamp(start.y + dy, 0, start.y + start.h - minHeight);
|
||||
h = start.y + start.h - y;
|
||||
}
|
||||
|
||||
this.updateField(interaction.fieldId, {
|
||||
x: Math.round(x),
|
||||
y: Math.round(y),
|
||||
w: Math.round(w),
|
||||
h: Math.round(h),
|
||||
});
|
||||
};
|
||||
|
||||
private stopFieldInteraction = () => {
|
||||
this.fieldInteraction = null;
|
||||
window.removeEventListener('pointermove', this.handleFieldPointerMove);
|
||||
window.removeEventListener('pointerup', this.stopFieldInteraction);
|
||||
window.removeEventListener('pointercancel', this.stopFieldInteraction);
|
||||
};
|
||||
|
||||
private reorderRecipient(targetId: number) {
|
||||
if (this.draggedRecipientId === null || this.draggedRecipientId === targetId) return;
|
||||
const next = [...this.recipients];
|
||||
const fromIndex = next.findIndex((recipient) => recipient.id === this.draggedRecipientId);
|
||||
const toIndex = next.findIndex((recipient) => recipient.id === targetId);
|
||||
if (fromIndex === -1 || toIndex === -1) return;
|
||||
const [moved] = next.splice(fromIndex, 1);
|
||||
next.splice(toIndex, 0, moved);
|
||||
this.recipients = next.map((recipient, index) => ({ ...recipient, order: index + 1 }));
|
||||
this.draggedRecipientId = null;
|
||||
}
|
||||
|
||||
private addFieldFromDrop(event: DragEvent) {
|
||||
event.preventDefault();
|
||||
const page = event.currentTarget as HTMLElement;
|
||||
page.classList.remove('drag-over');
|
||||
const transferredType = event.dataTransfer?.getData('application/x-signature-field') as IFieldPlacement['type'];
|
||||
if (!this.draggedFieldDefinition && !transferredType) return;
|
||||
const definition = this.draggedFieldDefinition || this.fieldDefinition(transferredType);
|
||||
const transferredOffset = event.dataTransfer?.getData('application/x-signature-field-offset');
|
||||
const offset = this.draggedFieldGrabOffset || (transferredOffset ? JSON.parse(transferredOffset) as { x: number; y: number } : { x: definition.w / 2, y: definition.h / 2 });
|
||||
const rect = page.getBoundingClientRect();
|
||||
const x = Math.round(event.clientX - rect.left - offset.x);
|
||||
const y = Math.round(event.clientY - rect.top - offset.y);
|
||||
const nextField: IFieldPlacement = {
|
||||
id: `field_${Date.now()}`,
|
||||
type: definition.type,
|
||||
x: Math.max(0, Math.min(Math.max(0, rect.width - definition.w), x)),
|
||||
y: Math.max(0, Math.min(Math.max(0, rect.height - definition.h), y)),
|
||||
w: definition.w,
|
||||
h: definition.h,
|
||||
page: 1,
|
||||
recipient: this.activeRecipient,
|
||||
label: definition.label,
|
||||
};
|
||||
this.fields = [...this.fields, nextField];
|
||||
this.selectedFieldId = nextField.id;
|
||||
this.draggedFieldDefinition = null;
|
||||
this.draggedFieldGrabOffset = null;
|
||||
}
|
||||
|
||||
private startFieldToolDrag(event: DragEvent, fieldType: TFieldDefinition) {
|
||||
const toolRect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const offset = {
|
||||
x: Math.round(event.clientX - toolRect.left),
|
||||
y: Math.round(event.clientY - toolRect.top),
|
||||
};
|
||||
this.draggedFieldDefinition = fieldType;
|
||||
this.draggedFieldGrabOffset = offset;
|
||||
event.dataTransfer?.setData('application/x-signature-field', fieldType.type);
|
||||
event.dataTransfer?.setData('application/x-signature-field-offset', JSON.stringify(offset));
|
||||
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'copy';
|
||||
}
|
||||
|
||||
private endFieldToolDrag() {
|
||||
this.draggedFieldDefinition = null;
|
||||
this.draggedFieldGrabOffset = null;
|
||||
}
|
||||
|
||||
private renderFieldEditor(field: IFieldPlacement): TemplateResult {
|
||||
return html`
|
||||
<div class="card field-editor">
|
||||
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px;">
|
||||
<div style="font-size: 11px; font-weight: 600;">Field editor</div>
|
||||
${pill(this.fieldDefinition(field.type).label, 'info', true)}
|
||||
</div>
|
||||
<div class="field-editor-grid">
|
||||
<label class="field-control full">Label<input .value=${field.label} @input=${(event: Event) => this.updateSelectedField({ label: (event.target as HTMLInputElement).value })} /></label>
|
||||
<label class="field-control full">Assigned signer<select .value=${String(field.recipient)} @change=${(event: Event) => this.updateSelectedField({ recipient: Number((event.target as HTMLSelectElement).value) })}>${this.recipients.map((recipient) => html`<option value=${String(recipient.id)}>${recipient.order}. ${recipient.name}</option>`)}</select></label>
|
||||
<label class="field-control">X<input type="number" min="0" .value=${String(field.x)} @input=${(event: Event) => this.updateSelectedFieldNumber('x', event)} /></label>
|
||||
<label class="field-control">Y<input type="number" min="0" .value=${String(field.y)} @input=${(event: Event) => this.updateSelectedFieldNumber('y', event)} /></label>
|
||||
<label class="field-control">Width<input type="number" min="16" .value=${String(field.w)} @input=${(event: Event) => this.updateSelectedFieldNumber('w', event)} /></label>
|
||||
<label class="field-control">Height<input type="number" min="16" .value=${String(field.h)} @input=${(event: Event) => this.updateSelectedFieldNumber('h', event)} /></label>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px; margin-top: 12px;">
|
||||
<button class="btn outline small" style="flex: 1;" @click=${() => this.resetSelectedFieldSize(field)}>Reset size</button>
|
||||
<button class="btn ghost small" style="color: var(--error);" @click=${() => this.removeSelectedField()}>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderResizeHandles(field: IFieldPlacement): TemplateResult {
|
||||
return html`${resizeHandles.map((handle) => html`<span class="resize-handle ${handle}" @pointerdown=${(event: PointerEvent) => this.startFieldResize(event, field, handle)}></span>`)}`;
|
||||
}
|
||||
|
||||
private renderStepper(): TemplateResult {
|
||||
const labels = ['Upload', 'Place fields', 'Recipients & routing', 'Review & send'];
|
||||
return html`
|
||||
<div class="stepper">
|
||||
${labels.map((label, index) => {
|
||||
const stepNumber = index + 1;
|
||||
return html`<button class="step ${stepNumber === this.step ? 'active' : stepNumber < this.step ? 'done' : ''}" @click=${() => this.step = stepNumber}><span class="step-number">${stepNumber < this.step ? '✓' : stepNumber}</span><span>${label}</span>${index < labels.length - 1 ? html`<span style="width: 24px; height: 1px; background: var(--border); margin-left: 8px;"></span>` : ''}</button>`;
|
||||
})}
|
||||
<div style="flex: 1;"></div><span class="mono" style="font-size: 11px; color: var(--text-muted);">doc_8mK3pL · 14 pages · 2.4 MB</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
const selectedField = this.fields.find((field) => field.id === this.selectedFieldId);
|
||||
|
||||
return html`
|
||||
${topBar({ breadcrumb: ['signature.digital', 'Inbox', 'Compose'], title: 'Master Services Agreement', subtitle: pill('Draft · auto-saved'), actions: html`${actionButton('Save draft', 'ghost')}${actionButton('Preview', 'outline', 'eye')}${actionButton('Send for signature', 'primary', 'send')}` })}
|
||||
${this.renderStepper()}
|
||||
<div class="compose-workspace">
|
||||
<div class="palette">
|
||||
<div class="label-upper">Drag onto document</div>
|
||||
${fieldDefinitions.map((fieldType) => html`<div class="field-tool" style="--tool-w: ${fieldType.w}px; --tool-h: ${fieldType.h}px; --recipient-color: ${this.recipientColor(this.activeRecipient)};" draggable="true" @dragstart=${(event: DragEvent) => this.startFieldToolDrag(event, fieldType)} @dragend=${() => this.endFieldToolDrag()}>${icon(fieldType.icon, 14)}<span style="flex: 1;">${fieldType.label}</span></div>`)}
|
||||
<div style="height: 1px; background: var(--border-subtle); margin: 20px 0 16px;"></div>
|
||||
<div class="label-upper">Active for</div>
|
||||
${this.recipients.map((recipient) => html`<div class="recipient-line ${this.activeRecipient === recipient.id ? 'active' : ''}" @click=${() => this.activeRecipient = recipient.id}><span class="swatch" style="--recipient-color: ${recipient.color};"></span><span style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${recipient.name.split(' ')[0]}</span><span class="mono" style="font-size: 10px; color: var(--text-muted);">${this.fields.filter((field) => field.recipient === recipient.id).length}</span></div>`)}
|
||||
</div>
|
||||
<div class="document-stage">
|
||||
<div class="document-page page-drop-target" @click=${this.handleDocumentClick} @dragover=${(event: DragEvent) => { event.preventDefault(); if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'; (event.currentTarget as HTMLElement).classList.add('drag-over'); }} @dragleave=${(event: DragEvent) => (event.currentTarget as HTMLElement).classList.remove('drag-over')} @drop=${(event: DragEvent) => this.addFieldFromDrop(event)}>
|
||||
${fakeDocument()}
|
||||
${this.fields.map((field) => html`<div class="field-box ${this.selectedFieldId === field.id ? 'selected' : ''}" style="--x: ${field.x}px; --y: ${field.y}px; --w: ${field.w}px; --h: ${field.h}px; --field-color: ${this.recipientColor(field.recipient)};" @click=${() => this.selectedFieldId = field.id} @pointerdown=${(event: PointerEvent) => this.startFieldMove(event, field)}><div class="field-content">${icon(this.fieldIcon(field.type), 12)}<span>${field.label}</span></div>${this.selectedFieldId === field.id ? this.renderResizeHandles(field) : ''}</div>`)}
|
||||
<div class="mono" style="position: absolute; bottom: 12px; right: 16px; font-size: 9px; color: hsl(0 0% 60%);">Page 1 of 14</div>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center; gap: 10px; font-size: 11px; color: var(--text-muted);">${actionButton('Prev', 'outline')}${html`<span class="mono">1 / 14</span>`}${actionButton('Next', 'outline')}</div>
|
||||
</div>
|
||||
<div class="right-panel">
|
||||
<div class="label-upper">Signing order · drag to reorder</div>
|
||||
${this.recipients.map((recipient) => html`<div class="recipient-line ${this.draggedRecipientId === recipient.id ? 'dragging' : ''}" draggable="true" @dragstart=${() => this.draggedRecipientId = recipient.id} @dragover=${(event: DragEvent) => event.preventDefault()} @drop=${() => this.reorderRecipient(recipient.id)} @dragend=${() => this.draggedRecipientId = null}><span class="mono" style="width: 14px; font-size: 10px; color: var(--text-muted);">${recipient.order}</span><span class="avatar" style="background: ${recipient.color};">${recipient.name.split(' ').map((part) => part[0]).slice(0, 2).join('')}</span><div style="flex: 1; min-width: 0;"><div style="font-size: 12px; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${recipient.name}</div><div class="mono" style="font-size: 10px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${recipient.email}</div></div>${icon('more', 12)}</div>`)}
|
||||
${selectedField ? this.renderFieldEditor(selectedField) : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { DeesElement, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
|
||||
import { actionButton, icon, pill, topBar, workspaceBaseStyles, workspaceDemoFrame } from './sdig-workspace.shared.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sdig-workspace-developers': SdigWorkspaceDevelopers;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('sdig-workspace-developers')
|
||||
export class SdigWorkspaceDevelopers extends DeesElement {
|
||||
public static demo = () => workspaceDemoFrame(html`<sdig-workspace-developers></sdig-workspace-developers>`);
|
||||
public static demoGroups = ['Signature Digital Workspace'];
|
||||
|
||||
public static styles = [workspaceBaseStyles, css`
|
||||
.developer-grid { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 20px; }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; }
|
||||
.metric-card { padding: 14px; }
|
||||
.metric-value { font-family: 'Plus Jakarta Sans', Inter, sans-serif; font-size: 22px; font-weight: 600; letter-spacing: -0.02em; }
|
||||
pre.code { margin: 0; padding: 16px; background: var(--bg-el); border: 1px solid var(--border-subtle); border-radius: 6px; font-size: 12px; line-height: 1.7; color: var(--text-sec); overflow: auto; }
|
||||
@media (max-width: 920px) { .developer-grid, .stats-grid { grid-template-columns: 1fr; } }
|
||||
`];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`
|
||||
${topBar({ breadcrumb: ['signature.digital', 'Developers'], title: 'Developers', subtitle: pill('API · v0.42', 'info', true), actions: html`${actionButton('View on GitHub', 'outline', 'github')}${actionButton('New API key', 'primary', 'plus')}` })}
|
||||
<div class="content-scroll developer-grid">
|
||||
<div style="display: flex; flex-direction: column; gap: 16px;">
|
||||
<div class="card" style="padding: 20px;"><div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px;"><span style="font-size: 14px; font-weight: 600;">Send a document in 8 lines</span><span class="pill">node</span></div><pre class="code mono">import { Signature } from '@signature.digital/sdk';
|
||||
|
||||
const sig = new Signature(process.env.SIGD_KEY);
|
||||
|
||||
await sig.documents.send({
|
||||
file: './msa.pdf',
|
||||
recipients: [{ name: 'Sarah Chen', email: 'sarah@acme.com' }],
|
||||
fields: 'auto',
|
||||
});</pre></div>
|
||||
<div class="stats-grid">${[['Requests this month', '14,892', '+8.2%'], ['P95 latency', '142ms', '-12ms'], ['Error rate', '0.04%', '✓']].map((metric) => html`<div class="card metric-card"><div class="metric-value">${metric[1]}</div><div style="font-size: 10px; color: var(--text-muted); margin-top: 2px;">${metric[0]}</div><div class="mono" style="font-size: 10px; color: var(--success); margin-top: 6px;">${metric[2]}</div></div>`)}</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; gap: 16px;">
|
||||
<div class="card" style="padding: 16px;"><div style="display: flex; align-items: center; gap: 8px; margin-bottom: 10px;">${icon('github', 14)}<span style="font-size: 12px; font-weight: 600;">signature-digital/core</span></div><div style="font-size: 11px; color: var(--text-muted); line-height: 1.5;">MIT-licensed. Self-host on your own infra, or use signature.digital cloud.</div></div>
|
||||
<div class="card" style="padding: 16px;"><div class="label-upper">Self-host status</div>${[['Docker image', 'ghcr.io/signature-digital'], ['Helm chart', 'v0.42.1'], ['Postgres ≥ 14', 'required'], ['S3-compatible', 'optional']].map((row) => html`<div style="display: flex; justify-content: space-between; font-size: 11px; margin-bottom: 6px;"><span style="color: var(--text-muted);">${row[0]}</span><span class="mono" style="color: var(--text-sec);">${row[1]}</span></div>`)}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { DeesElement, property, state, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
|
||||
import { actionButton, demoDocuments, icon, pill, requestWorkspaceView, topBar, workspaceBaseStyles, type IDocumentRow, type TDensity } from './sdig-workspace.shared.js';
|
||||
import { workspaceDemoFrame } from './sdig-workspace.shared.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sdig-workspace-inbox': SdigWorkspaceInbox;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('sdig-workspace-inbox')
|
||||
export class SdigWorkspaceInbox extends DeesElement {
|
||||
public static demo = () => workspaceDemoFrame(html`<sdig-workspace-inbox></sdig-workspace-inbox>`);
|
||||
public static demoGroups = ['Signature Digital Workspace'];
|
||||
|
||||
@property({ type: String }) public accessor density: TDensity = 'comfortable';
|
||||
@state() private accessor filter: string = 'all';
|
||||
@state() private accessor search: string = '';
|
||||
|
||||
public static styles = [workspaceBaseStyles, css`
|
||||
.filterbar { padding: 14px 24px; border-bottom: 1px solid var(--border-subtle); display: flex; align-items: center; gap: 8px; }
|
||||
.searchbox { display: flex; align-items: center; gap: 8px; padding: 0 10px; height: 32px; width: 280px; background: var(--bg-input); border: 1px solid var(--border); border-radius: 6px; }
|
||||
.searchbox input { flex: 1; min-width: 0; background: transparent; border: none; outline: none; color: var(--text); font-size: 12px; }
|
||||
.segmented { display: flex; gap: 2px; padding: 2px; background: var(--bg-el); border-radius: 6px; border: 1px solid var(--border-subtle); }
|
||||
.segmented button { padding: 4px 10px; font-size: 11px; font-weight: 500; border-radius: 4px; background: transparent; color: var(--text-muted); display: inline-flex; align-items: center; gap: 5px; }
|
||||
.segmented button.active { background: var(--bg-card); color: var(--text); box-shadow: inset 0 0 0 1px var(--border); }
|
||||
.doc-table { min-width: 880px; }
|
||||
.doc-head, .doc-row { display: grid; grid-template-columns: 32px minmax(220px,2.4fr) 150px 160px 90px 60px 32px; align-items: center; gap: 14px; padding: 0 16px; }
|
||||
.doc-head { height: 36px; border-bottom: 1px solid var(--border-subtle); color: var(--text-dim); font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
.doc-row { height: 60px; border-bottom: 1px solid var(--border-subtle); cursor: pointer; transition: background 0.1s ease; }
|
||||
.doc-row.compact { height: 48px; }
|
||||
.doc-row:last-child { border-bottom: 0; }
|
||||
.doc-row:hover { background: var(--row-hover); }
|
||||
.doc-icon { width: 28px; height: 32px; border-radius: 4px; background: var(--bg-input); border: 1px solid var(--border); display: flex; align-items: center; justify-content: center; }
|
||||
.doc-title { font-size: 13px; color: var(--text); font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.doc-meta { margin-top: 2px; font-size: 11px; color: var(--text-muted); }
|
||||
.recipient-stack { display: flex; align-items: center; }
|
||||
.recipient-dot { width: 22px; height: 22px; border-radius: 50%; background: var(--bg-input); border: 1.5px solid var(--border); margin-left: -6px; font-size: 9px; font-weight: 600; color: var(--text-sec); display: flex; align-items: center; justify-content: center; }
|
||||
.recipient-dot:first-child { margin-left: 0; }
|
||||
.recipient-dot.signed { border-color: var(--success); color: var(--success); background: var(--bg-el); }
|
||||
.stats-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-top: 24px; }
|
||||
.metric-card { padding: 16px; }
|
||||
.metric-value { font-family: 'Plus Jakarta Sans', Inter, sans-serif; font-size: 22px; font-weight: 600; letter-spacing: -0.02em; }
|
||||
@media (max-width: 920px) { .filterbar { padding: 12px 16px; display: block; } .searchbox { width: 100%; margin-bottom: 10px; } .stats-grid { grid-template-columns: 1fr; } }
|
||||
`];
|
||||
|
||||
private get filteredDocuments(): IDocumentRow[] {
|
||||
return demoDocuments
|
||||
.filter((doc) => this.filter === 'all' || doc.status === this.filter)
|
||||
.filter((doc) => !this.search || doc.title.toLowerCase().includes(this.search.toLowerCase()));
|
||||
}
|
||||
|
||||
private statusPill(status: IDocumentRow['status']): TemplateResult {
|
||||
const map = {
|
||||
awaiting: ['warning', 'awaiting signature'],
|
||||
signed: ['success', 'completed'],
|
||||
draft: ['default', 'draft'],
|
||||
declined: ['error', 'declined'],
|
||||
} as const;
|
||||
const [tone, label] = map[status];
|
||||
return pill(label, tone, true);
|
||||
}
|
||||
|
||||
private openDocument(doc: IDocumentRow) {
|
||||
requestWorkspaceView(this, doc.status === 'signed' ? 'audit' : 'sign');
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
const filters = [
|
||||
{ id: 'all', label: 'All', count: demoDocuments.length },
|
||||
{ id: 'awaiting', label: 'Awaiting', count: demoDocuments.filter((doc) => doc.status === 'awaiting').length },
|
||||
{ id: 'signed', label: 'Completed', count: demoDocuments.filter((doc) => doc.status === 'signed').length },
|
||||
{ id: 'draft', label: 'Drafts', count: demoDocuments.filter((doc) => doc.status === 'draft').length },
|
||||
{ id: 'declined', label: 'Declined', count: demoDocuments.filter((doc) => doc.status === 'declined').length },
|
||||
];
|
||||
|
||||
return html`
|
||||
${topBar({
|
||||
breadcrumb: ['signature.digital', 'Lossless GmbH', 'Inbox'],
|
||||
title: 'Inbox',
|
||||
subtitle: pill(`${demoDocuments.filter((doc) => doc.status === 'awaiting').length} need attention`, 'info'),
|
||||
actions: html`${actionButton('Import', 'outline', 'upload')}${actionButton('New document', 'primary', 'plus', () => requestWorkspaceView(this, 'compose'))}`,
|
||||
})}
|
||||
<div class="filterbar">
|
||||
<div class="searchbox">${icon('search', 13)}<input .value=${this.search} @input=${(event: Event) => this.search = (event.target as HTMLInputElement).value} placeholder="Search documents, recipients, IDs..." /><span class="mono" style="font-size: 10px; color: var(--text-dim); border: 1px solid var(--border); border-radius: 3px; padding: 1px 5px;">⌘K</span></div>
|
||||
<div style="flex: 1;"></div>
|
||||
<div class="segmented">${filters.map((filter) => html`<button class=${this.filter === filter.id ? 'active' : ''} @click=${() => this.filter = filter.id}>${filter.label}<span class="mono" style="color: var(--text-dim);">${filter.count}</span></button>`)}</div>
|
||||
</div>
|
||||
<div class="content-scroll">
|
||||
<div class="card" style="overflow-x: auto;">
|
||||
<div class="doc-table">
|
||||
<div class="doc-head"><span></span><span>Document</span><span>Status</span><span>Recipients</span><span>Deadline</span><span style="text-align: right;">Pages</span><span></span></div>
|
||||
${this.filteredDocuments.map((doc) => html`
|
||||
<div class="doc-row ${this.density === 'compact' ? 'compact' : ''}" @click=${() => this.openDocument(doc)}>
|
||||
<div class="doc-icon">${icon('file', 14)}</div>
|
||||
<div style="min-width: 0;"><div class="doc-title">${doc.title}</div><div class="doc-meta mono">${doc.id} · ${doc.sender} · ${doc.updated}</div></div>
|
||||
<div>${this.statusPill(doc.status)}</div>
|
||||
<div style="display: flex; align-items: center; gap: 8px;"><div class="recipient-stack">${doc.recipients.slice(0, 4).map((recipient) => html`<span class="recipient-dot ${recipient.signed ? 'signed' : ''}" title=${recipient.name}>${recipient.initials}</span>`)}</div><span style="font-size: 11px; color: var(--text-muted);">${doc.recipients.filter((recipient) => recipient.signed).length}/${doc.recipients.length}</span></div>
|
||||
<div class="mono" style="font-size: 11px; color: ${doc.deadline && doc.status === 'awaiting' ? 'var(--warning)' : 'var(--text-dim)'};">${doc.deadline ? html`${icon('clock', 11)} ${doc.deadline}` : '—'}</div>
|
||||
<div class="mono" style="font-size: 11px; color: var(--text-muted); text-align: right;">${doc.pages}</div>
|
||||
<div>${icon('more', 14)}</div>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-grid">
|
||||
${[
|
||||
{ label: 'Sent this month', value: '127', delta: '+24%', icon: 'send' },
|
||||
{ label: 'Avg time to sign', value: '4.2h', delta: '-18%', icon: 'clock' },
|
||||
{ label: 'Completion rate', value: '94.1%', delta: '+2.1%', icon: 'check' },
|
||||
{ label: 'API signatures', value: '2,481', delta: '+312', icon: 'code' },
|
||||
].map((metric) => html`<div class="card metric-card"><div style="display: flex; justify-content: space-between; margin-bottom: 12px;">${icon(metric.icon, 14)}<span class="mono" style="font-size: 10px; color: var(--success);">${metric.delta}</span></div><div class="metric-value">${metric.value}</div><div style="font-size: 11px; color: var(--text-muted); margin-top: 2px;">${metric.label}</div></div>`)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { DeesElement, property, html, customElement, type TemplateResult } from '@design.estate/dees-element';
|
||||
import { icon, pill, topBar, workspaceBaseStyles, workspaceDemoFrame } from './sdig-workspace.shared.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sdig-workspace-placeholder': SdigWorkspacePlaceholder;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('sdig-workspace-placeholder')
|
||||
export class SdigWorkspacePlaceholder extends DeesElement {
|
||||
public static demo = () => workspaceDemoFrame(html`<sdig-workspace-placeholder label="Templates" subtitle="Reusable agreement templates"></sdig-workspace-placeholder>`);
|
||||
public static demoGroups = ['Signature Digital Workspace'];
|
||||
|
||||
@property({ type: String }) public accessor label: string = 'Section';
|
||||
@property({ type: String }) public accessor subtitle: string = 'Coming soon';
|
||||
public static styles = [workspaceBaseStyles];
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`${topBar({ breadcrumb: ['signature.digital', this.label], title: this.label, subtitle: pill('coming soon') })}<div class="content-scroll" style="display: flex; align-items: center; justify-content: center; flex-direction: column; color: var(--text-muted); gap: 8px;">${icon('folder', 32)}<div style="font-size: 13px; color: var(--text-sec);">${this.label}</div><div style="font-size: 11px;">${this.subtitle}</div></div>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { DeesElement, state, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
|
||||
import { actionButton, demoFields, fakeDocument, icon, pill, workspaceBaseStyles, workspaceDemoFrame, type IFieldPlacement } from './sdig-workspace.shared.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sdig-workspace-sign': SdigWorkspaceSign;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('sdig-workspace-sign')
|
||||
export class SdigWorkspaceSign extends DeesElement {
|
||||
public static demo = () => workspaceDemoFrame(html`<sdig-workspace-sign></sdig-workspace-sign>`);
|
||||
public static demoGroups = ['Signature Digital Workspace'];
|
||||
|
||||
@state() private accessor activeFieldId: string = 'f1';
|
||||
@state() private accessor signedFieldIds: string[] = [];
|
||||
|
||||
public static styles = [workspaceBaseStyles, css`
|
||||
.recipient-header { height: 56px; flex-shrink: 0; padding: 0 24px; background: var(--bg-card); border-bottom: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; }
|
||||
.logomark { width: 28px; height: 28px; border-radius: 6px; background: var(--bg-el); border: 1px solid var(--border-strong); display: inline-flex; align-items: center; justify-content: center; font-family: 'Plus Jakarta Sans', Inter, sans-serif; font-weight: 700; position: relative; }
|
||||
.logomark::after { content: ''; position: absolute; right: 5px; bottom: 5px; width: 4px; height: 4px; border-radius: 50%; background: var(--accent); }
|
||||
.sign-layout { flex: 1; display: flex; overflow: hidden; background: hsl(0 0% 96%); color: hsl(0 0% 10%); }
|
||||
:host-context(sdig-workspace[theme='dark']) .sign-layout { background: hsl(0 0% 6%); color: hsl(0 0% 95%); }
|
||||
.sign-body { flex: 1; overflow: auto; padding: 32px 32px 80px; display: flex; flex-direction: column; align-items: center; gap: 20px; }
|
||||
.sign-panel { width: 320px; border-left: 1px solid var(--border); background: var(--bg-card); padding: 20px; overflow: auto; flex-shrink: 0; }
|
||||
@media (max-width: 920px) { .recipient-header .actions { display: none; } .sign-layout { flex-direction: column; overflow: auto; } .sign-panel { width: 100%; border-left: 0; border-top: 1px solid var(--border); } .document-page { width: 560px; } }
|
||||
`];
|
||||
|
||||
private get signFields() {
|
||||
return demoFields.slice(0, 3);
|
||||
}
|
||||
|
||||
private fieldIcon(type: IFieldPlacement['type']): string {
|
||||
if (type === 'signature') return 'sign';
|
||||
if (type === 'date') return 'calendar';
|
||||
return 'type';
|
||||
}
|
||||
|
||||
private signField(fieldId: string) {
|
||||
if (!this.signedFieldIds.includes(fieldId)) {
|
||||
this.signedFieldIds = [...this.signedFieldIds, fieldId];
|
||||
}
|
||||
const next = this.signFields.find((field) => !this.signedFieldIds.includes(field.id) && field.id !== fieldId);
|
||||
if (next) this.activeFieldId = next.id;
|
||||
}
|
||||
|
||||
private renderSignedValue(field: IFieldPlacement): TemplateResult {
|
||||
if (field.type === 'signature') return html`<span style="font-family: 'Plus Jakarta Sans', cursive; font-size: 22px; font-weight: 600; font-style: italic; color: hsl(220 50% 30%);">Sarah Chen</span>`;
|
||||
if (field.type === 'date') return html`<span class="mono" style="font-size: 12px; color: hsl(0 0% 18%);">2026-05-02</span>`;
|
||||
return html`<span style="font-size: 12px; color: hsl(0 0% 18%);">Sarah Chen</span>`;
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
const completed = this.signedFieldIds.length;
|
||||
const progress = Math.round((completed / this.signFields.length) * 100);
|
||||
const activeField = this.signFields.find((field) => field.id === this.activeFieldId) || this.signFields[0];
|
||||
|
||||
return html`
|
||||
<div class="recipient-header">
|
||||
<div style="display: flex; align-items: center; gap: 12px;"><span class="logomark">s</span><div><div style="font-size: 12px; font-weight: 600;">Master Services Agreement</div><div class="mono" style="font-size: 10px; color: var(--text-muted);">From Lossless GmbH · doc_8mK3pL · 14 pages</div></div></div>
|
||||
<div class="actions"><span class="pill success">${icon('shield', 12)} Verified sender · DKIM ✓</span>${actionButton('Decline', 'outline')}${actionButton('PDF', 'outline', 'download')}</div>
|
||||
</div>
|
||||
<div class="progress-track"><div class="progress-fill" style="width: ${progress}%"></div></div>
|
||||
<div class="sign-layout">
|
||||
<div class="sign-body">
|
||||
<div class="document-page" style="width: 620px; min-height: 820px;">
|
||||
${fakeDocument()}
|
||||
${this.signFields.map((field) => {
|
||||
const filled = this.signedFieldIds.includes(field.id);
|
||||
const active = this.activeFieldId === field.id && !filled;
|
||||
return html`<div class="field-box ${active ? 'selected' : ''}" style="--x: ${field.x}px; --y: ${field.y}px; --w: ${field.w}px; --h: ${field.h}px; --field-color: ${filled ? 'transparent' : 'var(--accent)'}; color: ${filled ? 'hsl(220 50% 30%)' : 'var(--accent)'}; background: ${filled ? 'transparent' : 'color-mix(in srgb, var(--accent) 12%, transparent)'};" @click=${() => !filled ? this.signField(field.id) : undefined}>${filled ? this.renderSignedValue(field) : html`${icon(this.fieldIcon(field.type), 12)}<span>${active ? html`<span style="display: inline-block; width: 5px; height: 5px; border-radius: 50%; background: var(--accent); animation: pulse 1.4s infinite;"></span>` : ''}${field.label}</span>`}</div>`;
|
||||
})}
|
||||
<div class="mono" style="position: absolute; bottom: 14px; right: 18px; font-size: 9px; color: hsl(0 0% 60%);">Page 1 of 14</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="sign-panel">
|
||||
<div style="display: flex; align-items: center; gap: 8px;"><span class="avatar" style="background: #60a5fa;">SC</span><div><div style="font-size: 13px; font-weight: 600;">Hi, Sarah</div><div class="mono" style="font-size: 10px; color: var(--text-muted);">sarah@acme.com</div></div></div>
|
||||
<div class="card" style="padding: 14px; margin-top: 16px;"><div class="label-upper">Your progress</div><div style="font-size: 24px; font-weight: 700;">${completed} <span style="color: var(--text-muted); font-weight: 400;">/ ${this.signFields.length}</span></div><div style="font-size: 11px; color: var(--text-muted); margin-top: 2px;">${this.signFields.length - completed === 0 ? 'All fields complete' : `${this.signFields.length - completed} fields remaining`}</div><div class="progress-track" style="margin-top: 12px;"><div class="progress-fill" style="width: ${progress}%"></div></div></div>
|
||||
<div style="margin-top: 20px;" class="label-upper">Step by step</div>
|
||||
${this.signFields.map((field, index) => {
|
||||
const filled = this.signedFieldIds.includes(field.id);
|
||||
const active = this.activeFieldId === field.id && !filled;
|
||||
return html`<div class="recipient-line ${active ? 'active' : ''}" @click=${() => !filled ? this.activeFieldId = field.id : undefined}><span class="avatar" style="width: 22px; height: 22px; background: ${filled ? 'var(--success)' : active ? 'var(--accent)' : 'var(--bg-input)'}; color: ${filled || active ? 'white' : 'var(--text-muted)'};">${filled ? '✓' : index + 1}</span><div style="flex: 1;"><div style="font-size: 12px; font-weight: 500; text-decoration: ${filled ? 'line-through' : 'none'};">${field.label}</div><div class="mono" style="font-size: 10px; color: var(--text-muted);">${field.type} · page ${field.page}</div></div>${active ? icon('chevronRight', 12) : ''}</div>`;
|
||||
})}
|
||||
<button class="btn primary" style="width: 100%; height: 44px; margin-top: 20px;" @click=${() => this.signField(activeField.id)}>${completed === this.signFields.length ? 'Finish & submit' : `Continue - ${activeField.label}`}</button>
|
||||
<div style="margin-top: 12px; padding: 10px; font-size: 10px; color: var(--text-muted); line-height: 1.5; text-align: center; border-radius: 6px; background: var(--bg-el);">By signing, you agree to the ESIGN Act & eIDAS terms.<br /><span class="mono">IP 81.221.4.18 · Brussels, BE</span></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
import { html, css, type TemplateResult } from '@design.estate/dees-element';
|
||||
import '@design.estate/dees-catalog/ts_web/elements/00group-utility/dees-icon/dees-icon.js';
|
||||
|
||||
export type TWorkspaceView =
|
||||
| 'inbox'
|
||||
| 'compose'
|
||||
| 'sign'
|
||||
| 'audit'
|
||||
| 'developers'
|
||||
| 'templates'
|
||||
| 'team'
|
||||
| 'settings';
|
||||
|
||||
export type TWorkspaceTheme = 'dark' | 'light';
|
||||
export type TDensity = 'compact' | 'comfortable';
|
||||
|
||||
export interface IDocumentRow {
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'awaiting' | 'signed' | 'draft' | 'declined';
|
||||
recipients: Array<{ name: string; initials: string; signed: boolean }>;
|
||||
updated: string;
|
||||
sender: string;
|
||||
pages: number;
|
||||
deadline?: string;
|
||||
}
|
||||
|
||||
export interface IRecipient {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
color: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface IFieldPlacement {
|
||||
id: string;
|
||||
type: 'signature' | 'date' | 'text' | 'initials' | 'check';
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
page: number;
|
||||
recipient: number;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export const demoDocuments: IDocumentRow[] = [
|
||||
{ id: 'doc_8mK3pL', title: 'Master Services Agreement - Acme Corp', status: 'awaiting', recipients: [{ name: 'Sarah Chen', initials: 'SC', signed: true }, { name: 'David Park', initials: 'DP', signed: false }, { name: 'You', initials: 'PK', signed: true }], updated: '2 min ago', sender: 'You', pages: 14, deadline: 'May 5' },
|
||||
{ id: 'doc_2nQ7vR', title: 'NDA - Helio Robotics', status: 'signed', recipients: [{ name: 'Marcus Tan', initials: 'MT', signed: true }, { name: 'You', initials: 'PK', signed: true }], updated: '1h ago', sender: 'You', pages: 3 },
|
||||
{ id: 'doc_5tH1zM', title: 'Series B Term Sheet (Lead) v3', status: 'awaiting', recipients: [{ name: 'Anna Lindqvist', initials: 'AL', signed: false }, { name: 'Roy Banerjee', initials: 'RB', signed: true }, { name: 'You', initials: 'PK', signed: false }], updated: '3h ago', sender: 'Sequoia Counsel', pages: 22, deadline: 'May 3' },
|
||||
{ id: 'doc_9wB4cX', title: 'Employment Offer - Mira Abebe', status: 'declined', recipients: [{ name: 'Mira Abebe', initials: 'MA', signed: false }, { name: 'You', initials: 'PK', signed: true }], updated: 'yesterday', sender: 'You', pages: 6 },
|
||||
{ id: 'doc_1jF6kY', title: 'Lease - Berlin office Q3', status: 'draft', recipients: [{ name: 'You', initials: 'PK', signed: false }], updated: 'yesterday', sender: 'You', pages: 11 },
|
||||
{ id: 'doc_4dN8sP', title: 'API Reseller Agreement - Northwind', status: 'signed', recipients: [{ name: 'Lila Brooks', initials: 'LB', signed: true }, { name: 'You', initials: 'PK', signed: true }], updated: '2 days ago', sender: 'You', pages: 8 },
|
||||
];
|
||||
|
||||
export const demoRecipients: IRecipient[] = [
|
||||
{ id: 0, name: 'Sarah Chen', email: 'sarah@acme.com', color: '#60a5fa', order: 1 },
|
||||
{ id: 1, name: 'David Park', email: 'd.park@acme.com', color: '#fbbf24', order: 2 },
|
||||
{ id: 2, name: 'Philipp K.', email: 'philipp@lossless.com', color: '#3b82f6', order: 3 },
|
||||
];
|
||||
|
||||
export const demoFields: IFieldPlacement[] = [
|
||||
{ id: 'f1', type: 'signature', x: 60, y: 580, w: 200, h: 50, page: 1, recipient: 0, label: 'Signature' },
|
||||
{ id: 'f2', type: 'date', x: 320, y: 580, w: 120, h: 30, page: 1, recipient: 0, label: 'Date' },
|
||||
{ id: 'f3', type: 'text', x: 60, y: 460, w: 280, h: 30, page: 1, recipient: 1, label: 'Full legal name' },
|
||||
{ id: 'f4', type: 'signature', x: 60, y: 700, w: 200, h: 50, page: 1, recipient: 1, label: 'Counter-signature' },
|
||||
];
|
||||
|
||||
export const workspaceBaseStyles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-family: Geist, Inter, Roboto, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-feature-settings: 'cv11', 'tnum', 'cv05' 1;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
button, input, textarea { font: inherit; }
|
||||
button { border: 0; cursor: pointer; }
|
||||
dees-icon { flex-shrink: 0; }
|
||||
|
||||
.mono {
|
||||
font-family: 'Intel One Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 56px;
|
||||
flex-shrink: 0;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
background: var(--bg);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.top-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.top-title > span:first-child {
|
||||
font-family: 'Plus Jakarta Sans', Inter, sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
transition: all 0.12s ease;
|
||||
}
|
||||
|
||||
.btn.small {
|
||||
height: 28px;
|
||||
padding: 0 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.btn.primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: 1px solid var(--accent);
|
||||
}
|
||||
|
||||
.btn.outline {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.btn.ghost {
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.btn:hover { background-color: var(--hover); }
|
||||
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--bg-el);
|
||||
color: var(--text-sec);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.pill::before {
|
||||
content: '';
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
display: none;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.pill.dot::before { display: block; }
|
||||
.pill.success { background: rgba(34,197,94,0.12); color: #4ade80; }
|
||||
.pill.warning { background: rgba(245,158,11,0.12); color: #fbbf24; }
|
||||
.pill.error { background: rgba(239,68,68,0.12); color: #f87171; }
|
||||
.pill.info { background: rgba(59,130,246,0.12); color: #60a5fa; }
|
||||
|
||||
.content-scroll {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.label-upper {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.document-page {
|
||||
position: relative;
|
||||
width: 600px;
|
||||
min-height: 800px;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.35), 0 0 0 1px rgba(255,255,255,0.05);
|
||||
color: hsl(0 0% 20%);
|
||||
}
|
||||
|
||||
.fake-document {
|
||||
padding: 48px 56px;
|
||||
font-size: 11px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.fake-title {
|
||||
font-family: 'Plus Jakarta Sans', Inter, sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
color: hsl(0 0% 10%);
|
||||
}
|
||||
|
||||
.fake-line {
|
||||
height: 6px;
|
||||
background: hsl(0 0% 82%);
|
||||
margin-bottom: 7px;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.fake-line.heavy { background: hsl(0 0% 65%); }
|
||||
.fake-line.short { width: 70%; }
|
||||
|
||||
.field-box {
|
||||
position: absolute;
|
||||
left: var(--x);
|
||||
top: var(--y);
|
||||
width: var(--w);
|
||||
height: var(--h);
|
||||
background: color-mix(in srgb, var(--field-color) 13%, transparent);
|
||||
border: 1.5px dashed var(--field-color);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 0 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--field-color);
|
||||
}
|
||||
|
||||
.field-box.selected {
|
||||
border-style: solid;
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--field-color) 18%, transparent);
|
||||
}
|
||||
|
||||
.recipient-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-sec);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.recipient-line.active {
|
||||
background: var(--hover);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.progress-track {
|
||||
height: 4px;
|
||||
background: var(--bg-el);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.topbar { padding: 0 16px; }
|
||||
.actions { display: none; }
|
||||
.content-scroll { padding: 16px; }
|
||||
}
|
||||
`;
|
||||
|
||||
export function icon(name: string, size = 14): TemplateResult {
|
||||
const iconMap: Record<string, string> = {
|
||||
inbox: 'lucide:Inbox', plus: 'lucide:Plus', folder: 'lucide:Folder', shield: 'lucide:Shield', code: 'lucide:Code2',
|
||||
user: 'lucide:User', settings: 'lucide:Settings', upload: 'lucide:Upload', file: 'lucide:FileText', sign: 'lucide:PenTool',
|
||||
clock: 'lucide:Clock', search: 'lucide:Search', more: 'lucide:MoreHorizontal', send: 'lucide:Send', check: 'lucide:Check',
|
||||
eye: 'lucide:Eye', calendar: 'lucide:Calendar', type: 'lucide:Type', download: 'lucide:Download', hash: 'lucide:Hash',
|
||||
github: 'lucide:GitBranch', git: 'lucide:GitBranch', server: 'lucide:Server', star: 'lucide:Star', sparkle: 'lucide:Sparkles',
|
||||
chevronRight: 'lucide:ChevronRight', chevronDown: 'lucide:ChevronDown', x: 'lucide:X', activity: 'lucide:Activity',
|
||||
};
|
||||
return html`<dees-icon .icon=${iconMap[name] || iconMap.file} style="font-size: ${size}px;"></dees-icon>`;
|
||||
}
|
||||
|
||||
export function pill(label: string, tone: 'default' | 'success' | 'warning' | 'error' | 'info' = 'default', dot = false): TemplateResult {
|
||||
return html`<span class="pill ${tone} ${dot ? 'dot' : ''}">${label}</span>`;
|
||||
}
|
||||
|
||||
export function actionButton(label: string, variant: 'primary' | 'outline' | 'ghost' = 'outline', iconName?: string, onClick?: () => void): TemplateResult {
|
||||
return html`<button class="btn ${variant}" @click=${onClick || (() => undefined)}>${iconName ? icon(iconName, 13) : ''}${label}</button>`;
|
||||
}
|
||||
|
||||
export function topBar(config: { breadcrumb: string[]; title: string; subtitle?: TemplateResult; actions?: TemplateResult }): TemplateResult {
|
||||
return html`
|
||||
<div class="topbar">
|
||||
<div style="min-width: 0; flex: 1;">
|
||||
<div class="breadcrumb">
|
||||
${config.breadcrumb.map((part, index) => html`${index > 0 ? icon('chevronRight', 10) : ''}<span>${part}</span>`)}
|
||||
</div>
|
||||
<div class="top-title"><span>${config.title}</span>${config.subtitle || ''}</div>
|
||||
</div>
|
||||
<div class="actions">${config.actions || ''}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function workspaceDemoFrame(content: TemplateResult, theme: TWorkspaceTheme = 'dark'): TemplateResult {
|
||||
const darkVars = `
|
||||
--accent: #3b82f6;
|
||||
--bg: hsl(0 0% 3.9%);
|
||||
--bg-el: hsl(0 0% 6%);
|
||||
--bg-card: hsl(0 0% 7%);
|
||||
--bg-input: hsl(0 0% 9%);
|
||||
--border: hsl(0 0% 14.9%);
|
||||
--border-subtle: hsl(0 0% 11%);
|
||||
--border-strong: hsl(0 0% 20%);
|
||||
--text: hsl(0 0% 98%);
|
||||
--text-sec: hsl(0 0% 63.9%);
|
||||
--text-muted: hsl(0 0% 48%);
|
||||
--text-dim: hsl(0 0% 32%);
|
||||
--hover: rgba(255,255,255,0.06);
|
||||
--hover-subtle: rgba(255,255,255,0.03);
|
||||
--row-hover: rgba(255,255,255,0.025);
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--error: #ef4444;
|
||||
`;
|
||||
const lightVars = `
|
||||
--accent: #3b82f6;
|
||||
--bg: hsl(0 0% 99%);
|
||||
--bg-el: hsl(0 0% 97%);
|
||||
--bg-card: hsl(0 0% 100%);
|
||||
--bg-input: hsl(0 0% 98%);
|
||||
--border: hsl(0 0% 90%);
|
||||
--border-subtle: hsl(0 0% 93%);
|
||||
--border-strong: hsl(0 0% 80%);
|
||||
--text: hsl(0 0% 9%);
|
||||
--text-sec: hsl(0 0% 32%);
|
||||
--text-muted: hsl(0 0% 45%);
|
||||
--text-dim: hsl(0 0% 62%);
|
||||
--hover: rgba(0,0,0,0.04);
|
||||
--hover-subtle: rgba(0,0,0,0.02);
|
||||
--row-hover: rgba(0,0,0,0.02);
|
||||
--success: #16a34a;
|
||||
--warning: #d97706;
|
||||
--error: #dc2626;
|
||||
`;
|
||||
|
||||
return html`<div style="${theme === 'dark' ? darkVars : lightVars} height: 720px; min-height: 720px; background: var(--bg); color: var(--text); overflow: hidden; font-family: Geist, Inter, Roboto, -apple-system, BlinkMacSystemFont, sans-serif;">${content}</div>`;
|
||||
}
|
||||
|
||||
export function fakeDocument(): TemplateResult {
|
||||
return html`
|
||||
<div class="fake-document">
|
||||
<div class="fake-title">Master Services Agreement</div>
|
||||
<div class="mono" style="font-size: 10px; color: hsl(0 0% 45%); margin-bottom: 24px;">Effective: May 2, 2026 · Acme Corp ↔ Lossless GmbH</div>
|
||||
${Array.from({ length: 18 }).map((_, index) => html`<div class="fake-line ${index % 5 === 0 ? 'heavy' : ''} ${index % 4 === 3 ? 'short' : ''}"></div>`)}
|
||||
<div style="height: 16px;"></div>
|
||||
${Array.from({ length: 8 }).map((_, index) => html`<div class="fake-line ${index % 3 === 2 ? 'short' : ''}"></div>`)}
|
||||
<div style="margin-top: 60px; font-size: 10px; font-weight: 600; color: hsl(0 0% 35%);">SIGNED ON BEHALF OF ACME CORP</div>
|
||||
<div style="margin-top: 70px; font-size: 10px; font-weight: 600; color: hsl(0 0% 35%);">SIGNED ON BEHALF OF LOSSLESS GMBH</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
export function requestWorkspaceView(element: HTMLElement, view: TWorkspaceView) {
|
||||
element.dispatchEvent(new CustomEvent('workspace-view-request', {
|
||||
detail: { view },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { DeesElement, property, state, html, customElement, type TemplateResult, css } from '@design.estate/dees-element';
|
||||
import { icon, type TDensity, type TWorkspaceTheme, type TWorkspaceView } from './sdig-workspace.shared.js';
|
||||
import './sdig-workspace-inbox.js';
|
||||
import './sdig-workspace-compose.js';
|
||||
import './sdig-workspace-sign.js';
|
||||
import './sdig-workspace-audit.js';
|
||||
import './sdig-workspace-developers.js';
|
||||
import './sdig-workspace-placeholder.js';
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'sdig-workspace': SdigWorkspace;
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('sdig-workspace')
|
||||
export class SdigWorkspace extends DeesElement {
|
||||
public static demo = () => html`<sdig-workspace></sdig-workspace>`;
|
||||
public static demoGroups = ['Signature Digital Workspace'];
|
||||
|
||||
@property({ type: String }) public accessor accent: string = '#3b82f6';
|
||||
@property({ type: String }) public accessor density: TDensity = 'comfortable';
|
||||
@property({ type: String, reflect: true }) public accessor theme: TWorkspaceTheme = 'dark';
|
||||
@property({ type: String }) public accessor initialView: TWorkspaceView = 'inbox';
|
||||
@state() private accessor view: TWorkspaceView = 'inbox';
|
||||
|
||||
public connectedCallback = async () => {
|
||||
await super.connectedCallback();
|
||||
this.view = this.initialView || 'inbox';
|
||||
this.addEventListener('workspace-view-request', this.handleViewRequest as EventListener);
|
||||
};
|
||||
|
||||
public disconnectedCallback = async () => {
|
||||
this.removeEventListener('workspace-view-request', this.handleViewRequest as EventListener);
|
||||
await super.disconnectedCallback();
|
||||
};
|
||||
|
||||
public static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 720px;
|
||||
--accent: #3b82f6;
|
||||
--bg: hsl(0 0% 3.9%);
|
||||
--bg-el: hsl(0 0% 6%);
|
||||
--bg-card: hsl(0 0% 7%);
|
||||
--bg-input: hsl(0 0% 9%);
|
||||
--border: hsl(0 0% 14.9%);
|
||||
--border-subtle: hsl(0 0% 11%);
|
||||
--border-strong: hsl(0 0% 20%);
|
||||
--text: hsl(0 0% 98%);
|
||||
--text-sec: hsl(0 0% 63.9%);
|
||||
--text-muted: hsl(0 0% 48%);
|
||||
--text-dim: hsl(0 0% 32%);
|
||||
--hover: rgba(255,255,255,0.06);
|
||||
--hover-subtle: rgba(255,255,255,0.03);
|
||||
--row-hover: rgba(255,255,255,0.025);
|
||||
--success: #22c55e;
|
||||
--warning: #f59e0b;
|
||||
--error: #ef4444;
|
||||
font-family: Geist, Inter, Roboto, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
}
|
||||
|
||||
:host([theme='light']) {
|
||||
--bg: hsl(0 0% 99%);
|
||||
--bg-el: hsl(0 0% 97%);
|
||||
--bg-card: hsl(0 0% 100%);
|
||||
--bg-input: hsl(0 0% 98%);
|
||||
--border: hsl(0 0% 90%);
|
||||
--border-subtle: hsl(0 0% 93%);
|
||||
--border-strong: hsl(0 0% 80%);
|
||||
--text: hsl(0 0% 9%);
|
||||
--text-sec: hsl(0 0% 32%);
|
||||
--text-muted: hsl(0 0% 45%);
|
||||
--text-dim: hsl(0 0% 62%);
|
||||
--hover: rgba(0,0,0,0.04);
|
||||
--hover-subtle: rgba(0,0,0,0.02);
|
||||
--row-hover: rgba(0,0,0,0.02);
|
||||
--success: #16a34a;
|
||||
--warning: #d97706;
|
||||
--error: #dc2626;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
button { font: inherit; border: 0; cursor: pointer; }
|
||||
.workspace { display: flex; height: 100%; min-height: 720px; background: var(--bg); color: var(--text); overflow: hidden; }
|
||||
.sidebar { width: 220px; background: var(--bg); border-right: 1px solid var(--border-subtle); display: flex; flex-direction: column; flex-shrink: 0; height: 100%; }
|
||||
.brand { padding: 14px 16px 12px; display: flex; align-items: center; gap: 8px; }
|
||||
.logomark { width: 26px; height: 26px; border-radius: 6px; background: var(--bg-el); border: 1px solid var(--border-strong); display: inline-flex; align-items: center; justify-content: center; font-family: 'Plus Jakarta Sans', Inter, sans-serif; font-size: 13px; font-weight: 700; position: relative; }
|
||||
.logomark::after, .wordmark::after { content: ''; display: inline-block; border-radius: 50%; background: var(--accent); }
|
||||
.logomark::after { width: 4px; height: 4px; position: absolute; right: 5px; bottom: 5px; }
|
||||
.wordmark { font-size: 13px; font-weight: 500; letter-spacing: -0.02em; white-space: nowrap; }
|
||||
.wordmark .dot { color: var(--text-muted); }
|
||||
.wordmark::after { width: 4px; height: 4px; margin-left: 3px; transform: translateY(-1px); }
|
||||
.workspace-card { margin: 0 12px 8px; padding: 7px 10px; background: var(--bg-el); border: 1px solid var(--border-subtle); border-radius: 6px; display: flex; align-items: center; gap: 8px; }
|
||||
.workspace-badge { width: 18px; height: 18px; border-radius: 4px; background: linear-gradient(135deg, var(--accent), hsl(280 70% 60%)); color: white; font-size: 10px; font-weight: 700; display: flex; align-items: center; justify-content: center; }
|
||||
.workspace-name { font-size: 12px; font-weight: 500; line-height: 1.2; }
|
||||
.workspace-plan { font-size: 10px; color: var(--text-muted); }
|
||||
.nav-block { padding: 4px 0; }
|
||||
.nav-item { display: flex; align-items: center; gap: 10px; padding: 7px 10px; margin: 1px 8px; border-radius: 6px; color: var(--text-muted); background: transparent; transition: all 0.1s ease; font-size: 13px; position: relative; width: calc(100% - 16px); text-align: left; }
|
||||
.compact .nav-item { padding: 5px 10px; }
|
||||
.nav-item:hover { background: var(--hover-subtle); color: var(--text-sec); }
|
||||
.nav-item.active { background: var(--hover); color: var(--text); font-weight: 500; }
|
||||
.nav-item.active::before { content: ''; position: absolute; left: -8px; width: 2px; height: 14px; border-radius: 2px; background: var(--accent); }
|
||||
.nav-count { margin-left: auto; min-width: 18px; padding: 1px 6px; border-radius: 999px; background: var(--bg-el); color: var(--text-muted); font-size: 10px; text-align: center; }
|
||||
.github-card { margin: 8px 12px; padding: 10px; border: 1px solid var(--border-subtle); border-radius: 6px; background: var(--bg-el); }
|
||||
.sparkline { margin-top: 8px; display: flex; gap: 2px; align-items: flex-end; height: 18px; }
|
||||
.sparkline span { flex: 1; background: var(--border-strong); border-radius: 1px; }
|
||||
.sparkline span:nth-last-child(-n+4) { background: var(--accent); }
|
||||
.user-card { padding: 8px 12px; border-top: 1px solid var(--border-subtle); display: flex; align-items: center; gap: 10px; }
|
||||
.avatar { width: 26px; height: 26px; border-radius: 50%; background: var(--accent); color: white; font-size: 11px; font-weight: 700; display: flex; align-items: center; justify-content: center; }
|
||||
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.view-host { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
.statusbar { height: 24px; flex-shrink: 0; border-top: 1px solid var(--border-subtle); background: var(--bg); display: flex; align-items: center; padding: 0 16px; gap: 16px; font-size: 10px; color: var(--text-dim); font-family: 'Intel One Mono', ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
@media (max-width: 920px) { .workspace { flex-direction: column; min-height: 100vh; } .sidebar { width: 100%; height: auto; border-right: 0; border-bottom: 1px solid var(--border-subtle); } .brand, .workspace-card, .github-card, .user-card { display: none; } .nav-block { display: flex; overflow-x: auto; padding: 8px; } .nav-item { width: auto; margin: 0 2px; } .statusbar { display: none; } }
|
||||
`;
|
||||
|
||||
private handleViewRequest = (event: CustomEvent<{ view: TWorkspaceView }>) => {
|
||||
this.setView(event.detail.view);
|
||||
};
|
||||
|
||||
private setView(viewArg: TWorkspaceView) {
|
||||
this.view = viewArg;
|
||||
this.dispatchEvent(new CustomEvent('view-change', { detail: { view: viewArg }, bubbles: true, composed: true }));
|
||||
}
|
||||
|
||||
private navButton(item: { id: TWorkspaceView; label: string; icon: string; count?: number }): TemplateResult {
|
||||
return html`<button class="nav-item ${this.view === item.id ? 'active' : ''}" @click=${() => this.setView(item.id)}>${icon(item.icon, 15)}<span>${item.label}</span>${item.count !== undefined ? html`<span class="nav-count">${item.count}</span>` : ''}</button>`;
|
||||
}
|
||||
|
||||
private renderSidebar(): TemplateResult {
|
||||
const navItems = [
|
||||
{ id: 'inbox', label: 'Inbox', icon: 'inbox', count: 4 },
|
||||
{ id: 'compose', label: 'Compose', icon: 'plus' },
|
||||
{ id: 'templates', label: 'Templates', icon: 'folder', count: 12 },
|
||||
{ id: 'audit', label: 'Audit Trail', icon: 'shield' },
|
||||
{ id: 'developers', label: 'Developers', icon: 'code' },
|
||||
] as Array<{ id: TWorkspaceView; label: string; icon: string; count?: number }>;
|
||||
const lowerItems = [
|
||||
{ id: 'team', label: 'Team', icon: 'user' },
|
||||
{ id: 'settings', label: 'Settings', icon: 'settings' },
|
||||
] as Array<{ id: TWorkspaceView; label: string; icon: string }>;
|
||||
|
||||
return html`
|
||||
<aside class="sidebar">
|
||||
<div class="brand"><span class="logomark">s</span><span class="wordmark">signature<span class="dot">.</span>digital</span></div>
|
||||
<div class="workspace-card"><span class="workspace-badge">L</span><div style="flex: 1; min-width: 0;"><div class="workspace-name">Lossless GmbH</div><div class="workspace-plan">Cloud · Pro</div></div>${icon('chevronDown', 12)}</div>
|
||||
<div class="nav-block">${navItems.map((item) => this.navButton(item))}</div>
|
||||
<div style="flex: 1;"></div>
|
||||
<div class="github-card"><div style="display: flex; align-items: center; gap: 6px; margin-bottom: 8px; font-size: 11px; color: var(--text-sec); font-family: 'Intel One Mono', ui-monospace;">${icon('github', 13)} signature-digital/core</div><div style="display: flex; gap: 12px; font-size: 11px; color: var(--text-muted);"><span>${icon('star', 11)} 8.2k</span><span>${icon('git', 11)} 248</span></div><div class="sparkline">${[3, 5, 2, 7, 4, 6, 8, 5, 9, 6, 4, 8, 7, 10].map((height) => html`<span style="height: ${height * 10}%"></span>`)}</div></div>
|
||||
<div class="nav-block" style="border-top: 1px solid var(--border-subtle); padding-top: 8px;">${lowerItems.map((item) => this.navButton(item))}</div>
|
||||
<div class="user-card"><span class="avatar">PK</span><div style="flex: 1; min-width: 0;"><div style="font-size: 12px; font-weight: 500;">Philipp K.</div><div style="font-family: 'Intel One Mono', ui-monospace; font-size: 10px; color: var(--text-muted); overflow: hidden; text-overflow: ellipsis;">philipp@lossless.com</div></div>${icon('more', 14)}</div>
|
||||
</aside>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderView(): TemplateResult {
|
||||
switch (this.view) {
|
||||
case 'inbox': return html`<sdig-workspace-inbox class="view-host" .density=${this.density}></sdig-workspace-inbox>`;
|
||||
case 'compose': return html`<sdig-workspace-compose class="view-host"></sdig-workspace-compose>`;
|
||||
case 'sign': return html`<sdig-workspace-sign class="view-host"></sdig-workspace-sign>`;
|
||||
case 'audit': return html`<sdig-workspace-audit class="view-host"></sdig-workspace-audit>`;
|
||||
case 'developers': return html`<sdig-workspace-developers class="view-host"></sdig-workspace-developers>`;
|
||||
case 'templates': return html`<sdig-workspace-placeholder class="view-host" label="Templates" subtitle="Reusable agreement templates"></sdig-workspace-placeholder>`;
|
||||
case 'team': return html`<sdig-workspace-placeholder class="view-host" label="Team" subtitle="Workspace members & roles"></sdig-workspace-placeholder>`;
|
||||
case 'settings': return html`<sdig-workspace-placeholder class="view-host" label="Settings" subtitle="Workspace, billing, security"></sdig-workspace-placeholder>`;
|
||||
default: return html`<sdig-workspace-inbox class="view-host" .density=${this.density}></sdig-workspace-inbox>`;
|
||||
}
|
||||
}
|
||||
|
||||
public render(): TemplateResult {
|
||||
return html`<div class="workspace ${this.density === 'compact' ? 'compact' : ''}" style="--accent: ${this.accent};" data-screen-label=${this.view}>${this.renderSidebar()}<main class="main">${this.renderView()}<div class="statusbar"><span style="display: inline-flex; align-items: center; gap: 5px;"><span style="width: 6px; height: 6px; border-radius: 50%; background: var(--success);"></span>api.signature.digital</span><span>eu-central-1</span><span>4 sigs queued</span><div style="flex: 1;"></div><span style="color: var(--accent);">Open Source · MIT</span><span>v0.42.1</span><span>${icon('git', 11)} main</span></div></main></div>`;
|
||||
}
|
||||
}
|
||||
+1
-19
@@ -1,21 +1,3 @@
|
||||
// @signature.digital scope
|
||||
import * as sdDemodata from '@signature.digital/tools/demodata';
|
||||
import * as sdInterfaces from '@signature.digital/tools/interfaces';
|
||||
import * as sdTools from '@signature.digital/tools';
|
||||
|
||||
export {
|
||||
sdDemodata,
|
||||
sdInterfaces,
|
||||
sdTools,
|
||||
}
|
||||
|
||||
// @design.estate scope
|
||||
import * as deesCatalog from '@design.estate/dees-catalog';
|
||||
|
||||
export {
|
||||
deesCatalog,
|
||||
}
|
||||
|
||||
// third party
|
||||
import signaturePadMod from 'signature_pad';
|
||||
type signaturePadType = (typeof import('signature_pad'))['default'];
|
||||
@@ -23,4 +5,4 @@ const signaturePad = signaturePadMod as any as signaturePadType;
|
||||
|
||||
export {
|
||||
signaturePad,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user