diff --git a/SERVICE_CONTRACT_WIZARD_README.md b/SERVICE_CONTRACT_WIZARD_README.md new file mode 100644 index 0000000..f403036 --- /dev/null +++ b/SERVICE_CONTRACT_WIZARD_README.md @@ -0,0 +1,201 @@ +# Service Contract Migration Wizard - Implementation Summary + +## ✅ What Was Built + +A step-by-step wizard that migrates Vtiger service contracts to Hub systems: +- **Cases** → Archived to `tticket_archived_tickets` +- **Timelogs** → Transferred as klippekort top-ups (prepaid card hours) + +Features: +- ✅ Dry-run toggle (preview mode without database writes) +- ✅ Step-by-step review of each case/timelog +- ✅ Manual klippekort selection per timelog +- ✅ Progress tracking and summary report +- ✅ Read-only from Vtiger (no writes back to Vtiger) + +## 🎯 Files Created/Modified + +### New Files: +1. **[app/timetracking/backend/service_contract_wizard.py](app/timetracking/backend/service_contract_wizard.py)** (275 lines) + - Core wizard service with all business logic + - Methods: `load_contract_detailed_data()`, `archive_case()`, `transfer_timelog_to_klippekort()`, `get_wizard_summary()` + - Dry-run support built into each method + +2. **[app/timetracking/frontend/service_contract_wizard.html](app/timetracking/frontend/service_contract_wizard.html)** (650 lines) + - Complete wizard UI with Nordic design + - Contract dropdown selector + - Progress bar with live counters + - Current item display with conditional klippekort dropdown + - Summary report on completion + +### Modified Files: +1. **[app/services/vtiger_service.py](app/services/vtiger_service.py)** (+65 lines) + - Added `get_service_contracts(account_id=None)` - Fetch active service contracts + - Added `get_service_contract_cases(contract_id)` - Fetch cases linked to contract + - Added `get_service_contract_timelogs(contract_id)` - Fetch timelogs linked to contract + +2. **[app/timetracking/backend/models.py](app/timetracking/backend/models.py)** (+70 lines) + - `ServiceContractBase` - Base contract model + - `ServiceContractItem` - Single case/timelog item + - `ServiceContractWizardData` - Complete contract data for wizard + - `ServiceContractWizardAction` - Action result (archive/transfer) + - `ServiceContractWizardSummary` - Final summary + - `TimologTransferRequest` - Request model for timelog transfer + - `TimologTransferResult` - Transfer result + +3. **[app/timetracking/backend/router.py](app/timetracking/backend/router.py)** (+180 lines) + - `GET /api/v1/timetracking/service-contracts` - List contracts dropdown + - `POST /api/v1/timetracking/service-contracts/wizard/load` - Load contract data + - `POST /api/v1/timetracking/service-contracts/wizard/archive-case` - Archive case + - `POST /api/v1/timetracking/service-contracts/wizard/transfer-timelog` - Transfer timelog + - `GET /api/v1/timetracking/service-contracts/wizard/customer-cards/{customer_id}` - Get klippekort + +4. **[app/timetracking/frontend/views.py](app/timetracking/frontend/views.py)** (+5 lines) + - Added frontend route: `/timetracking/service-contract-wizard` + +## 🚀 How to Test + +### 1. Start the API +```bash +docker-compose up -d api +docker-compose logs -f api +``` + +### 2. Access the Wizard +``` +http://localhost:8000/timetracking/service-contract-wizard +``` + +### 3. Dry-Run Mode (Recommended First) +1. Check the "Preview Mode" checkbox at top (enabled by default) +2. Select a service contract from dropdown +3. Review each case/timelog and click "Gem & Næste" +4. No data is written to database in dry-run mode +5. Review summary report to see what WOULD be changed + +### 4. Live Mode +1. **Uncheck** "Preview Mode" checkbox +2. Select same or different contract +3. Process items - changes ARE committed to database +4. Cases are exported to `tticket_archived_tickets` +5. Timelogs are added to klippekort via top-up transaction + +## 🔍 Database Changes + +### Dryrun Mode: +- All operations are **logged** but **NOT committed** +- Queries are constructed but rolled back +- UI shows what WOULD happen + +### Live Mode: +- Cases are inserted into `tticket_archived_tickets` with: + - `source_system = 'vtiger_service_contract'` + - `external_id = vtiger case ID` + - Full case data in `raw_data` JSONB field + +- Timelogs create transactions in `tticket_prepaid_transactions` with: + - `transaction_type = 'top_up'` + - Hours added to klippekort `purchased_hours` + - Description references vTiger timelog + +## 📊 Data Flow + +``` +Vtiger Service Contract + ↓ +SelectContract (dropdown) + ↓ +LoadContractData + ├─ Cases → Archive to tticket_archived_tickets + └─ Timelogs → Transfer to klippekort (top-up) + ↓ +WizardProgress (step-by-step review) + ├─ [DRY RUN] Preview mode (no DB writes) + └─ [LIVE] Commit to database + ↓ +Summary Report + ├─ Cases archived: N + ├─ Hours transferred: N + └─ Failed items: N +``` + +## 🔐 Safety Features + +1. **Dry-run mode enabled by default** - Users see what WOULD happen first +2. **Customer linking** - Looks up Hub customer ID from vTiger account +3. **Klippekort validation** - Verifies card belongs to customer before transfer +4. **Read-only from Vtiger** - No writes back to Vtiger (only reads) +5. **Transaction handling** - Each operation is atomic +6. **Audit logging** - All actions logged with DRY RUN/COMMITTED markers + +## 🛠️ Technical Details + +### Wizard Service (`ServiceContractWizardService`) +- Stateless service class +- All methods are static +- Database operations via `execute_query()` helpers +- Klippekort transfers via `KlippekortService.top_up_card()` + +### Frontend UI +- Vanilla JavaScript (no frameworks) +- Nordic Top design system (matches existing Hub UI) +- Responsive Bootstrap 5 grid +- Real-time progress updates +- Conditional klippekort dropdown (only for timelogs) + +### API Endpoints +- RESTful architecture +- All endpoints support `dry_run` query parameter +- Request/response models use Pydantic validation +- Comprehensive error handling with HTTPException + +## 📝 Logging Output + +### Dry-Run Mode: +``` +🔍 DRY RUN: Would archive case 1x123: 'Case Title' +🔍 DRY RUN: Would transfer 5h to card 42 from timelog 2x456 +``` + +### Live Mode: +``` +✅ Archived case 1x123 to tticket_archived_tickets (ID: 1234) +✅ Transferred 5h from timelog 2x456 to card 42 +``` + +## 🐛 Troubleshooting + +### Contracts dropdown is empty: +- Verify Vtiger integration is configured (VTIGER_URL, VTIGER_USERNAME, VTIGER_API_KEY in .env) +- Check vTiger has active ServiceContracts +- Check API user has access to ServiceContracts module + +### Klippekort dropdown empty for customer: +- Customer may not have any active prepaid cards +- Or customer is not linked between Vtiger account and Hub customer +- Create a prepaid card for the customer first + +### Dry-run mode not working: +- Ensure checkbox is checked +- Check browser console for JavaScript errors +- Verify `dry_run` parameter is passed to API endpoints + +## 📋 Next Steps + +1. **Test with sample data** - Create test service contract in Vtiger +2. **Verify database changes** - Query `tticket_archived_tickets` post-migration +3. **Monitor klippekort** - Check `tticket_prepaid_transactions` for top-up entries +4. **Adjust as needed** - Tweak timelog filtering or case mapping based on results + +## 🔗 Related Components + +- **Klippekort System**: [app/ticket/backend/klippekort_service.py](app/ticket/backend/klippekort_service.py) +- **Archive System**: Database table `tticket_archived_tickets` +- **Timetracking Module**: [app/timetracking/](app/timetracking/) +- **Vtiger Integration**: [app/services/vtiger_service.py](app/services/vtiger_service.py) + +--- + +**Status**: ✅ Ready for testing and deployment +**Estimated Time to Test**: 15-20 minutes +**Database Dependency**: PostgreSQL (no migrations needed - uses existing tables) diff --git a/app/contacts/frontend/contact_detail.html b/app/contacts/frontend/contact_detail.html index 12b46bf..4613b32 100644 --- a/app/contacts/frontend/contact_detail.html +++ b/app/contacts/frontend/contact_detail.html @@ -98,6 +98,18 @@ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); transform: translateY(-2px); } + + .session-card { + background: var(--bg-card); + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 12px; + padding: 1rem; + } + + .session-meta { + font-size: 0.9rem; + color: var(--text-secondary); + } .remove-company-btn { position: absolute; @@ -147,6 +159,11 @@ Firmaer + @@ -236,6 +253,29 @@ + + +
+
+
Remote Sessions
+
+ + + + +
+
+
+
+
+
+
+
+
@@ -354,6 +394,22 @@ + + + {% endblock %} {% block extra_js %} @@ -369,6 +425,13 @@ document.addEventListener('DOMContentLoaded', () => { document.querySelector('a[href="#companies"]').addEventListener('shown.bs.tab', () => { loadCompanies(); }); + + const remoteSessionsTab = document.querySelector('a[href="#remote-sessions"]'); + if (remoteSessionsTab) { + remoteSessionsTab.addEventListener('shown.bs.tab', () => { + loadRemoteSessions(); + }); + } }); async function loadContact() { @@ -415,6 +478,8 @@ function displayContact(contact) { ? 'Aktiv' : 'Inaktiv'; document.getElementById('companyCount').textContent = contact.companies ? contact.companies.length : 0; + + populateSessionCompanySelect(contact); // System Info document.getElementById('vtigerId').textContent = contact.vtiger_id || '-'; @@ -426,6 +491,27 @@ function displayContact(contact) { } } +function populateSessionCompanySelect(contact) { + const select = document.getElementById('sessionCompanySelect'); + if (!select) return; + + const companies = contact.companies || []; + if (companies.length === 0) { + select.innerHTML = ''; + select.disabled = true; + return; + } + + const primary = companies.find(c => c.is_primary) || companies[0]; + select.innerHTML = companies.map(c => { + const label = c.is_primary ? `${c.name} (Primær)` : c.name; + return ``; + }).join(''); + + select.value = String(primary.id); + select.disabled = false; +} + async function loadCompanies() { if (!contactData) return; displayCompanies(contactData.companies); @@ -470,6 +556,164 @@ function displayCompanies(companies) { `).join(''); } +async function loadRemoteSessions() { + const container = document.getElementById('remoteSessionsContainer'); + if (!container) return; + + container.innerHTML = ` +
+
+
+ `; + + try { + const response = await fetch(`/api/v1/anydesk/sessions?contact_id=${contactId}`); + if (!response.ok) { + throw new Error('Kunne ikke hente sessioner'); + } + + const data = await response.json(); + displayRemoteSessions(data.sessions || []); + } catch (error) { + console.error('Failed to load sessions:', error); + container.innerHTML = '
Kunne ikke hente sessioner
'; + } +} + +function displayRemoteSessions(sessions) { + const container = document.getElementById('remoteSessionsContainer'); + if (!container) return; + + if (!sessions || sessions.length === 0) { + container.innerHTML = '
Ingen remote sessions endnu
'; + return; + } + + container.innerHTML = sessions.map(session => { + const badgeClass = getSessionBadgeClass(session.status); + const duration = session.duration_minutes ? `${session.duration_minutes} min` : '—'; + const startedAt = session.started_at ? new Date(session.started_at).toLocaleString('da-DK') : '-'; + const endedAt = session.ended_at ? new Date(session.ended_at).toLocaleString('da-DK') : '-'; + const linkButton = session.session_link + ? `Åbn Link` + : ''; + const worklogButton = session.status === 'completed' + ? `` + : ''; + + return ` +
+
+
+
+
+ ${escapeHtml(session.status || 'ukendt')} + Session #${session.id} +
+
Start: ${startedAt} | Slut: ${endedAt} | Varighed: ${duration}
+ ${session.sag_id ? `
Sag ID: ${session.sag_id}
` : ''} +
+
+ ${linkButton} + ${worklogButton} +
+
+
+
+ `; + }).join(''); +} + +async function startRemoteSession() { + const alertBox = document.getElementById('remoteSessionAlert'); + const select = document.getElementById('sessionCompanySelect'); + + if (!contactData || !select || select.disabled) { + alert('Kontakt skal have mindst et firma for at starte en session'); + return; + } + + const customerId = parseInt(select.value); + const sagIdValue = document.getElementById('sessionSagIdInput').value; + const sagId = sagIdValue ? parseInt(sagIdValue) : null; + + try { + const response = await fetch('/api/v1/anydesk/start-session', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + customer_id: customerId, + contact_id: contactId, + sag_id: sagId, + description: `Remote support for ${contactData.first_name} ${contactData.last_name}` + }) + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Kunne ikke starte session'); + } + + const data = await response.json(); + if (alertBox) { + alertBox.classList.remove('d-none'); + alertBox.innerHTML = `Session startet. ${data.session_link ? `Link: ${data.session_link}` : ''}`; + } + + if (data.session_link) { + window.open(data.session_link, '_blank'); + } + + await loadRemoteSessions(); + } catch (error) { + console.error('Failed to start session:', error); + alert('Fejl: ' + error.message); + } +} + +async function showWorklogSuggestion(sessionId) { + try { + const response = await fetch(`/api/v1/anydesk/sessions/${sessionId}/worklog-suggestion`); + if (!response.ok) { + const error = await response.json(); + throw new Error(error.detail || 'Kunne ikke hente forslag'); + } + + const suggestion = await response.json(); + const body = document.getElementById('worklogSuggestionBody'); + if (body) { + body.innerHTML = ` +
Varighed: ${suggestion.duration_hours} timer
+
Start: ${new Date(suggestion.start_time).toLocaleString('da-DK')}
+
Slut: ${new Date(suggestion.end_time).toLocaleString('da-DK')}
+
Beskrivelse: ${escapeHtml(suggestion.description || '')}
+
Forslaget kan bruges til manuel tidsregistrering
+ `; + } + + const modal = new bootstrap.Modal(document.getElementById('worklogSuggestionModal')); + modal.show(); + } catch (error) { + console.error('Failed to load suggestion:', error); + alert('Fejl: ' + error.message); + } +} + +function getSessionBadgeClass(status) { + switch ((status || '').toLowerCase()) { + case 'active': + return 'bg-success'; + case 'completed': + return 'bg-primary'; + case 'failed': + return 'bg-danger'; + case 'cancelled': + return 'bg-secondary'; + default: + return 'bg-light text-dark'; + } +} + async function loadCompaniesForSelect() { try { const response = await fetch('/api/v1/customers?limit=1000'); diff --git a/app/core/config.py b/app/core/config.py index 2fa4171..266d6d5 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -68,6 +68,13 @@ class Settings(BaseSettings): NEXTCLOUD_TIMEOUT_SECONDS: int = 15 NEXTCLOUD_CACHE_TTL_SECONDS: int = 300 NEXTCLOUD_ENCRYPTION_KEY: str = "" + + # Wiki.js Integration + WIKI_BASE_URL: str = "https://wiki.bmcnetworks.dk" + WIKI_API_TOKEN: str = "" + WIKI_API_KEY: str = "" + WIKI_TIMEOUT_SECONDS: int = 12 + WIKI_READ_ONLY: bool = True # Ollama LLM OLLAMA_ENDPOINT: str = "http://localhost:11434" @@ -197,6 +204,21 @@ class Settings(BaseSettings): REMINDERS_MAX_PER_USER_PER_HOUR: int = 5 REMINDERS_QUEUE_BATCH_SIZE: int = 10 + # AnyDesk Remote Support Integration + ANYDESK_LICENSE_ID: str = "" + ANYDESK_API_TOKEN: str = "" + ANYDESK_PASSWORD: str = "" + ANYDESK_READ_ONLY: bool = True # SAFETY: Prevent API calls if true + ANYDESK_DRY_RUN: bool = True # SAFETY: Log without executing API calls + ANYDESK_TIMEOUT_SECONDS: int = 30 + ANYDESK_AUTO_START_SESSION: bool = True # Auto-start session when requested + + # SMS Integration (CPSMS) + SMS_API_KEY: str = "" + SMS_USERNAME: str = "" + SMS_SENDER: str = "BMC Networks" + SMS_WEBHOOK_SECRET: str = "" + # Dev-only shortcuts DEV_ALLOW_ARCHIVED_IMPORT: bool = False diff --git a/app/customers/backend/router.py b/app/customers/backend/router.py index 424ec31..ea85e38 100644 --- a/app/customers/backend/router.py +++ b/app/customers/backend/router.py @@ -34,6 +34,7 @@ class CustomerBase(BaseModel): postal_code: Optional[str] = None country: Optional[str] = "DK" website: Optional[str] = None + wiki_slug: Optional[str] = None is_active: Optional[bool] = True invoice_email: Optional[str] = None mobile_phone: Optional[str] = None @@ -53,6 +54,7 @@ class CustomerUpdate(BaseModel): postal_code: Optional[str] = None country: Optional[str] = None website: Optional[str] = None + wiki_slug: Optional[str] = None is_active: Optional[bool] = None invoice_email: Optional[str] = None mobile_phone: Optional[str] = None diff --git a/app/customers/frontend/customer_detail.html b/app/customers/frontend/customer_detail.html index 60d31c8..f5da03c 100644 --- a/app/customers/frontend/customer_detail.html +++ b/app/customers/frontend/customer_detail.html @@ -97,6 +97,18 @@ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); transform: translateY(-2px); } + + .session-card { + background: var(--bg-card); + border: 1px solid rgba(0, 0, 0, 0.1); + border-radius: 12px; + padding: 1rem; + } + + .session-meta { + font-size: 0.9rem; + color: var(--text-secondary); + } .activity-item { padding: 1.5rem; @@ -326,6 +338,11 @@ Nextcloud +