8 Commits

Author SHA1 Message Date
3d266c89b2 v1.1.0
Some checks failed
Default (tags) / security (push) Failing after 16s
Default (tags) / test (push) Failing after 17s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2025-12-18 15:27:22 +00:00
56c087bc3a feat(catalog): add ContractEditor and many editor subcomponents; implement SignPad and SignBox; update README and bump dependencies 2025-12-18 15:27:22 +00:00
6d53259b75 1.0.59
Some checks failed
Default (tags) / security (push) Failing after 14s
Default (tags) / test (push) Failing after 13s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2024-12-19 18:37:00 +01:00
eefcd4f807 fix(dependencies): Update package dependencies and project metadata 2024-12-19 18:37:00 +01:00
ce9db08ca0 1.0.58
Some checks failed
Default (tags) / security (push) Failing after 15s
Default (tags) / test (push) Failing after 14s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2023-11-28 20:44:46 +01:00
247a401982 fix(core): update 2023-11-28 20:44:45 +01:00
68164f4d9e 1.0.57
Some checks failed
Default (tags) / security (push) Failing after 15s
Default (tags) / test (push) Failing after 14s
Default (tags) / release (push) Has been skipped
Default (tags) / metadata (push) Has been skipped
2023-11-27 18:30:44 +01:00
6c99b95ed7 fix(core): update 2023-11-27 18:30:44 +01:00
40 changed files with 16608 additions and 102 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

24
changelog.md Normal file
View File

@@ -0,0 +1,24 @@
# Changelog
## 2025-12-18 - 1.1.0 - feat(catalog)
add ContractEditor and many editor subcomponents; implement SignPad and SignBox; update README and bump dependencies
- New contract editor module with many subcomponents added (header, metadata, parties, content, terms, signatures, attachments, collaboration, audit)
- Implemented signature UI components: sdig-signpad and sdig-signbox (canvas-based signature capture, undo/clear/export APIs)
- Reorganized exports (ts_web/elements/index.ts) to expose new submodules and barrel files
- Added editor types and smartstate store (ts_web/elements/sdig-contracteditor/types.ts and state.ts) for editor state management, undo/redo and events
- Large README overhaul: improved usage examples, API docs, security/issue reporting section, theming and integration examples
- Dependencies bumped in package.json (notable bumps: @design.estate/* packages, @git.zone tooling, signature_pad) and pnpm overrides added
- tsconfig.json compiler options modified (removed experimentalDecorators and useDefineForClassFields) — may affect local build configurations
## 2024-12-19 - 1.0.59 - fix(dependencies)
Update package dependencies and project metadata
- Updated package dependencies to the latest versions in package.json
- Synchronized project description and keywords in npmextra.json with package.json
## 2023-11-28 - 1.0.55 to 1.0.58 - core updates
Main changes include fixing and updating core functionalities.
- Fixed core issues and updated core functionalities in versions 1.0.55, 1.0.56, and 1.0.57.
- Further updates and improvements were carried out in version 1.0.58.

View File

@@ -1,6 +1,6 @@
// dees tools // dees tools
import * as deesWccTools from '@designestate/dees-wcctools'; import * as deesWccTools from '@design.estate/dees-wcctools';
import * as deesDomTools from '@designestate/dees-domtools'; import * as deesDomTools from '@design.estate/dees-domtools';
// elements and pages // elements and pages
import * as elements from '../ts_web/elements/index.js'; import * as elements from '../ts_web/elements/index.js';

View File

@@ -5,10 +5,25 @@
"githost": "gitlab.com", "githost": "gitlab.com",
"gitscope": "signature.digital_private", "gitscope": "signature.digital_private",
"gitrepo": "catalog", "gitrepo": "catalog",
"description": "a catalog containing components for e-signing", "description": "A comprehensive catalog of customizable web components designed for building and managing e-signature applications.",
"npmPackagename": "@signature.digital_private/catalog", "npmPackagename": "@signature.digital_private/catalog",
"license": "UNLICENSED", "license": "UNLICENSED",
"projectDomain": "signature.digital" "projectDomain": "signature.digital",
"keywords": [
"e-signature",
"web components",
"digital signature",
"signature capture",
"ECMAScript Modules",
"typescript",
"component library",
"contract management",
"frontend development",
"signature pad",
"custom elements",
"electronic signing",
"npm package"
]
} }
}, },
"npmci": { "npmci": {

View File

@@ -1,10 +1,11 @@
{ {
"name": "@signature.digital_private/catalog", "name": "@signature.digital/catalog",
"version": "1.0.56", "version": "1.1.0",
"private": false, "private": false,
"description": "a catalog containing components for e-signing", "description": "A comprehensive catalog of customizable web components designed for building and managing e-signature applications.",
"main": "dist_ts_web/index.js", "exports": {
"typings": "dist_ts_web/index.d.ts", ".": "./dist_ts_web/index.ts"
},
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "npm run build", "test": "npm run build",
@@ -12,21 +13,21 @@
"watch": "tswatch element" "watch": "tswatch element"
}, },
"author": "Task Venture Capital GmbH", "author": "Task Venture Capital GmbH",
"license": "UNLICENSED", "license": "MIT",
"dependencies": { "dependencies": {
"@designestate/dees-domtools": "^1.0.41", "@design.estate/dees-catalog": "^3.3.1",
"@designestate/dees-element": "^1.0.26", "@design.estate/dees-domtools": "^2.3.6",
"@designestate/dees-wcctools": "^1.0.37", "@design.estate/dees-element": "^2.1.3",
"@git.zone/tsrun": "^1.2.12", "@design.estate/dees-wcctools": "^2.0.1",
"@losslessone_private/loint-pubapi": "^1.0.9", "@git.zone/tsrun": "^2.0.1",
"@pushrocks/smartexpress": "^3.0.76", "@signature.digital/tools": "^1.1.0",
"typescript": "^4.4.3" "signature_pad": "^5.1.3"
}, },
"devDependencies": { "devDependencies": {
"@git.zone/tsbuild": "^2.1.24", "@git.zone/tsbuild": "^4.0.2",
"@git.zone/tsbundle": "^1.0.72", "@git.zone/tsbundle": "^2.6.3",
"@git.zone/tswatch": "^1.0.50", "@git.zone/tswatch": "^2.3.13",
"@pushrocks/projectinfo": "^4.0.5" "@push.rocks/projectinfo": "^5.0.2"
}, },
"files": [ "files": [
"ts/**/*", "ts/**/*",
@@ -42,5 +43,26 @@
], ],
"browserslist": [ "browserslist": [
"last 1 Chrome versions" "last 1 Chrome versions"
] ],
"keywords": [
"e-signature",
"web components",
"digital signature",
"signature capture",
"ECMAScript Modules",
"typescript",
"component library",
"contract management",
"frontend development",
"signature pad",
"custom elements",
"electronic signing",
"npm package"
],
"pnpm": {
"overrides": {
"@push.rocks/smartrequest": "^5.0.1",
"agentkeepalive": "^4.6.0"
}
}
} }

7299
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

0
readme.hints.md Normal file
View File

197
readme.md
View File

@@ -1,31 +1,176 @@
# @signature.digital_private/catalog # @signature.digital/catalog
a catalog containing components for e-signing
## Availabililty and Links A comprehensive catalog of customizable web components designed for building and managing e-signature applications. Built with modern web technologies using LitElement and TypeScript.
* [npmjs.org (npm package)](https://www.npmjs.com/package/@signature.digital_private/catalog)
* [gitlab.com (source)](https://gitlab.com/signature.digital_private/catalog)
* [github.com (source mirror)](https://github.com/signature.digital_private/catalog)
* [docs (typedoc)](https://signature.digital_private.gitlab.io/catalog/)
## Status for master ## Issue Reporting and Security
Status Category | Status Badge For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
-- | --
GitLab Pipelines | [![pipeline status](https://gitlab.com/signature.digital_private/catalog/badges/master/pipeline.svg)](https://lossless.cloud)
GitLab Pipline Test Coverage | [![coverage report](https://gitlab.com/signature.digital_private/catalog/badges/master/coverage.svg)](https://lossless.cloud)
npm | [![npm downloads per month](https://badgen.net/npm/dy/@signature.digital_private/catalog)](https://lossless.cloud)
Snyk | [![Known Vulnerabilities](https://badgen.net/snyk/signature.digital_private/catalog)](https://lossless.cloud)
TypeScript Support | [![TypeScript](https://badgen.net/badge/TypeScript/>=%203.x/blue?icon=typescript)](https://lossless.cloud)
node Support | [![node](https://img.shields.io/badge/node->=%2010.x.x-blue.svg)](https://nodejs.org/dist/latest-v10.x/docs/api/)
Code Style | [![Code Style](https://badgen.net/badge/style/prettier/purple)](https://lossless.cloud)
PackagePhobia (total standalone install weight) | [![PackagePhobia](https://badgen.net/packagephobia/install/@signature.digital_private/catalog)](https://lossless.cloud)
PackagePhobia (package size on registry) | [![PackagePhobia](https://badgen.net/packagephobia/publish/@signature.digital_private/catalog)](https://lossless.cloud)
BundlePhobia (total size when bundled) | [![BundlePhobia](https://badgen.net/bundlephobia/minzip/@signature.digital_private/catalog)](https://lossless.cloud)
## Usage ## Install
Use TypeScript for best in class intellisense
For further information read the linked docs at the top of this readme.
## Legal ```shell
> UNLICENSED licensed | **©** [Task Venture Capital GmbH](https://task.vc) npm install @signature.digital/catalog
| By using this npm module you agree to our [privacy policy](https://lossless.gmbH/privacy) # or
pnpm install @signature.digital/catalog
```
## 🎯 Overview
This package provides three main components for e-signature workflows:
| Component | Tag | Description |
|-----------|-----|-------------|
| **SignPad** | `<sdig-signpad>` | Canvas-based signature capture pad |
| **SignBox** | `<sdig-signbox>` | Complete signing interface with controls |
| **ContractEditor** | `<sdig-contracteditor>` | Contract document management component |
## 📦 Usage
### Basic Import
```typescript
import '@signature.digital/catalog';
```
This registers all custom elements and makes them available for use in your HTML.
### SignPad Component
The `<sdig-signpad>` is a canvas-based signature capture component that allows users to draw their signatures directly in the browser.
```html
<sdig-signpad></sdig-signpad>
```
**API Methods:**
```typescript
const signpad = document.querySelector('sdig-signpad');
// Get signature data as point arrays
const data = await signpad.toData();
// Load signature from data
await signpad.fromData(data);
// Export signature as SVG string
const svg = await signpad.toSVG();
// Undo last stroke
await signpad.undo();
// Clear the signature pad
await signpad.clear();
```
### SignBox Component
The `<sdig-signbox>` wraps `SignPad` with a complete UI including Clear, Undo, and Submit buttons.
```html
<sdig-signbox></sdig-signbox>
```
**Events:**
```typescript
const signbox = document.querySelector('sdig-signbox');
signbox.addEventListener('signature', (event) => {
const signatureData = event.detail.signature;
console.log('Signature captured:', signatureData);
});
```
### ContractEditor Component
The `<sdig-contracteditor>` provides contract viewing and editing capabilities using the `IPortableContract` interface from `@signature.digital/tools`.
```typescript
import '@signature.digital/catalog';
import { IPortableContract } from '@signature.digital/tools/interfaces';
import { demoContract } from '@signature.digital/tools/demodata';
const editor = document.querySelector('sdig-contracteditor');
editor.contract = demoContract;
```
## 🔧 Integration Example
Here's a complete example showing all components working together:
```typescript
import '@signature.digital/catalog';
import { LitElement, html, css } from 'lit';
import { customElement } from 'lit/decorators.js';
@customElement('my-signature-app')
class MySignatureApp extends LitElement {
static styles = css`
:host {
display: block;
max-width: 800px;
margin: 0 auto;
}
`;
render() {
return html`
<h2>Please sign below</h2>
<sdig-signbox @signature=${this.handleSignature}></sdig-signbox>
`;
}
private handleSignature(e: CustomEvent) {
console.log('Signature submitted:', e.detail.signature);
// Process or store the signature
}
}
```
## 🎨 Theming
Components support automatic light/dark theme detection and can be customized using CSS custom properties:
```css
sdig-signpad, sdig-signbox {
--main-background-color: #ffffff;
--line-color: #000000;
--button-color: #007bff;
}
```
The components use `cssManager.bdTheme()` for automatic theme switching based on system preferences.
## 📋 Requirements
- Modern browser with Custom Elements V1 support
- ECMAScript Modules (ESM) compatible environment
- TypeScript 5.0+ (for development)
## 🔗 Dependencies
- `@design.estate/dees-element` - LitElement-based component framework
- `@signature.digital/tools` - Contract interfaces and demo data
- `signature_pad` - Canvas signature capture library
## License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [LICENSE](./LICENSE) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
### Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
### Company Information
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.

View File

@@ -1,8 +1,8 @@
/** /**
* autocreated commitinfo by @pushrocks/commitinfo * autocreated commitinfo by @push.rocks/commitinfo
*/ */
export const commitinfo = { export const commitinfo = {
name: '@signature.digital_private/catalog', name: '@signature.digital/catalog',
version: '1.0.56', version: '1.1.0',
description: 'a catalog containing components for e-signing' description: 'A comprehensive catalog of customizable web components designed for building and managing e-signature applications.'
} }

View File

@@ -1,47 +0,0 @@
import { DeesElement, property, html, customElement, TemplateResult, css, cssManager } from '@designestate/dees-element';
import * as domtools from '@designestate/dees-domtools';
declare global {
interface HTMLElementTagNameMap {
'first-element': FirstElement;
}
}
@customElement('first-element')
export class FirstElement extends DeesElement {
public static demo = () => html`
<first-element .aProp="${'test'}"></first-element>
`;
@property({
type: String
})
public aProp: string = 'loading...';
constructor() {
super();
domtools.DomTools.setupDomTools();
}
public static styles = [
domtools.elementBasic.staticStyles,
css`
:host {
display: block;
background: blue;
color: white;
padding: 10px;
text-align: center;
}
`
]
public render(): TemplateResult {
return html`
<div class="mainbox">
${this.aProp}
</div>
`;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

View File

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

View File

@@ -0,0 +1,107 @@
import { DeesElement, property, html, customElement, type TemplateResult, css, cssManager } from '@design.estate/dees-element';
import * as plugins from '../../plugins.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-signbox': SignBox;
}
}
@customElement('sdig-signbox')
export class SignBox extends DeesElement {
public static demo = () => html`
<sdig-signbox></sdig-signbox>
`;
constructor() {
super();
}
public static styles = [
cssManager.defaultStyles,
css`
.mainbox {
position: relative;
background: ${cssManager.bdTheme('#eeeeeb', '#111111')};
border-radius: 16px;
max-width: 600px;
margin: auto;
overflow: hidden;
color: ${cssManager.bdTheme('#111111', '#eeeeeb')};
font-family: 'Roboto', sans-serif;
box-shadow: ${cssManager.bdTheme('0px 0px 8px 0px #00000040', 'none')};
}
.heading {
padding: 4px;
text-align: center;
font-weight: 500;
font-size: 12px;
margin-bottom: -20px;
pointer-events: none;
}
sdig-signpad {
position: relative;
z-index: 1;
}
.actions {
position: relative;
padding: 0px 24px;
font-size: 16px;
background: ${cssManager.bdTheme('#eeeeeb', '#000000')};
box-shadow: ${cssManager.bdTheme('0px 0px 8px 0px #00000040', 'none')};
z-index: 2;
display: flex;
justify-content: flex-end;
}
.button {
cursor: pointer;
margin: 0px 16px;
padding: 16px 0px;
color: ${cssManager.bdTheme('#666', '#999')};
user-select: none;
}
.button:hover {
color: ${cssManager.bdTheme('#111111', '#eeeeeb')};
}
`
];
public render(): TemplateResult {
return html`
<div class="mainbox">
<div class="heading">
You may sign below:
</div>
<sdig-signpad></sdig-signpad>
<div class="actions">
<div class="button" @click=${async () => {
await this.shadowRoot.querySelector('sdig-signpad').clear();
}}>
Clear
</div>
<div class="button" @click=${async () => {
await this.shadowRoot.querySelector('sdig-signpad').undo();
}}>
Undo
</div>
<div class="button" @click=${async () => {
const signature = await this.shadowRoot.querySelector('sdig-signpad').toData();
this.dispatchEvent(new CustomEvent('signature', {
detail: {
signature,
}
}));
console.log(signature);
}}>
Submit Signature
</div>
</div>
</div>
`;
}
}

View File

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

View File

@@ -0,0 +1,110 @@
import { DeesElement, property, html, customElement, type TemplateResult, css, cssManager } from '@design.estate/dees-element';
import * as plugins from '../../plugins.js';
declare global {
interface HTMLElementTagNameMap {
'sdig-signpad': SignPad;
}
}
@customElement('sdig-signpad')
export class SignPad extends DeesElement {
public static demo = () => html`
<sdig-signpad></sdig-signpad>
`;
constructor() {
super();
}
public static styles = [
cssManager.defaultStyles,
css`
:host {
display: block;
color: white;
position: relative;
max-width: 600px;
min-height: 280px;
max-height: 400px;
}
.mainbox {
position: relative;
width: 600px;
height: 280px;
}
.signline {
position: absolute;
bottom: 30%;
width: 80%;
left: 10%;
border-top: 1px dashed ${cssManager.bdTheme('#00000040', '#ffffff20')};
pointer-events: none;
}
canvas {
filter: ${cssManager.bdTheme('invert(0)', 'invert(1)')};
}
`
]
public render(): TemplateResult {
return html`
<div class="mainbox">
<div class="signline"></div>
</div>
`;
}
public signaturePad: typeof plugins.signaturePad.prototype;
public async firstUpdated() {
const domtools = await this.domtoolsPromise;
const canvas = document.createElement('canvas');
this.shadowRoot.querySelector('.mainbox').appendChild(canvas);
await this.resizeCanvas();
this.signaturePad = new plugins.signaturePad(canvas, {
});
this.signaturePad.on();
}
public async resizeCanvas() {
const mainbox = this.shadowRoot.querySelector('.mainbox');
const mainboxWidth = mainbox.clientWidth;
const mainboxHeight = mainbox.clientHeight;
const canvas = this.shadowRoot.querySelector('canvas');
canvas.width = mainboxWidth;
canvas.height = mainboxHeight;
if (this.signaturePad) {
this.signaturePad.clear();
}
}
public async clear() {
this.signaturePad.clear();
}
public async toData() {
const returnData = this.signaturePad.toData();
return returnData;
}
public async fromData(dataArrayArg: any[]) {
this.signaturePad.fromData(dataArrayArg);
}
public async toSVG() {
return this.signaturePad.toSVG({
includeBackgroundColor: false,
});
}
public async undo() {
const data = await this.toData();
data.pop();
await this.fromData(data);
}
}

26
ts_web/plugins.ts Normal file
View File

@@ -0,0 +1,26 @@
// @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'];
const signaturePad = signaturePadMod as any as signaturePadType;
export {
signaturePad,
}

View File

@@ -1,7 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"experimentalDecorators": true,
"useDefineForClassFields": false,
"target": "ES2022", "target": "ES2022",
"module": "NodeNext", "module": "NodeNext",
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",