This commit is contained in:
gsinghpal
2026-03-16 08:14:56 -04:00
parent fdca9518ab
commit e56974d46f
196 changed files with 19739 additions and 3471 deletions

View File

@@ -0,0 +1,135 @@
---
description: Use the Fusion API module for all API calls (OpenAI, Anthropic, Google Maps, Twilio, OAuth) in any Fusion module
globs: fusion_*/models/**/*.py, fusion_*/services/**/*.py
alwaysApply: false
---
# Fusion API Integration Guide
When any Fusion module needs to call an external API (OpenAI, Anthropic, Google Maps, Twilio, Google/Microsoft OAuth), it MUST use the centralized `fusion.api.service` instead of managing its own keys.
The service lives at `fusion_api/models/api_service.py` and is an AbstractModel accessible via `self.env['fusion.api.service']`.
## Available Methods
### 1. `call_openai()` -- AI text generation via OpenAI
```python
result = self.env['fusion.api.service'].call_openai(
consumer='fusion_clock_ai', # your module's technical name
feature='timesheet_summary', # descriptive feature name for tracking
messages=[
{'role': 'system', 'content': 'You are a helpful assistant.'},
{'role': 'user', 'content': 'Summarize this timesheet...'},
],
model='gpt-4o-mini', # optional, defaults to gpt-4o-mini
max_tokens=1024, # optional, defaults to 1024
user=self.env.user, # optional, defaults to current user
)
# Returns: str (the text response)
```
### 2. `call_anthropic()` -- AI text generation via Claude
```python
result = self.env['fusion.api.service'].call_anthropic(
consumer='fusion_notes',
feature='voice_transcription',
messages=[
{'role': 'user', 'content': 'Transcribe this text...'},
],
system='You are a medical transcription assistant.', # optional system prompt
model='claude-sonnet-4-20250514', # optional, defaults to claude-sonnet-4-20250514
max_tokens=1024, # optional
)
# Returns: str (the text response)
```
### 3. `get_api_key()` -- raw API key for non-AI services
```python
api_key = self.env['fusion.api.service'].get_api_key(
provider_type='google_maps', # one of: google_maps, twilio, custom
consumer='fusion_shipping', # your module's technical name
feature='geocoding', # optional feature name
)
# Returns: str (the raw API key to use in your own HTTP calls)
```
### 4. `get_oauth_credentials()` -- OAuth tokens for Google/Microsoft
```python
creds = self.env['fusion.api.service'].get_oauth_credentials(
provider_type='google_oauth', # google_oauth or microsoft_oauth
consumer='fusion_schedule',
feature='calendar_sync',
)
# Returns: dict with keys:
# client_id, client_secret, access_token, refresh_token, token_expiry, redirect_uri
```
## Provider Types
| Type | Use For |
|------|---------|
| `openai` | GPT-4o, GPT-4o-mini, o1, etc. |
| `anthropic` | Claude Sonnet, Haiku, Opus |
| `google_maps` | Geocoding, Places, Distance Matrix |
| `google_oauth` | Google Calendar, Drive OAuth |
| `microsoft_oauth` | Microsoft Calendar, Graph OAuth |
| `twilio` | SMS, Voice |
| `custom` | Any other API provider |
## What Happens Automatically
- The calling module is auto-registered as a consumer in Fusion API
- Every call is logged with: tokens, cost, response time, user, feature, model
- Budget caps (monthly/daily) are enforced before each call
- Rate limits (RPM/RPD) are enforced before each call
- Per-user limits are checked if configured
- On limit exceeded, a `UserError` is raised with a clear message
## Phased Migration Pattern
Do NOT add `fusion_api` as a hard dependency. Use a try/fallback pattern so the module works with or without Fusion API installed:
```python
def _call_ai(self, messages, feature='general'):
"""Call OpenAI through Fusion API, falling back to own key."""
try:
return self.env['fusion.api.service'].call_openai(
consumer='fusion_clock_ai',
feature=feature,
messages=messages,
)
except Exception:
api_key = self.env['ir.config_parameter'].sudo().get_param(
'fusion_clock_ai.openai_api_key'
)
if not api_key:
raise
from openai import OpenAI
client = OpenAI(api_key=api_key)
response = client.chat.completions.create(
model='gpt-4o-mini', messages=messages,
)
return response.choices[0].message.content
```
## Rules
- NEVER hardcode API keys in Python code
- NEVER store API keys in `ir.config_parameter` for new modules -- use Fusion API
- ALWAYS pass the module's technical name (e.g. `fusion_clock_ai`) as the `consumer` parameter
- ALWAYS pass a descriptive `feature` string for usage tracking granularity
- NEVER catch and silently swallow errors from `fusion.api.service` -- let UserError propagate
- DO NOT add `fusion_api` to `depends` in `__manifest__.py` -- use the fallback pattern above
- The `consumer` string should match the module's technical name exactly (folder name)
## Odoo 19 Conventions
- This project targets Odoo 19 (Community/Enterprise)
- No hardcoded colors in views or SCSS -- let Odoo handle dark/light theming
- Use `res.groups.privilege` for security groups (not `category_id`)
- Do not use deprecated `ir.cron` fields (`numbercall`, `doall`)
- Settings views: use `name=` attribute on `<app>` tags, not `data-key=`

40
CLAUDE.md Normal file
View File

@@ -0,0 +1,40 @@
# Odoo Modules — Claude Code Instructions
## Project
27 custom Odoo 19 modules for Fusion Central (Westin Healthcare + NEXA Systems).
## Critical Rules — Odoo 19
1. **NEVER code from memory** — Always read a reference file from Docker first:
```bash
docker exec odoo-dev-app cat /usr/lib/python3/dist-packages/odoo/addons/<module>/static/src/<path>
```
2. **Frontend JS**: Use `Interaction` class from `@web/public/interaction`, registered via `registry.category("public.interactions")`. NOT IIFE/DOMContentLoaded.
3. **Backend OWL**: Use standalone `rpc()` from `@web/core/network/rpc`. NOT `useService("rpc")`. `static props = []` not `{}`.
4. **HTTP routes**: `type="jsonrpc"` — NOT `type="json"` (deprecated).
5. **res.config.settings**: Only boolean/integer/float/char/selection/many2one/datetime. NO Date fields.
6. **res.groups**: NO `users` field, NO `category_id` field.
7. **Search views**: NO `group expand="0"` syntax.
## Naming
- New fields: `x_fc_*` prefix
- Legacy fields: `x_studio_*`
- Canadian English for all user-facing text
- Currency: `$` sign with Monetary fields + currency_id
## Cursor-Managed Modules
- **fusion_clock** is currently being modified in Cursor — always read files fresh before editing, don't assume you know the current state
## Workflow
- Local dev: `docker exec odoo-dev-app odoo -d fusion-dev -u <module> --stop-after-init`
- Local URL: http://localhost:8069
- Test before deploying. Edit existing files — don't create unnecessary new ones.
## Supabase Knowledge Base
Before starting unfamiliar work, check Supabase for context:
```bash
PGPASSWORD='a09e12e0995dc29446631fa458f3d4b3' psql -h 100.74.28.73 -p 5433 -U postgres -d postgres
```
- `fusionapps.decisions` — past architecture decisions
- `fusionapps.issues` — known issues and fixes
- `fusionapps.code_snippets` — reference code
- `fusionapps.quick_commands` — deployment and admin commands

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,79 @@
# Fusion API - Design Document
**Goal:** Build a standalone Odoo 19 module (`fusion_api`) serving as the central hub for API key management, usage tracking, rate limiting, and access control across all Fusion products.
**Architecture:** Dedicated Odoo application with a provider/key vault, auto-detected consumer modules, per-module and per-user access rules with budget caps and rate limits, granular usage logging with daily aggregation, and an OWL dashboard. Other Fusion modules call `self.env['fusion.api.service']` methods which handle key retrieval, access checks, usage logging, and API execution.
**Tech Stack:** Odoo 19, Python 3.12, OWL 2, OpenAI SDK (optional), Anthropic SDK (optional)
---
## Scope
### In Scope
- Shared service keys: OpenAI, Anthropic, Google Maps, Twilio
- OAuth services: Google OAuth, Microsoft OAuth
- Usage tracking: per module, per feature, per user, per AI model
- Rate limiting: global budgets, per-module budgets, RPM/RPD limits, per-user limits
- Access control: enable/disable per module, per user blocking
- Auto-detection of consumer modules on first API call
- OWL dashboard with usage stats and charts
### Out of Scope
- Shipping carrier credentials (stay on `delivery.carrier`)
- Payment provider credentials (stay on `payment.provider`)
- Migrating existing modules (phased rollout later)
---
## Data Model
### `fusion.api.provider`
API service providers (OpenAI, Anthropic, Google Maps, etc.). Pre-seeded with known providers.
### `fusion.api.key`
Credential storage per provider. Supports simple API keys and OAuth (client_id/secret/tokens). Masked in UI, system-group access only. Multiple keys per provider with default selection.
### `fusion.api.consumer`
Auto-detected Fusion modules. Created automatically when a module first calls the API service. Links to `ir.module.module`. Master kill switch per module.
### `fusion.api.access`
Access rules linking consumers to providers. Configures: enable/disable, monthly/daily budget caps, RPM/RPD rate limits. Computed fields show current usage and budget percentage.
### `fusion.api.user.limit`
Optional per-user overrides. Monthly budget, daily request cap, manual block toggle.
### `fusion.api.usage`
Individual API call log. Full granularity: consumer, provider, user, feature, model, tokens, cost, response time, status.
### `fusion.api.usage.daily`
Cron-aggregated daily summaries. Same dimensions with summed totals. Used for reporting and charts.
---
## Service Layer
`fusion.api.service` (AbstractModel) provides:
- `call_openai()` - Make OpenAI chat completion calls
- `call_anthropic()` - Make Anthropic messages API calls
- `get_api_key()` - Get raw API key for any provider (for non-chat uses like Google Maps)
- Auto-registration of consumers on first call
- Access control checks (budget, rate limit, user limit)
- Usage logging with cost estimation
- Key validation
---
## Migration Path
1. Module is standalone - no existing modules need changes to install it
2. Existing modules will be migrated one-by-one to use `fusion.api.service` instead of their own API key fields
3. During migration, modules can check `fusion_api` first and fall back to their own keys
---
## Security Groups
- `fusion_api.group_user` - View usage data
- `fusion_api.group_manager` - Manage providers, consumers, access rules
- `fusion_api.group_admin` - View/edit API keys, full system access

View File

@@ -0,0 +1,89 @@
# Fusion Schedule Redesign
Date: 2026-03-15
Status: Approved
## Summary
Redesign the `/my/schedule` portal page with responsive mobile-friendly layout,
add email/phone to the booking form, enable reschedule/cancel for both staff and
public visitors, show source calendar in event listings, and add a share button
for the public booking link.
## Features
### 1. Booking Form -- Email & Phone Fields
Add optional Email and Phone fields to the internal booking form (Step 2).
If email is provided, find-or-create a `res.partner` and add them as an attendee
so Odoo sends a calendar invitation. If no email, event is created with only the
staff user as attendee (current behavior).
Files: `portal_schedule.xml`, `portal_schedule.py`
### 2. Reschedule & Cancel
**Staff users** (logged in, `/my/schedule`):
- Each event row gets Reschedule and Cancel action buttons
- Reschedule opens a modal with date picker + slot selector (reuses existing slot API)
- Cancel shows confirmation, then deletes the event
- New endpoints: `/my/schedule/event/reschedule`, `/my/schedule/event/cancel`
**External visitors** (booked via public link):
- After booking, show a manage URL with a unique token: `/schedule/manage/<token>`
- Token stored as `x_fc_manage_token` on `calendar.event`
- Manage page shows appointment details + Reschedule / Cancel buttons
- No login required; token is the auth
- New endpoints: `/schedule/manage/<token>`, `/schedule/manage/<token>/reschedule`,
`/schedule/manage/<token>/cancel`
Files: `calendar_event.py`, `portal_schedule.py`, `portal_schedule.xml`,
`public_booking.xml`, `portal_schedule_accounts.js`
### 3. Source Calendar Column
Add a "Source" column to Today's Appointments and Upcoming Appointments tables.
Show provider icon (Google/Outlook) + truncated email, or "Odoo" for local events.
Data already available via `x_fc_source_account_id`.
Files: `portal_schedule.xml`, `portal_schedule.py`
### 4. Share Public Booking Link
Add a prominent "Share Calendar" button in the header area of `/my/schedule`.
Clicking copies the public booking link to clipboard. The public booking page
already only shows available time slots (no existing events).
Files: `portal_schedule.xml`
### 5. Responsive Redesign
- Connected Calendars: collapsible section, compact status bar when collapsed,
full details when expanded. Collapsed by default.
- Today's Appointments: card-based on mobile, list on desktop
- Upcoming Appointments: table on desktop, card list on mobile
- All action buttons: icon-only on mobile, icon+text on desktop
- Bootstrap 5 responsive utilities throughout
Files: `portal_schedule.xml`, new CSS in `portal_schedule.css`
## Data Model Changes
`calendar.event`:
- Add `x_fc_manage_token` (Char, index=True) for public manage links
## New Endpoints
| Route | Auth | Method | Purpose |
|-------|------|--------|---------|
| `/my/schedule/event/cancel` | user | JSONRPC | Cancel/delete a calendar event |
| `/my/schedule/event/reschedule` | user | JSONRPC | Reschedule a calendar event |
| `/schedule/manage/<token>` | public | HTTP | View appointment details |
| `/schedule/manage/<token>/reschedule` | public | POST | Reschedule via token |
| `/schedule/manage/<token>/cancel` | public | POST | Cancel via token |
## Security
- Staff reschedule/cancel: verify `partner_ids` contains current user
- Public manage: token-only access, tokens are 32-char hex random
- All mutations use CSRF protection

5
fusion_api/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import models

View File

@@ -0,0 +1,73 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion API',
'version': '19.0.1.0.0',
'category': 'Productivity',
'summary': 'Central API Key Management, Usage Tracking & Rate Limiting for Fusion Products',
'description': """
Fusion API - Central API Hub for Fusion Products
==================================================
Centralized management of API keys, usage tracking, rate limiting,
and access control for all Fusion modules.
Features
--------
* **Provider Management** - Configure API keys for OpenAI, Anthropic, Google Maps, Twilio, and more
* **Auto-Detection** - Automatically discovers which Fusion modules use the API
* **Usage Tracking** - Per-module, per-feature, per-user, per-model granular usage logs
* **Rate Limiting** - Requests per minute/day limits per module and per user
* **Budget Control** - Monthly and daily cost caps per module and per user
* **Access Control** - Enable/disable API access per module, block individual users
* **Dashboard** - Real-time overview of API usage, costs, and health
* **Daily Reports** - Aggregated usage summaries for trend analysis
Supported Providers
-------------------
* OpenAI (GPT-4o, GPT-4o-mini, GPT-3.5-turbo)
* Anthropic (Claude 3.5 Sonnet, Claude 3.5 Haiku, Claude 3 Opus)
* Google Maps (Geocoding, Places, Distance Matrix)
* Google OAuth (Calendar, Drive)
* Microsoft OAuth (Calendar, Graph)
* Twilio (SMS, Voice)
* Custom providers
""",
'author': 'Nexa Systems Inc.',
'website': 'https://nexasystems.io',
'license': 'OPL-1',
'depends': [
'base',
'mail',
],
'data': [
'security/security.xml',
'security/ir.model.access.csv',
'data/api_provider_data.xml',
'data/ir_cron_data.xml',
'views/api_key_views.xml',
'views/api_provider_views.xml',
'views/api_consumer_views.xml',
'views/api_access_views.xml',
'views/api_user_limit_views.xml',
'views/api_usage_views.xml',
'views/api_dashboard_views.xml',
'views/res_config_settings_views.xml',
'views/menus.xml',
],
'assets': {
'web.assets_backend': [
'fusion_api/static/src/scss/fusion_api.scss',
'fusion_api/static/src/js/dashboard.js',
'fusion_api/static/src/xml/dashboard.xml',
],
},
'images': ['static/description/icon.png'],
'installable': True,
'auto_install': False,
'application': True,
}

View File

@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="provider_openai" model="fusion.api.provider">
<field name="name">OpenAI</field>
<field name="provider_type">openai</field>
<field name="status">inactive</field>
<field name="sequence">1</field>
<field name="icon_class">fa-brain</field>
<field name="description">OpenAI API for GPT models (GPT-4o, GPT-4o-mini, GPT-3.5-turbo, o1). Used for text generation, chat completion, embeddings, and more.</field>
<field name="website_url">https://platform.openai.com/api-keys</field>
</record>
<record id="provider_anthropic" model="fusion.api.provider">
<field name="name">Anthropic</field>
<field name="provider_type">anthropic</field>
<field name="status">inactive</field>
<field name="sequence">2</field>
<field name="icon_class">fa-comments</field>
<field name="description">Anthropic API for Claude models (Claude 3.5 Sonnet, Claude 3.5 Haiku, Claude 3 Opus). Used for text generation and analysis.</field>
<field name="website_url">https://console.anthropic.com/settings/keys</field>
</record>
<record id="provider_google_maps" model="fusion.api.provider">
<field name="name">Google Maps</field>
<field name="provider_type">google_maps</field>
<field name="status">inactive</field>
<field name="sequence">3</field>
<field name="icon_class">fa-map-marker</field>
<field name="description">Google Maps Platform API. Used for geocoding, places, distance matrix, maps display, and directions.</field>
<field name="website_url">https://console.cloud.google.com/apis/credentials</field>
</record>
<record id="provider_google_oauth" model="fusion.api.provider">
<field name="name">Google OAuth</field>
<field name="provider_type">google_oauth</field>
<field name="status">inactive</field>
<field name="sequence">4</field>
<field name="icon_class">fa-google</field>
<field name="description">Google OAuth 2.0 credentials for Calendar, Drive, and other Google Workspace integrations.</field>
<field name="website_url">https://console.cloud.google.com/apis/credentials</field>
</record>
<record id="provider_microsoft_oauth" model="fusion.api.provider">
<field name="name">Microsoft OAuth</field>
<field name="provider_type">microsoft_oauth</field>
<field name="status">inactive</field>
<field name="sequence">5</field>
<field name="icon_class">fa-windows</field>
<field name="description">Microsoft Azure AD OAuth credentials for Calendar, Outlook, and Microsoft 365 integrations.</field>
<field name="website_url">https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps</field>
</record>
<record id="provider_twilio" model="fusion.api.provider">
<field name="name">Twilio</field>
<field name="provider_type">twilio</field>
<field name="status">inactive</field>
<field name="sequence">6</field>
<field name="icon_class">fa-phone</field>
<field name="description">Twilio API for SMS, voice calls, and communication services.</field>
<field name="website_url">https://console.twilio.com</field>
</record>
</odoo>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="cron_aggregate_daily_usage" model="ir.cron">
<field name="name">Fusion API: Aggregate Daily Usage</field>
<field name="model_id" ref="model_fusion_api_usage_daily"/>
<field name="state">code</field>
<field name="code">model._cron_aggregate_daily()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
<field name="priority">90</field>
</record>
<record id="cron_cleanup_old_logs" model="ir.cron">
<field name="name">Fusion API: Cleanup Old Usage Logs</field>
<field name="model_id" ref="model_fusion_api_usage_daily"/>
<field name="state">code</field>
<field name="code">model._cron_cleanup_old_logs()</field>
<field name="interval_number">1</field>
<field name="interval_type">weeks</field>
<field name="active">True</field>
<field name="priority">95</field>
</record>
</odoo>

View File

@@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import api_provider
from . import api_key
from . import api_consumer
from . import api_access
from . import api_user_limit
from . import api_usage
from . import api_usage_daily
from . import api_service
from . import res_config_settings

View File

@@ -0,0 +1,95 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api
class FusionApiAccess(models.Model):
_name = 'fusion.api.access'
_description = 'API Access Rule'
_order = 'consumer_id, provider_id'
_rec_name = 'display_name'
consumer_id = fields.Many2one(
'fusion.api.consumer', required=True, ondelete='cascade',
)
provider_id = fields.Many2one(
'fusion.api.provider', required=True, ondelete='cascade',
)
is_enabled = fields.Boolean(default=True, string='Enabled', tracking=True)
monthly_budget_usd = fields.Float(
string='Monthly Budget (USD)',
help='Maximum monthly spend. 0 = unlimited.',
)
daily_budget_usd = fields.Float(
string='Daily Budget (USD)',
help='Maximum daily spend. 0 = unlimited.',
)
max_rpm = fields.Integer(
string='Max Requests/Min',
help='Maximum requests per minute. 0 = unlimited.',
)
max_rpd = fields.Integer(
string='Max Requests/Day',
help='Maximum requests per day. 0 = unlimited.',
)
current_month_cost = fields.Float(
compute='_compute_current_usage', string='Month Spend',
)
current_day_cost = fields.Float(
compute='_compute_current_usage', string='Today Spend',
)
current_day_requests = fields.Integer(
compute='_compute_current_usage', string='Today Requests',
)
budget_usage_pct = fields.Float(
compute='_compute_current_usage', string='Budget Used %',
)
is_budget_exceeded = fields.Boolean(compute='_compute_current_usage')
display_name = fields.Char(compute='_compute_display_name', store=True)
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
_sql_constraints = [
('consumer_provider_uniq', 'unique(consumer_id, provider_id, company_id)',
'Only one access rule per consumer-provider pair per company.'),
]
@api.depends('consumer_id.name', 'provider_id.name')
def _compute_display_name(self):
for rec in self:
consumer = rec.consumer_id.name or ''
provider = rec.provider_id.name or ''
rec.display_name = f"{consumer} / {provider}"
def _compute_current_usage(self):
today = fields.Date.today()
month_start = today.replace(day=1)
for rec in self:
month_usages = self.env['fusion.api.usage'].sudo().search([
('consumer_id', '=', rec.consumer_id.id),
('provider_id', '=', rec.provider_id.id),
('create_date', '>=', fields.Datetime.to_string(month_start)),
('status', '=', 'success'),
])
day_usages = month_usages.filtered(
lambda u: u.create_date.date() == today
)
rec.current_month_cost = sum(u.estimated_cost_usd for u in month_usages)
rec.current_day_cost = sum(u.estimated_cost_usd for u in day_usages)
rec.current_day_requests = len(day_usages)
if rec.monthly_budget_usd > 0:
rec.budget_usage_pct = (
rec.current_month_cost / rec.monthly_budget_usd
) * 100
rec.is_budget_exceeded = (
rec.current_month_cost >= rec.monthly_budget_usd
)
else:
rec.budget_usage_pct = 0.0
rec.is_budget_exceeded = False

View File

@@ -0,0 +1,54 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api
class FusionApiConsumer(models.Model):
_name = 'fusion.api.consumer'
_description = 'API Consumer (Fusion Module)'
_order = 'name'
name = fields.Char(required=True, string='Display Name')
technical_name = fields.Char(required=True, index=True)
module_id = fields.Many2one('ir.module.module', string='Odoo Module', readonly=True)
module_state = fields.Selection(
related='module_id.state', string='Module Status', readonly=True,
)
is_active = fields.Boolean(default=True, string='API Access Enabled', tracking=True)
auto_detected = fields.Boolean(default=False, readonly=True)
first_seen_at = fields.Datetime(readonly=True)
access_ids = fields.One2many('fusion.api.access', 'consumer_id', string='Access Rules')
total_month_cost = fields.Float(
compute='_compute_usage_stats', string='Month Cost (USD)',
)
total_month_requests = fields.Integer(
compute='_compute_usage_stats', string='Month Requests',
)
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
_sql_constraints = [
('technical_name_company_uniq', 'unique(technical_name, company_id)',
'Consumer technical name must be unique per company.'),
]
def _compute_usage_stats(self):
today = fields.Date.today()
month_start = today.replace(day=1)
for rec in self:
usages = self.env['fusion.api.usage'].sudo().search([
('consumer_id', '=', rec.id),
('create_date', '>=', fields.Datetime.to_string(month_start)),
('status', '=', 'success'),
])
rec.total_month_cost = sum(u.estimated_cost_usd for u in usages)
rec.total_month_requests = len(usages)
def action_toggle_access(self):
for rec in self:
rec.is_active = not rec.is_active

View File

@@ -0,0 +1,105 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api, _
from odoo.exceptions import UserError
class FusionApiKey(models.Model):
_name = 'fusion.api.key'
_description = 'API Key'
_order = 'provider_id, is_default desc, name'
provider_id = fields.Many2one(
'fusion.api.provider', required=True, ondelete='cascade',
)
provider_type = fields.Selection(
related='provider_id.provider_type', store=True, readonly=True,
)
name = fields.Char(required=True, string='Label')
api_key = fields.Char(string='API Key', groups='fusion_api.group_admin')
client_id = fields.Char(groups='fusion_api.group_admin')
client_secret = fields.Char(groups='fusion_api.group_admin')
access_token = fields.Char(groups='fusion_api.group_admin')
refresh_token = fields.Char(groups='fusion_api.group_admin')
token_expiry = fields.Datetime()
redirect_uri = fields.Char()
environment = fields.Selection([
('production', 'Production'),
('sandbox', 'Sandbox'),
], default='production', required=True)
is_active = fields.Boolean(default=True)
is_default = fields.Boolean(default=False)
last_validated_at = fields.Datetime(readonly=True)
validation_status = fields.Selection([
('unknown', 'Not Validated'),
('valid', 'Valid'),
('invalid', 'Invalid'),
], default='unknown', readonly=True)
notes = fields.Text()
masked_key = fields.Char(compute='_compute_masked_key')
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
@api.depends('api_key')
def _compute_masked_key(self):
for rec in self:
key = rec.api_key
if key and len(key) > 8:
rec.masked_key = key[:4] + '*' * (len(key) - 8) + key[-4:]
elif key:
rec.masked_key = '****'
else:
rec.masked_key = ''
@api.constrains('is_default', 'provider_id', 'environment')
def _check_single_default(self):
for rec in self:
if rec.is_default:
duplicates = self.search([
('provider_id', '=', rec.provider_id.id),
('environment', '=', rec.environment),
('is_default', '=', True),
('id', '!=', rec.id),
])
if duplicates:
raise UserError(_(
"Only one default key per provider per environment. "
"Key '%(other)s' is already the default.",
other=duplicates[0].name,
))
def action_validate(self):
self.ensure_one()
try:
self.env['fusion.api.service']._validate_key(self)
self.write({
'last_validated_at': fields.Datetime.now(),
'validation_status': 'valid',
})
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Validation Successful'),
'message': _('API key is valid and working.'),
'type': 'success',
'sticky': False,
},
}
except UserError as e:
self.write({'validation_status': 'invalid'})
raise
def write(self, vals):
if 'api_key' in vals and not vals['api_key']:
for rec in self:
if rec.api_key:
vals.pop('api_key')
break
return super().write(vals)

View File

@@ -0,0 +1,173 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api
class FusionApiProvider(models.Model):
_name = 'fusion.api.provider'
_description = 'API Service Provider'
_order = 'sequence, name'
name = fields.Char(required=True)
provider_type = fields.Selection([
('openai', 'OpenAI'),
('anthropic', 'Anthropic'),
('google_maps', 'Google Maps'),
('google_oauth', 'Google OAuth'),
('microsoft_oauth', 'Microsoft OAuth'),
('twilio', 'Twilio'),
('custom', 'Custom'),
], required=True, default='custom')
status = fields.Selection([
('active', 'Active'),
('inactive', 'Inactive'),
('error', 'Error'),
], default='inactive', required=True, tracking=True)
description = fields.Text()
website_url = fields.Char(string='API Dashboard URL')
sequence = fields.Integer(default=10)
color = fields.Integer()
icon_class = fields.Char(
string='Icon CSS Class',
help='Font Awesome class for display, e.g. fa-brain',
)
key_ids = fields.One2many('fusion.api.key', 'provider_id', string='API Keys')
access_ids = fields.One2many('fusion.api.access', 'provider_id', string='Access Rules')
active_key_count = fields.Integer(compute='_compute_key_stats', string='Active Keys')
total_month_cost = fields.Float(compute='_compute_usage_stats', string='Month Cost (USD)')
total_month_requests = fields.Integer(compute='_compute_usage_stats', string='Month Requests')
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
@api.depends('key_ids', 'key_ids.is_active')
def _compute_key_stats(self):
for rec in self:
rec.active_key_count = len(rec.key_ids.filtered('is_active'))
def _compute_usage_stats(self):
today = fields.Date.today()
month_start = today.replace(day=1)
for rec in self:
usages = self.env['fusion.api.usage'].sudo().search([
('provider_id', '=', rec.id),
('create_date', '>=', fields.Datetime.to_string(month_start)),
('status', '=', 'success'),
])
rec.total_month_cost = sum(u.estimated_cost_usd for u in usages)
rec.total_month_requests = len(usages)
def action_activate(self):
self.ensure_one()
if not self.key_ids.filtered('is_active'):
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'No Active Keys',
'message': 'Add at least one active API key before activating this provider.',
'type': 'warning',
'sticky': False,
},
}
self.status = 'active'
def action_deactivate(self):
self.ensure_one()
self.status = 'inactive'
@api.model
def get_dashboard_data(self):
today = fields.Date.today()
month_start = today.replace(day=1)
providers = self.search([])
consumers = self.env['fusion.api.consumer'].search([])
month_usages = self.env['fusion.api.usage'].sudo().search([
('create_date', '>=', fields.Datetime.to_string(month_start)),
('status', '=', 'success'),
])
today_usages = month_usages.filtered(
lambda u: u.create_date.date() == today
)
month_cost = sum(u.estimated_cost_usd for u in month_usages)
month_requests = len(month_usages)
today_requests = len(today_usages)
consumer_stats = {}
for usage in month_usages:
cid = usage.consumer_id.id
if cid not in consumer_stats:
consumer_stats[cid] = {
'name': usage.consumer_id.name,
'cost': 0.0,
'requests': 0,
}
consumer_stats[cid]['cost'] += usage.estimated_cost_usd
consumer_stats[cid]['requests'] += 1
top_consumers = sorted(
consumer_stats.values(),
key=lambda x: x['cost'],
reverse=True,
)[:5]
provider_stats = []
for prov in providers.filtered(lambda p: p.status == 'active'):
prov_usages = month_usages.filtered(lambda u: u.provider_id.id == prov.id)
provider_stats.append({
'id': prov.id,
'name': prov.name,
'type': prov.provider_type,
'cost': sum(u.estimated_cost_usd for u in prov_usages),
'requests': len(prov_usages),
'keys': prov.active_key_count,
})
recent = self.env['fusion.api.usage'].sudo().search(
[], limit=10, order='create_date desc',
)
recent_list = [{
'consumer': r.consumer_id.name or '',
'provider': r.provider_id.name or '',
'feature': r.feature or '',
'cost': round(r.estimated_cost_usd, 6),
'status': r.status,
'tokens': r.total_tokens,
'time': fields.Datetime.to_string(r.create_date),
} for r in recent]
approaching_limits = []
access_rules = self.env['fusion.api.access'].sudo().search([
('monthly_budget_usd', '>', 0),
('is_enabled', '=', True),
])
for rule in access_rules:
pct = rule.budget_usage_pct
if pct >= 80:
approaching_limits.append({
'consumer': rule.consumer_id.name,
'provider': rule.provider_id.name,
'pct': round(pct, 1),
'budget': rule.monthly_budget_usd,
'spent': round(rule.current_month_cost, 2),
})
return {
'total_providers': len(providers),
'active_providers': len(providers.filtered(lambda p: p.status == 'active')),
'total_consumers': len(consumers),
'active_consumers': len(consumers.filtered('is_active')),
'month_cost': round(month_cost, 2),
'month_requests': month_requests,
'today_requests': today_requests,
'top_consumers': top_consumers,
'provider_stats': provider_stats,
'recent_usage': recent_list,
'approaching_limits': approaching_limits,
}

View File

@@ -0,0 +1,499 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import time
import logging
from datetime import timedelta
from odoo import models, fields, api, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
try:
from openai import OpenAI
except ImportError:
OpenAI = None
_logger.info("openai package not installed. OpenAI calls via Fusion API unavailable.")
try:
import anthropic as anthropic_sdk
except ImportError:
anthropic_sdk = None
_logger.info("anthropic package not installed. Anthropic calls via Fusion API unavailable.")
class FusionApiService(models.AbstractModel):
_name = 'fusion.api.service'
_description = 'Fusion API Service'
COST_PER_1K = {
'openai': {
'gpt-4o': {'input': 0.0025, 'output': 0.01},
'gpt-4o-mini': {'input': 0.00015, 'output': 0.0006},
'gpt-4-turbo': {'input': 0.01, 'output': 0.03},
'gpt-3.5-turbo': {'input': 0.0005, 'output': 0.0015},
'o1': {'input': 0.015, 'output': 0.06},
'o1-mini': {'input': 0.003, 'output': 0.012},
},
'anthropic': {
'claude-sonnet-4-20250514': {'input': 0.003, 'output': 0.015},
'claude-3-5-haiku-20241022': {'input': 0.001, 'output': 0.005},
'claude-3-opus-20240229': {'input': 0.015, 'output': 0.075},
},
}
# ------------------------------------------------------------------
# Provider / key resolution
# ------------------------------------------------------------------
def _get_provider(self, provider_type):
provider = self.env['fusion.api.provider'].sudo().search([
('provider_type', '=', provider_type),
('status', '=', 'active'),
('company_id', '=', self.env.company.id),
], limit=1)
if not provider:
provider = self.env['fusion.api.provider'].sudo().search([
('provider_type', '=', provider_type),
('status', '=', 'active'),
], limit=1)
if not provider:
raise UserError(_(
"No active %(type)s provider configured. "
"Go to Fusion API > Providers to set one up.",
type=provider_type,
))
return provider
def _get_default_key(self, provider):
domain = [
('provider_id', '=', provider.id),
('is_active', '=', True),
('environment', '=', 'production'),
]
key = self.env['fusion.api.key'].sudo().search(
domain + [('is_default', '=', True)], limit=1,
)
if not key:
key = self.env['fusion.api.key'].sudo().search(domain, limit=1)
if not key:
raise UserError(_(
"No active API key for provider '%(provider)s'. "
"Go to Fusion API > Providers to add one.",
provider=provider.name,
))
return key
# ------------------------------------------------------------------
# Consumer auto-registration
# ------------------------------------------------------------------
def _auto_register_consumer(self, consumer_name):
consumer = self.env['fusion.api.consumer'].sudo().search([
('technical_name', '=', consumer_name),
], limit=1)
if consumer:
return consumer
ICP = self.env['ir.config_parameter'].sudo()
if not ICP.get_param('fusion_api.auto_detect_consumers', 'True') == 'True':
raise UserError(_(
"Consumer '%(name)s' is not registered and auto-detection is disabled.",
name=consumer_name,
))
module = self.env['ir.module.module'].sudo().search([
('name', '=', consumer_name),
], limit=1)
display = consumer_name.replace('fusion_', 'Fusion ').replace('_', ' ').title()
consumer = self.env['fusion.api.consumer'].sudo().create({
'name': display,
'technical_name': consumer_name,
'module_id': module.id if module else False,
'is_active': True,
'auto_detected': True,
'first_seen_at': fields.Datetime.now(),
})
_logger.info("Auto-registered API consumer: %s", consumer_name)
return consumer
# ------------------------------------------------------------------
# Access control checks
# ------------------------------------------------------------------
def _check_access(self, consumer, provider, user=None):
if not consumer.is_active:
raise UserError(_(
"API access is disabled for '%(module)s'. "
"An administrator can re-enable it in Fusion API > Consumers.",
module=consumer.name,
))
access = self.env['fusion.api.access'].sudo().search([
('consumer_id', '=', consumer.id),
('provider_id', '=', provider.id),
], limit=1)
if access and not access.is_enabled:
raise UserError(_(
"'%(module)s' access to '%(provider)s' is disabled.",
module=consumer.name, provider=provider.name,
))
if access:
self._check_budget(access, consumer, provider)
self._check_rate_limit(access, consumer, provider)
if user:
self._check_user_limit(user, provider)
return access
def _check_budget(self, access, consumer, provider):
if access.monthly_budget_usd > 0 and access.is_budget_exceeded:
raise UserError(_(
"Monthly budget of $%(budget).2f exceeded for "
"'%(module)s' on '%(provider)s' ($%(spent).2f spent).",
budget=access.monthly_budget_usd,
module=consumer.name,
provider=provider.name,
spent=access.current_month_cost,
))
if access.daily_budget_usd > 0:
if access.current_day_cost >= access.daily_budget_usd:
raise UserError(_(
"Daily budget of $%(budget).2f exceeded for "
"'%(module)s' on '%(provider)s'.",
budget=access.daily_budget_usd,
module=consumer.name,
provider=provider.name,
))
def _check_rate_limit(self, access, consumer, provider):
if access.max_rpm > 0:
one_min_ago = fields.Datetime.now() - timedelta(minutes=1)
recent_count = self.env['fusion.api.usage'].sudo().search_count([
('consumer_id', '=', consumer.id),
('provider_id', '=', provider.id),
('create_date', '>=', one_min_ago),
])
if recent_count >= access.max_rpm:
raise UserError(_(
"Rate limit of %(limit)d requests/minute exceeded for "
"'%(module)s' on '%(provider)s'.",
limit=access.max_rpm,
module=consumer.name,
provider=provider.name,
))
if access.max_rpd > 0:
today = fields.Date.today()
day_count = self.env['fusion.api.usage'].sudo().search_count([
('consumer_id', '=', consumer.id),
('provider_id', '=', provider.id),
('create_date', '>=', fields.Datetime.to_string(today)),
])
if day_count >= access.max_rpd:
raise UserError(_(
"Daily limit of %(limit)d requests exceeded for "
"'%(module)s' on '%(provider)s'.",
limit=access.max_rpd,
module=consumer.name,
provider=provider.name,
))
def _check_user_limit(self, user, provider):
limit = self.env['fusion.api.user.limit'].sudo().search([
('user_id', '=', user.id),
('provider_id', '=', provider.id),
], limit=1)
if not limit:
return
if limit.is_blocked:
raise UserError(_(
"API access to '%(provider)s' is blocked for user '%(user)s'.",
provider=provider.name, user=user.name,
))
if limit.monthly_budget_usd > 0:
if limit.current_month_cost >= limit.monthly_budget_usd:
raise UserError(_(
"User monthly budget of $%(budget).2f exceeded for "
"'%(provider)s' ($%(spent).2f spent).",
budget=limit.monthly_budget_usd,
provider=provider.name,
spent=limit.current_month_cost,
))
if limit.max_rpd > 0:
if limit.current_day_requests >= limit.max_rpd:
raise UserError(_(
"User daily limit of %(limit)d requests exceeded for "
"'%(provider)s'.",
limit=limit.max_rpd,
provider=provider.name,
))
# ------------------------------------------------------------------
# Usage logging
# ------------------------------------------------------------------
def _log_usage(self, consumer, provider, user, feature, model_name,
tokens_in, tokens_out, cost, response_time_ms,
status, error_message=None):
self.env['fusion.api.usage'].sudo().create({
'consumer_id': consumer.id,
'provider_id': provider.id,
'user_id': user.id if user else False,
'feature': feature,
'model_name': model_name,
'tokens_in': tokens_in,
'tokens_out': tokens_out,
'estimated_cost_usd': cost,
'response_time_ms': response_time_ms,
'status': status,
'error_message': error_message,
})
def _estimate_cost(self, provider_type, model_name, tokens_in, tokens_out):
provider_costs = self.COST_PER_1K.get(provider_type, {})
model_costs = provider_costs.get(
model_name, {'input': 0.001, 'output': 0.002},
)
return (
(tokens_in / 1000) * model_costs['input']
+ (tokens_out / 1000) * model_costs['output']
)
# ------------------------------------------------------------------
# Public API: OpenAI
# ------------------------------------------------------------------
def call_openai(self, consumer, feature, messages,
model=None, max_tokens=1024, user=None, **kwargs):
if OpenAI is None:
raise UserError(_(
"The 'openai' Python package is not installed. "
"Run: pip install openai"
))
provider = self._get_provider('openai')
consumer_rec = self._auto_register_consumer(consumer)
user_rec = user or self.env.user
self._check_access(consumer_rec, provider, user_rec)
key = self._get_default_key(provider)
model_name = model or 'gpt-4o-mini'
start = time.time()
try:
client = OpenAI(api_key=key.api_key)
response = client.chat.completions.create(
model=model_name,
messages=messages,
max_tokens=max_tokens,
**kwargs,
)
elapsed_ms = int((time.time() - start) * 1000)
usage = response.usage
cost = self._estimate_cost(
'openai', model_name,
usage.prompt_tokens, usage.completion_tokens,
)
self._log_usage(
consumer_rec, provider, user_rec, feature, model_name,
usage.prompt_tokens, usage.completion_tokens,
cost, elapsed_ms, 'success',
)
return response.choices[0].message.content
except UserError:
raise
except Exception as e:
elapsed_ms = int((time.time() - start) * 1000)
self._log_usage(
consumer_rec, provider, user_rec, feature, model_name,
0, 0, 0, elapsed_ms, 'error', str(e),
)
_logger.error("OpenAI API error for %s: %s", consumer, e)
raise UserError(_("OpenAI API error: %s", str(e)))
# ------------------------------------------------------------------
# Public API: Anthropic
# ------------------------------------------------------------------
def call_anthropic(self, consumer, feature, messages,
model=None, max_tokens=1024, system=None,
user=None, **kwargs):
if anthropic_sdk is None:
raise UserError(_(
"The 'anthropic' Python package is not installed. "
"Run: pip install anthropic"
))
provider = self._get_provider('anthropic')
consumer_rec = self._auto_register_consumer(consumer)
user_rec = user or self.env.user
self._check_access(consumer_rec, provider, user_rec)
key = self._get_default_key(provider)
model_name = model or 'claude-sonnet-4-20250514'
call_kwargs = {
'model': model_name,
'messages': messages,
'max_tokens': max_tokens,
}
if system:
call_kwargs['system'] = system
call_kwargs.update(kwargs)
start = time.time()
try:
client = anthropic_sdk.Anthropic(api_key=key.api_key)
response = client.messages.create(**call_kwargs)
elapsed_ms = int((time.time() - start) * 1000)
cost = self._estimate_cost(
'anthropic', model_name,
response.usage.input_tokens, response.usage.output_tokens,
)
self._log_usage(
consumer_rec, provider, user_rec, feature, model_name,
response.usage.input_tokens, response.usage.output_tokens,
cost, elapsed_ms, 'success',
)
text_blocks = [
b.text for b in response.content if b.type == 'text'
]
return '\n'.join(text_blocks)
except UserError:
raise
except Exception as e:
elapsed_ms = int((time.time() - start) * 1000)
self._log_usage(
consumer_rec, provider, user_rec, feature, model_name,
0, 0, 0, elapsed_ms, 'error', str(e),
)
_logger.error("Anthropic API error for %s: %s", consumer, e)
raise UserError(_("Anthropic API error: %s", str(e)))
# ------------------------------------------------------------------
# Public API: Raw key access (Google Maps, Twilio, etc.)
# ------------------------------------------------------------------
def get_api_key(self, provider_type, consumer,
feature=None, user=None):
provider = self._get_provider(provider_type)
consumer_rec = self._auto_register_consumer(consumer)
user_rec = user or self.env.user
self._check_access(consumer_rec, provider, user_rec)
key = self._get_default_key(provider)
self._log_usage(
consumer_rec, provider, user_rec,
feature or 'key_access', '', 0, 0, 0, 0, 'success',
)
return key.api_key
def get_oauth_credentials(self, provider_type, consumer,
feature=None, user=None):
provider = self._get_provider(provider_type)
consumer_rec = self._auto_register_consumer(consumer)
user_rec = user or self.env.user
self._check_access(consumer_rec, provider, user_rec)
key = self._get_default_key(provider)
self._log_usage(
consumer_rec, provider, user_rec,
feature or 'oauth_access', '', 0, 0, 0, 0, 'success',
)
return {
'client_id': key.client_id,
'client_secret': key.client_secret,
'access_token': key.access_token,
'refresh_token': key.refresh_token,
'token_expiry': key.token_expiry,
'redirect_uri': key.redirect_uri,
}
# ------------------------------------------------------------------
# Key validation
# ------------------------------------------------------------------
def _validate_key(self, key_record):
ptype = key_record.provider_id.provider_type
if ptype == 'openai':
if OpenAI is None:
raise UserError(_("openai package not installed."))
try:
from openai import AuthenticationError as OpenAIAuthError
try:
client = OpenAI(api_key=key_record.api_key)
client.models.list()
except OpenAIAuthError as e:
raise UserError(_(
"OpenAI key is invalid (authentication failed): %s", str(e),
))
except Exception as e:
err_str = str(e).lower()
if 'billing' in err_str or 'quota' in err_str or 'insufficient' in err_str:
return
raise UserError(_(
"OpenAI key validation failed: %s", str(e),
))
except UserError:
raise
elif ptype == 'anthropic':
if anthropic_sdk is None:
raise UserError(_("anthropic package not installed."))
try:
client = anthropic_sdk.Anthropic(api_key=key_record.api_key)
client.messages.create(
model='claude-3-5-haiku-20241022',
max_tokens=1,
messages=[{'role': 'user', 'content': 'hi'}],
)
except anthropic_sdk.AuthenticationError as e:
raise UserError(_(
"Anthropic key is invalid (authentication failed): %s", str(e),
))
except anthropic_sdk.BadRequestError as e:
if 'credit balance' in str(e) or 'billing' in str(e).lower():
return
raise UserError(_(
"Anthropic key validation failed: %s", str(e),
))
except anthropic_sdk.PermissionDeniedError:
raise UserError(_(
"Anthropic key lacks required permissions.",
))
except Exception as e:
raise UserError(_(
"Anthropic key validation failed: %s", str(e),
))
elif ptype in ('google_maps', 'twilio'):
if not key_record.api_key:
raise UserError(_("API key is empty."))
elif ptype in ('google_oauth', 'microsoft_oauth'):
if not key_record.client_id or not key_record.client_secret:
raise UserError(_("Client ID and Client Secret are required."))
else:
if not key_record.api_key:
raise UserError(_("API key is empty."))

View File

@@ -0,0 +1,53 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api
class FusionApiUsage(models.Model):
_name = 'fusion.api.usage'
_description = 'API Usage Log'
_order = 'create_date desc'
_rec_name = 'display_name'
consumer_id = fields.Many2one(
'fusion.api.consumer', required=True, index=True, ondelete='cascade',
)
provider_id = fields.Many2one(
'fusion.api.provider', required=True, index=True, ondelete='cascade',
)
user_id = fields.Many2one('res.users', index=True, ondelete='set null')
feature = fields.Char(index=True, help='Feature label, e.g. invoice_extraction')
model_name = fields.Char(string='AI Model', help='e.g. gpt-4o, claude-3-5-sonnet')
tokens_in = fields.Integer(string='Input Tokens')
tokens_out = fields.Integer(string='Output Tokens')
total_tokens = fields.Integer(
compute='_compute_total_tokens', store=True, string='Total Tokens',
)
estimated_cost_usd = fields.Float(digits=(10, 6), string='Cost (USD)')
response_time_ms = fields.Integer(string='Response Time (ms)')
status = fields.Selection([
('success', 'Success'),
('error', 'Error'),
('rate_limited', 'Rate Limited'),
('budget_exceeded', 'Budget Exceeded'),
], default='success', required=True, index=True)
error_message = fields.Text()
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
display_name = fields.Char(compute='_compute_display_name')
@api.depends('tokens_in', 'tokens_out')
def _compute_total_tokens(self):
for rec in self:
rec.total_tokens = rec.tokens_in + rec.tokens_out
def _compute_display_name(self):
for rec in self:
consumer = rec.consumer_id.name or 'Unknown'
provider = rec.provider_id.name or 'Unknown'
rec.display_name = f"{consumer} - {provider} ({rec.status})"

View File

@@ -0,0 +1,141 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
import logging
from datetime import timedelta
from odoo import models, fields, api
_logger = logging.getLogger(__name__)
class FusionApiUsageDaily(models.Model):
_name = 'fusion.api.usage.daily'
_description = 'API Usage Daily Summary'
_order = 'date desc'
_rec_name = 'display_name'
date = fields.Date(required=True, index=True)
consumer_id = fields.Many2one(
'fusion.api.consumer', required=True, index=True, ondelete='cascade',
)
provider_id = fields.Many2one(
'fusion.api.provider', required=True, index=True, ondelete='cascade',
)
user_id = fields.Many2one('res.users', index=True, ondelete='set null')
feature = fields.Char(index=True)
model_name = fields.Char(string='AI Model')
total_tokens_in = fields.Integer()
total_tokens_out = fields.Integer()
total_tokens = fields.Integer(
compute='_compute_total_tokens', store=True,
)
total_cost_usd = fields.Float(digits=(10, 4))
request_count = fields.Integer()
error_count = fields.Integer()
avg_response_time_ms = fields.Integer()
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
display_name = fields.Char(compute='_compute_display_name')
@api.depends('total_tokens_in', 'total_tokens_out')
def _compute_total_tokens(self):
for rec in self:
rec.total_tokens = rec.total_tokens_in + rec.total_tokens_out
def _compute_display_name(self):
for rec in self:
rec.display_name = (
f"{rec.date} - {rec.consumer_id.name or ''} / "
f"{rec.provider_id.name or ''}"
)
@api.model
def _cron_aggregate_daily(self):
yesterday = fields.Date.today() - timedelta(days=1)
_logger.info("Aggregating API usage for %s", yesterday)
usages = self.env['fusion.api.usage'].sudo().search([
('create_date', '>=', fields.Datetime.to_string(yesterday)),
('create_date', '<', fields.Datetime.to_string(
yesterday + timedelta(days=1)
)),
])
if not usages:
_logger.info("No usage records to aggregate for %s", yesterday)
return
groups = {}
for usage in usages:
key = (
usage.consumer_id.id,
usage.provider_id.id,
usage.user_id.id or 0,
usage.feature or '',
usage.model_name or '',
)
if key not in groups:
groups[key] = {
'date': yesterday,
'consumer_id': usage.consumer_id.id,
'provider_id': usage.provider_id.id,
'user_id': usage.user_id.id or False,
'feature': usage.feature or False,
'model_name': usage.model_name or False,
'total_tokens_in': 0,
'total_tokens_out': 0,
'total_cost_usd': 0.0,
'request_count': 0,
'error_count': 0,
'total_response_time': 0,
}
grp = groups[key]
grp['total_tokens_in'] += usage.tokens_in
grp['total_tokens_out'] += usage.tokens_out
grp['total_cost_usd'] += usage.estimated_cost_usd
grp['request_count'] += 1
grp['total_response_time'] += usage.response_time_ms
if usage.status == 'error':
grp['error_count'] += 1
for grp in groups.values():
total_rt = grp.pop('total_response_time')
count = grp['request_count']
grp['avg_response_time_ms'] = total_rt // count if count else 0
existing = self.sudo().search([
('date', '=', grp['date']),
('consumer_id', '=', grp['consumer_id']),
('provider_id', '=', grp['provider_id']),
('user_id', '=', grp['user_id']),
('feature', '=', grp['feature']),
('model_name', '=', grp['model_name']),
], limit=1)
if existing:
existing.write(grp)
else:
self.sudo().create(grp)
_logger.info(
"Aggregated %d usage records into %d daily summaries",
len(usages), len(groups),
)
@api.model
def _cron_cleanup_old_logs(self):
ICP = self.env['ir.config_parameter'].sudo()
retention_days = int(ICP.get_param('fusion_api.log_retention_days', '90'))
if retention_days <= 0:
return
cutoff = fields.Date.today() - timedelta(days=retention_days)
old_logs = self.env['fusion.api.usage'].sudo().search([
('create_date', '<', fields.Datetime.to_string(cutoff)),
])
count = len(old_logs)
if count:
old_logs.unlink()
_logger.info("Cleaned up %d usage logs older than %s", count, cutoff)

View File

@@ -0,0 +1,65 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api
class FusionApiUserLimit(models.Model):
_name = 'fusion.api.user.limit'
_description = 'API User Limit'
_order = 'user_id, provider_id'
_rec_name = 'display_name'
user_id = fields.Many2one('res.users', required=True, ondelete='cascade')
provider_id = fields.Many2one(
'fusion.api.provider', required=True, ondelete='cascade',
)
monthly_budget_usd = fields.Float(
string='Monthly Budget (USD)',
help='Maximum monthly spend for this user. 0 = unlimited.',
)
max_rpd = fields.Integer(
string='Max Requests/Day',
help='Maximum requests per day. 0 = unlimited.',
)
is_blocked = fields.Boolean(string='Blocked', tracking=True)
current_month_cost = fields.Float(
compute='_compute_current_usage', string='Month Spend',
)
current_day_requests = fields.Integer(
compute='_compute_current_usage', string='Today Requests',
)
display_name = fields.Char(compute='_compute_display_name', store=True)
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
_sql_constraints = [
('user_provider_uniq', 'unique(user_id, provider_id, company_id)',
'Only one limit per user-provider pair per company.'),
]
@api.depends('user_id.name', 'provider_id.name')
def _compute_display_name(self):
for rec in self:
user = rec.user_id.name or ''
provider = rec.provider_id.name or ''
rec.display_name = f"{user} - {provider}"
def _compute_current_usage(self):
today = fields.Date.today()
month_start = today.replace(day=1)
for rec in self:
month_usages = self.env['fusion.api.usage'].sudo().search([
('user_id', '=', rec.user_id.id),
('provider_id', '=', rec.provider_id.id),
('create_date', '>=', fields.Datetime.to_string(month_start)),
('status', '=', 'success'),
])
day_usages = month_usages.filtered(
lambda u: u.create_date.date() == today
)
rec.current_month_cost = sum(u.estimated_cost_usd for u in month_usages)
rec.current_day_requests = len(day_usages)

View File

@@ -0,0 +1,36 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
fusion_api_global_budget = fields.Float(
string='Global Monthly Budget (USD)',
config_parameter='fusion_api.global_monthly_budget_usd',
default=0.0,
help="Global monthly budget across all providers. 0 = unlimited.",
)
fusion_api_default_environment = fields.Selection([
('production', 'Production'),
('sandbox', 'Sandbox'),
], string='Default Environment',
config_parameter='fusion_api.default_environment',
default='production',
)
fusion_api_log_retention_days = fields.Integer(
string='Usage Log Retention (days)',
config_parameter='fusion_api.log_retention_days',
default=90,
help="Keep detailed usage logs for this many days. "
"Daily summaries are kept indefinitely. 0 = keep forever.",
)
fusion_api_auto_detect = fields.Boolean(
string='Auto-Detect Consumers',
config_parameter='fusion_api.auto_detect_consumers',
default=True,
help="Automatically register new Fusion modules when they first call the API.",
)

View File

@@ -0,0 +1,21 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_provider_user,fusion.api.provider.user,model_fusion_api_provider,fusion_api.group_user,1,0,0,0
access_provider_manager,fusion.api.provider.manager,model_fusion_api_provider,fusion_api.group_manager,1,1,1,0
access_provider_admin,fusion.api.provider.admin,model_fusion_api_provider,fusion_api.group_admin,1,1,1,1
access_key_manager,fusion.api.key.manager,model_fusion_api_key,fusion_api.group_manager,1,0,0,0
access_key_admin,fusion.api.key.admin,model_fusion_api_key,fusion_api.group_admin,1,1,1,1
access_consumer_user,fusion.api.consumer.user,model_fusion_api_consumer,fusion_api.group_user,1,0,0,0
access_consumer_manager,fusion.api.consumer.manager,model_fusion_api_consumer,fusion_api.group_manager,1,1,1,1
access_consumer_admin,fusion.api.consumer.admin,model_fusion_api_consumer,fusion_api.group_admin,1,1,1,1
access_access_user,fusion.api.access.user,model_fusion_api_access,fusion_api.group_user,1,0,0,0
access_access_manager,fusion.api.access.manager,model_fusion_api_access,fusion_api.group_manager,1,1,1,1
access_access_admin,fusion.api.access.admin,model_fusion_api_access,fusion_api.group_admin,1,1,1,1
access_user_limit_user,fusion.api.user.limit.user,model_fusion_api_user_limit,fusion_api.group_user,1,0,0,0
access_user_limit_manager,fusion.api.user.limit.manager,model_fusion_api_user_limit,fusion_api.group_manager,1,1,1,1
access_user_limit_admin,fusion.api.user.limit.admin,model_fusion_api_user_limit,fusion_api.group_admin,1,1,1,1
access_usage_user,fusion.api.usage.user,model_fusion_api_usage,fusion_api.group_user,1,0,0,0
access_usage_manager,fusion.api.usage.manager,model_fusion_api_usage,fusion_api.group_manager,1,0,0,0
access_usage_admin,fusion.api.usage.admin,model_fusion_api_usage,fusion_api.group_admin,1,1,1,1
access_usage_daily_user,fusion.api.usage.daily.user,model_fusion_api_usage_daily,fusion_api.group_user,1,0,0,0
access_usage_daily_manager,fusion.api.usage.daily.manager,model_fusion_api_usage_daily,fusion_api.group_manager,1,0,0,0
access_usage_daily_admin,fusion.api.usage.daily.admin,model_fusion_api_usage_daily,fusion_api.group_admin,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_provider_user fusion.api.provider.user model_fusion_api_provider fusion_api.group_user 1 0 0 0
3 access_provider_manager fusion.api.provider.manager model_fusion_api_provider fusion_api.group_manager 1 1 1 0
4 access_provider_admin fusion.api.provider.admin model_fusion_api_provider fusion_api.group_admin 1 1 1 1
5 access_key_manager fusion.api.key.manager model_fusion_api_key fusion_api.group_manager 1 0 0 0
6 access_key_admin fusion.api.key.admin model_fusion_api_key fusion_api.group_admin 1 1 1 1
7 access_consumer_user fusion.api.consumer.user model_fusion_api_consumer fusion_api.group_user 1 0 0 0
8 access_consumer_manager fusion.api.consumer.manager model_fusion_api_consumer fusion_api.group_manager 1 1 1 1
9 access_consumer_admin fusion.api.consumer.admin model_fusion_api_consumer fusion_api.group_admin 1 1 1 1
10 access_access_user fusion.api.access.user model_fusion_api_access fusion_api.group_user 1 0 0 0
11 access_access_manager fusion.api.access.manager model_fusion_api_access fusion_api.group_manager 1 1 1 1
12 access_access_admin fusion.api.access.admin model_fusion_api_access fusion_api.group_admin 1 1 1 1
13 access_user_limit_user fusion.api.user.limit.user model_fusion_api_user_limit fusion_api.group_user 1 0 0 0
14 access_user_limit_manager fusion.api.user.limit.manager model_fusion_api_user_limit fusion_api.group_manager 1 1 1 1
15 access_user_limit_admin fusion.api.user.limit.admin model_fusion_api_user_limit fusion_api.group_admin 1 1 1 1
16 access_usage_user fusion.api.usage.user model_fusion_api_usage fusion_api.group_user 1 0 0 0
17 access_usage_manager fusion.api.usage.manager model_fusion_api_usage fusion_api.group_manager 1 0 0 0
18 access_usage_admin fusion.api.usage.admin model_fusion_api_usage fusion_api.group_admin 1 1 1 1
19 access_usage_daily_user fusion.api.usage.daily.user model_fusion_api_usage_daily fusion_api.group_user 1 0 0 0
20 access_usage_daily_manager fusion.api.usage.daily.manager model_fusion_api_usage_daily fusion_api.group_manager 1 0 0 0
21 access_usage_daily_admin fusion.api.usage.daily.admin model_fusion_api_usage_daily fusion_api.group_admin 1 1 1 1

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="module_category_fusion_api" model="ir.module.category">
<field name="name">Fusion API</field>
<field name="sequence">50</field>
</record>
<record id="res_groups_privilege_fusion_api" model="res.groups.privilege">
<field name="name">Fusion API</field>
<field name="sequence">50</field>
<field name="category_id" ref="module_category_fusion_api"/>
</record>
<record id="group_user" model="res.groups">
<field name="name">User</field>
<field name="sequence">10</field>
<field name="implied_ids" eval="[(4, ref('base.group_user'))]"/>
<field name="privilege_id" ref="res_groups_privilege_fusion_api"/>
</record>
<record id="group_manager" model="res.groups">
<field name="name">Manager</field>
<field name="sequence">20</field>
<field name="implied_ids" eval="[(4, ref('fusion_api.group_user'))]"/>
<field name="privilege_id" ref="res_groups_privilege_fusion_api"/>
</record>
<record id="group_admin" model="res.groups">
<field name="name">Administrator</field>
<field name="sequence">30</field>
<field name="implied_ids" eval="[(4, ref('fusion_api.group_manager'))]"/>
<field name="privilege_id" ref="res_groups_privilege_fusion_api"/>
<field name="user_ids" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
</record>
</odoo>

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1,102 @@
/** @odoo-module */
import { Component, onWillStart, useState } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
export class FusionApiDashboard extends Component {
static template = "fusion_api.Dashboard";
setup() {
this.orm = useService("orm");
this.actionService = useService("action");
this.state = useState({
totalProviders: 0,
activeProviders: 0,
totalConsumers: 0,
activeConsumers: 0,
monthCost: 0,
monthRequests: 0,
todayRequests: 0,
topConsumers: [],
providerStats: [],
recentUsage: [],
approachingLimits: [],
loaded: false,
});
onWillStart(() => this.loadData());
}
async loadData() {
const data = await this.orm.call(
"fusion.api.provider",
"get_dashboard_data",
[],
);
Object.assign(this.state, {
totalProviders: data.total_providers,
activeProviders: data.active_providers,
totalConsumers: data.total_consumers,
activeConsumers: data.active_consumers,
monthCost: data.month_cost,
monthRequests: data.month_requests,
todayRequests: data.today_requests,
topConsumers: data.top_consumers || [],
providerStats: data.provider_stats || [],
recentUsage: data.recent_usage || [],
approachingLimits: data.approaching_limits || [],
loaded: true,
});
}
async refresh() {
this.state.loaded = false;
await this.loadData();
}
openProviders() {
this.actionService.doAction("fusion_api.action_api_provider");
}
openConsumers() {
this.actionService.doAction("fusion_api.action_api_consumer");
}
openUsageLog() {
this.actionService.doAction("fusion_api.action_api_usage");
}
openAccessRules() {
this.actionService.doAction("fusion_api.action_api_access");
}
formatCost(value) {
return (value || 0).toFixed(2);
}
formatCostDetailed(value) {
return (value || 0).toFixed(6);
}
getStatusClass(status) {
const classes = {
success: "text-bg-success",
error: "text-bg-danger",
rate_limited: "text-bg-warning",
budget_exceeded: "text-bg-warning",
};
return classes[status] || "text-bg-secondary";
}
formatTime(isoString) {
if (!isoString) return "";
const d = new Date(isoString);
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
}
registry
.category("actions")
.add("fusion_api_dashboard", FusionApiDashboard);

View File

@@ -0,0 +1,76 @@
.o_fusion_api_dashboard {
background-color: var(--o-view-background-color);
min-height: 100%;
.o_fusion_api_header {
h2 {
font-weight: 600;
}
}
.o_fusion_stat_card {
transition: transform 0.15s ease, box-shadow 0.15s ease;
border-radius: 0.5rem;
&:hover {
transform: translateY(-2px);
}
.card-body h2 {
font-weight: 700;
font-size: 1.75rem;
}
}
.o_fusion_icon_circle {
width: 2.75rem;
height: 2.75rem;
min-width: 2.75rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
opacity: 0.85;
}
.card {
border-radius: 0.5rem;
.card-header {
padding: 1rem 1.25rem 0.5rem;
h5 {
font-weight: 600;
font-size: 0.95rem;
}
}
.table {
th {
font-weight: 600;
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.03em;
opacity: 0.65;
border-top: none;
}
td {
vertical-align: middle;
font-size: 0.9rem;
}
}
}
.alert-warning {
border-radius: 0.5rem;
border-left-width: 4px;
}
.badge {
font-size: 0.75rem;
font-weight: 500;
padding: 0.35em 0.6em;
}
}

View File

@@ -0,0 +1,228 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_api.Dashboard">
<div class="o_fusion_api_dashboard o_action">
<!-- Header -->
<div class="o_fusion_api_header d-flex align-items-center justify-content-between p-3 border-bottom bg-view">
<h2 class="mb-0">Fusion API Dashboard</h2>
<button class="btn btn-outline-primary" t-on-click="refresh">
<i class="fa fa-refresh me-1"/> Refresh
</button>
</div>
<div class="o_fusion_api_content p-3" t-if="state.loaded">
<!-- Stats Cards -->
<div class="row g-3 mb-4">
<div class="col-md-3 col-sm-6">
<div class="card h-100 border-0 shadow-sm o_fusion_stat_card"
style="cursor:pointer" t-on-click="openProviders">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="text-muted small text-uppercase">Active Providers</div>
<h2 class="mb-0 mt-1" t-esc="state.activeProviders"/>
</div>
<div class="o_fusion_icon_circle bg-primary bg-opacity-25">
<i class="fa fa-plug text-primary"/>
</div>
</div>
<div class="text-muted small mt-2">
<t t-esc="state.totalProviders"/> total configured
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6">
<div class="card h-100 border-0 shadow-sm o_fusion_stat_card"
style="cursor:pointer" t-on-click="openConsumers">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="text-muted small text-uppercase">Active Modules</div>
<h2 class="mb-0 mt-1" t-esc="state.activeConsumers"/>
</div>
<div class="o_fusion_icon_circle bg-success bg-opacity-25">
<i class="fa fa-cubes text-success"/>
</div>
</div>
<div class="text-muted small mt-2">
<t t-esc="state.totalConsumers"/> total registered
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6">
<div class="card h-100 border-0 shadow-sm o_fusion_stat_card"
style="cursor:pointer" t-on-click="openUsageLog">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="text-muted small text-uppercase">Month Cost</div>
<h2 class="mb-0 mt-1">$<t t-esc="formatCost(state.monthCost)"/></h2>
</div>
<div class="o_fusion_icon_circle bg-warning bg-opacity-25">
<i class="fa fa-usd text-warning"/>
</div>
</div>
<div class="text-muted small mt-2">
<t t-esc="state.monthRequests"/> requests this month
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6">
<div class="card h-100 border-0 shadow-sm o_fusion_stat_card"
style="cursor:pointer" t-on-click="openUsageLog">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start">
<div>
<div class="text-muted small text-uppercase">Today</div>
<h2 class="mb-0 mt-1" t-esc="state.todayRequests"/>
</div>
<div class="o_fusion_icon_circle bg-info bg-opacity-25">
<i class="fa fa-bar-chart text-info"/>
</div>
</div>
<div class="text-muted small mt-2">
requests today
</div>
</div>
</div>
</div>
</div>
<!-- Budget Alerts -->
<div class="mb-4" t-if="state.approachingLimits.length > 0">
<div class="alert alert-warning d-flex align-items-start" t-foreach="state.approachingLimits" t-as="alert" t-key="alert_index">
<i class="fa fa-exclamation-triangle me-2 mt-1"/>
<div>
<strong t-esc="alert.consumer"/> on <t t-esc="alert.provider"/>:
<t t-esc="alert.pct"/>% of budget used
($<t t-esc="alert.spent"/> / $<t t-esc="alert.budget"/>)
</div>
</div>
</div>
<div class="row g-3">
<!-- Provider Stats -->
<div class="col-lg-6" t-if="state.providerStats.length > 0">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-transparent border-bottom-0">
<h5 class="mb-0">Provider Activity</h5>
</div>
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Provider</th>
<th class="text-end">Keys</th>
<th class="text-end">Requests</th>
<th class="text-end">Cost</th>
</tr>
</thead>
<tbody>
<tr t-foreach="state.providerStats" t-as="prov" t-key="prov.id">
<td><strong t-esc="prov.name"/></td>
<td class="text-end" t-esc="prov.keys"/>
<td class="text-end" t-esc="prov.requests"/>
<td class="text-end">$<t t-esc="formatCost(prov.cost)"/></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Top Consumers -->
<div class="col-lg-6" t-if="state.topConsumers.length > 0">
<div class="card border-0 shadow-sm h-100">
<div class="card-header bg-transparent border-bottom-0">
<h5 class="mb-0">Top Consumers (This Month)</h5>
</div>
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Module</th>
<th class="text-end">Requests</th>
<th class="text-end">Cost</th>
</tr>
</thead>
<tbody>
<tr t-foreach="state.topConsumers" t-as="cons" t-key="cons_index">
<td><strong t-esc="cons.name"/></td>
<td class="text-end" t-esc="cons.requests"/>
<td class="text-end">$<t t-esc="formatCost(cons.cost)"/></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="mt-3" t-if="state.recentUsage.length > 0">
<div class="card border-0 shadow-sm">
<div class="card-header bg-transparent border-bottom-0 d-flex justify-content-between align-items-center">
<h5 class="mb-0">Recent Activity</h5>
<button class="btn btn-sm btn-outline-secondary" t-on-click="openUsageLog">
View All
</button>
</div>
<div class="card-body p-0">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Time</th>
<th>Consumer</th>
<th>Provider</th>
<th>Feature</th>
<th class="text-end">Tokens</th>
<th class="text-end">Cost</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr t-foreach="state.recentUsage" t-as="usage" t-key="usage_index">
<td class="text-muted small" t-esc="formatTime(usage.time)"/>
<td t-esc="usage.consumer"/>
<td t-esc="usage.provider"/>
<td class="text-muted" t-esc="usage.feature"/>
<td class="text-end" t-esc="usage.tokens"/>
<td class="text-end">$<t t-esc="formatCostDetailed(usage.cost)"/></td>
<td>
<span class="badge" t-att-class="getStatusClass(usage.status)"
t-esc="usage.status"/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Empty State -->
<div class="text-center py-5" t-if="state.monthRequests === 0 and state.activeProviders === 0">
<i class="fa fa-rocket fa-3x text-muted mb-3 d-block"/>
<h4>Welcome to Fusion API</h4>
<p class="text-muted mb-3">
Get started by configuring your API providers and adding your keys.
</p>
<button class="btn btn-primary" t-on-click="openProviders">
<i class="fa fa-plus me-1"/> Configure Providers
</button>
</div>
</div>
<!-- Loading State -->
<div class="text-center py-5" t-if="!state.loaded">
<i class="fa fa-spinner fa-spin fa-2x text-muted"/>
<p class="text-muted mt-2">Loading dashboard...</p>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Tree View -->
<record id="view_api_access_tree" model="ir.ui.view">
<field name="name">fusion.api.access.tree</field>
<field name="model">fusion.api.access</field>
<field name="arch" type="xml">
<list editable="bottom">
<field name="consumer_id"/>
<field name="provider_id"/>
<field name="is_enabled" widget="boolean_toggle"/>
<field name="monthly_budget_usd"/>
<field name="daily_budget_usd" optional="hide"/>
<field name="max_rpm"/>
<field name="max_rpd" optional="hide"/>
<field name="current_month_cost" string="Month Spend ($)"/>
<field name="current_day_requests" string="Today Reqs"/>
<field name="budget_usage_pct" widget="progressbar" string="Budget Used"/>
<field name="is_budget_exceeded" column_invisible="True"/>
</list>
</field>
</record>
<!-- Form View -->
<record id="view_api_access_form" model="ir.ui.view">
<field name="name">fusion.api.access.form</field>
<field name="model">fusion.api.access</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group string="Link">
<field name="consumer_id"/>
<field name="provider_id"/>
<field name="is_enabled"/>
</group>
<group string="Current Usage">
<field name="current_month_cost"/>
<field name="current_day_cost"/>
<field name="current_day_requests"/>
<field name="budget_usage_pct" widget="progressbar"/>
<field name="is_budget_exceeded"/>
</group>
</group>
<group>
<group string="Budget Limits">
<field name="monthly_budget_usd"/>
<field name="daily_budget_usd"/>
</group>
<group string="Rate Limits">
<field name="max_rpm"/>
<field name="max_rpd"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- Action -->
<record id="action_api_access" model="ir.actions.act_window">
<field name="name">Access Rules</field>
<field name="res_model">fusion.api.access</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No access rules configured
</p>
<p>Access rules control which Fusion modules can use which API providers, with budget and rate limits.</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,91 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Tree View -->
<record id="view_api_consumer_tree" model="ir.ui.view">
<field name="name">fusion.api.consumer.tree</field>
<field name="model">fusion.api.consumer</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="technical_name"/>
<field name="module_state" widget="badge"
decoration-success="module_state == 'installed'"
decoration-muted="module_state != 'installed'"
optional="show"/>
<field name="is_active" widget="boolean_toggle"/>
<field name="auto_detected"/>
<field name="first_seen_at" optional="hide"/>
<field name="total_month_cost" string="Month Cost ($)"/>
<field name="total_month_requests" string="Month Requests"/>
</list>
</field>
</record>
<!-- Form View -->
<record id="view_api_consumer_form" model="ir.ui.view">
<field name="name">fusion.api.consumer.form</field>
<field name="model">fusion.api.consumer</field>
<field name="arch" type="xml">
<form>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_toggle_access" type="object"
class="oe_stat_button"
icon="fa-power-off">
<field name="is_active" widget="boolean_button"
options='{"terminology": {"string_true": "Active", "string_false": "Disabled"}}'/>
</button>
</div>
<div class="oe_title">
<h1><field name="name"/></h1>
</div>
<group>
<group>
<field name="technical_name"/>
<field name="module_id"/>
<field name="module_state"/>
</group>
<group>
<field name="auto_detected"/>
<field name="first_seen_at"/>
<field name="total_month_cost"/>
<field name="total_month_requests"/>
</group>
</group>
<notebook>
<page string="Access Rules" name="access">
<field name="access_ids">
<list editable="bottom">
<field name="provider_id"/>
<field name="is_enabled"/>
<field name="monthly_budget_usd"/>
<field name="daily_budget_usd"/>
<field name="max_rpm"/>
<field name="max_rpd"/>
<field name="current_month_cost" string="Month Spend"/>
<field name="current_day_requests" string="Today"/>
<field name="budget_usage_pct" widget="progressbar" string="Budget %"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Action -->
<record id="action_api_consumer" model="ir.actions.act_window">
<field name="name">Consumers</field>
<field name="res_model">fusion.api.consumer</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No consumers detected yet
</p>
<p>Fusion modules will appear here automatically when they make their first API call.</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_dashboard" model="ir.actions.client">
<field name="name">Fusion API Dashboard</field>
<field name="tag">fusion_api_dashboard</field>
</record>
</odoo>

View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Form View (used as dialog from provider form) -->
<record id="view_api_key_form" model="ir.ui.view">
<field name="name">fusion.api.key.form</field>
<field name="model">fusion.api.key</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_validate" type="object"
string="Validate Key" class="btn-primary"
icon="fa-check-circle"/>
</header>
<sheet>
<group>
<group>
<field name="name" placeholder="e.g. Production Key"/>
<field name="api_key" password="True" placeholder="Enter your API key"/>
<field name="environment"/>
<field name="is_default"/>
<field name="is_active"/>
</group>
<group>
<field name="provider_id" invisible="context.get('default_provider_id')"/>
<field name="provider_type" invisible="1"/>
<field name="validation_status" widget="badge"
decoration-success="validation_status == 'valid'"
decoration-danger="validation_status == 'invalid'"
decoration-muted="validation_status == 'unknown'"/>
<field name="last_validated_at"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<group string="OAuth Credentials"
invisible="provider_type not in ('google_oauth', 'microsoft_oauth')">
<group>
<field name="client_id" password="True"/>
<field name="client_secret" password="True"/>
<field name="redirect_uri"/>
</group>
<group>
<field name="access_token" password="True"/>
<field name="refresh_token" password="True"/>
<field name="token_expiry"/>
</group>
</group>
<field name="notes" placeholder="Optional notes about this key..."/>
</sheet>
</form>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,207 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Kanban View -->
<record id="view_api_provider_kanban" model="ir.ui.view">
<field name="name">fusion.api.provider.kanban</field>
<field name="model">fusion.api.provider</field>
<field name="arch" type="xml">
<kanban class="o_kanban_dashboard" create="false">
<field name="name"/>
<field name="provider_type"/>
<field name="status"/>
<field name="active_key_count"/>
<field name="total_month_cost"/>
<field name="total_month_requests"/>
<field name="color"/>
<field name="icon_class"/>
<field name="website_url"/>
<templates>
<t t-name="card">
<div class="d-flex align-items-start mb-2">
<div class="me-3">
<i t-att-class="'fa fa-2x text-primary ' + (record.icon_class.value or 'fa-plug')"/>
</div>
<div class="flex-grow-1">
<strong><field name="name"/></strong>
<div class="text-muted small">
<field name="provider_type"/>
</div>
</div>
<div>
<field name="status" widget="badge"
decoration-success="status == 'active'"
decoration-muted="status == 'inactive'"
decoration-danger="status == 'error'"/>
</div>
</div>
<div class="row g-2 mt-2">
<div class="col-4 text-center">
<div class="fw-bold"><field name="active_key_count"/></div>
<div class="text-muted small">Keys</div>
</div>
<div class="col-4 text-center">
<div class="fw-bold">$<field name="total_month_cost" widget="float" digits="[10,2]"/></div>
<div class="text-muted small">Month</div>
</div>
<div class="col-4 text-center">
<div class="fw-bold"><field name="total_month_requests"/></div>
<div class="text-muted small">Requests</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- Tree View -->
<record id="view_api_provider_tree" model="ir.ui.view">
<field name="name">fusion.api.provider.tree</field>
<field name="model">fusion.api.provider</field>
<field name="arch" type="xml">
<list>
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="provider_type"/>
<field name="status" widget="badge"
decoration-success="status == 'active'"
decoration-muted="status == 'inactive'"
decoration-danger="status == 'error'"/>
<field name="active_key_count"/>
<field name="total_month_cost" string="Month Cost ($)"/>
<field name="total_month_requests" string="Month Requests"/>
<field name="website_url" widget="url" string="Dashboard"/>
</list>
</field>
</record>
<!-- Form View -->
<record id="view_api_provider_form" model="ir.ui.view">
<field name="name">fusion.api.provider.form</field>
<field name="model">fusion.api.provider</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_activate" type="object" string="Activate"
class="btn-primary"
invisible="status == 'active'"/>
<button name="action_deactivate" type="object" string="Deactivate"
class="btn-secondary"
invisible="status != 'active'"/>
<field name="status" widget="statusbar"
statusbar_visible="inactive,active"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" placeholder="Provider Name"/></h1>
</div>
<group>
<group>
<field name="provider_type"/>
<field name="website_url" widget="url"/>
<field name="icon_class"/>
</group>
<group>
<field name="active_key_count"/>
<field name="total_month_cost"/>
<field name="total_month_requests"/>
<field name="sequence"/>
</group>
</group>
<field name="description" placeholder="Description of this API provider..."/>
<notebook>
<page string="API Keys" name="keys">
<field name="key_ids" context="{'default_provider_id': id}">
<list>
<field name="name"/>
<field name="masked_key" string="API Key"/>
<field name="environment"/>
<field name="is_default"/>
<field name="is_active"/>
<field name="validation_status" widget="badge"
decoration-success="validation_status == 'valid'"
decoration-danger="validation_status == 'invalid'"
decoration-muted="validation_status == 'unknown'"/>
<field name="last_validated_at"/>
</list>
<form>
<header>
<button name="action_validate" type="object"
string="Validate Key" class="btn-primary"
icon="fa-check-circle"/>
</header>
<sheet>
<group>
<group>
<field name="name" placeholder="e.g. Production Key"/>
<field name="api_key" password="True"
placeholder="Enter your API key"/>
<field name="environment"/>
<field name="is_default"/>
<field name="is_active"/>
</group>
<group>
<field name="provider_id" invisible="1"/>
<field name="provider_type" invisible="1"/>
<field name="validation_status" widget="badge"
decoration-success="validation_status == 'valid'"
decoration-danger="validation_status == 'invalid'"
decoration-muted="validation_status == 'unknown'"/>
<field name="last_validated_at"/>
<field name="company_id"
groups="base.group_multi_company"/>
</group>
</group>
<group string="OAuth Credentials"
invisible="provider_type not in ('google_oauth', 'microsoft_oauth')">
<group>
<field name="client_id" password="True"/>
<field name="client_secret" password="True"/>
<field name="redirect_uri"/>
</group>
<group>
<field name="access_token" password="True"/>
<field name="refresh_token" password="True"/>
<field name="token_expiry"/>
</group>
</group>
<field name="notes"
placeholder="Optional notes about this key..."/>
</sheet>
</form>
</field>
</page>
<page string="Access Rules" name="access">
<field name="access_ids">
<list>
<field name="consumer_id"/>
<field name="is_enabled"/>
<field name="monthly_budget_usd"/>
<field name="max_rpm"/>
<field name="max_rpd"/>
<field name="current_month_cost"/>
<field name="budget_usage_pct" widget="progressbar"/>
</list>
</field>
</page>
</notebook>
</sheet>
</form>
</field>
</record>
<!-- Action -->
<record id="action_api_provider" model="ir.actions.act_window">
<field name="name">API Providers</field>
<field name="res_model">fusion.api.provider</field>
<field name="view_mode">kanban,list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Configure your first API provider
</p>
<p>Add API keys for OpenAI, Anthropic, Google Maps, and other services used by Fusion modules.</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,223 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Usage Log: Tree View -->
<record id="view_api_usage_tree" model="ir.ui.view">
<field name="name">fusion.api.usage.tree</field>
<field name="model">fusion.api.usage</field>
<field name="arch" type="xml">
<list create="false" edit="false" default_order="create_date desc">
<field name="create_date" string="Timestamp"/>
<field name="consumer_id"/>
<field name="provider_id"/>
<field name="user_id"/>
<field name="feature"/>
<field name="model_name"/>
<field name="tokens_in" optional="show"/>
<field name="tokens_out" optional="show"/>
<field name="total_tokens"/>
<field name="estimated_cost_usd" string="Cost ($)" widget="float" digits="[10,6]"/>
<field name="response_time_ms" string="Time (ms)" optional="hide"/>
<field name="status" widget="badge"
decoration-success="status == 'success'"
decoration-danger="status == 'error'"
decoration-warning="status in ('rate_limited', 'budget_exceeded')"/>
</list>
</field>
</record>
<!-- Usage Log: Form View -->
<record id="view_api_usage_form" model="ir.ui.view">
<field name="name">fusion.api.usage.form</field>
<field name="model">fusion.api.usage</field>
<field name="arch" type="xml">
<form create="false" edit="false">
<sheet>
<group>
<group>
<field name="create_date"/>
<field name="consumer_id"/>
<field name="provider_id"/>
<field name="user_id"/>
</group>
<group>
<field name="feature"/>
<field name="model_name"/>
<field name="status"/>
<field name="response_time_ms"/>
</group>
</group>
<group>
<group string="Token Usage">
<field name="tokens_in"/>
<field name="tokens_out"/>
<field name="total_tokens"/>
</group>
<group string="Cost">
<field name="estimated_cost_usd"/>
</group>
</group>
<group string="Error Details" invisible="not error_message">
<field name="error_message" nolabel="1"/>
</group>
</sheet>
</form>
</field>
</record>
<!-- Usage Log: Pivot View -->
<record id="view_api_usage_pivot" model="ir.ui.view">
<field name="name">fusion.api.usage.pivot</field>
<field name="model">fusion.api.usage</field>
<field name="arch" type="xml">
<pivot string="API Usage Analysis">
<field name="consumer_id" type="row"/>
<field name="provider_id" type="col"/>
<field name="estimated_cost_usd" type="measure"/>
<field name="total_tokens" type="measure"/>
</pivot>
</field>
</record>
<!-- Usage Log: Graph View -->
<record id="view_api_usage_graph" model="ir.ui.view">
<field name="name">fusion.api.usage.graph</field>
<field name="model">fusion.api.usage</field>
<field name="arch" type="xml">
<graph string="API Usage" type="bar">
<field name="create_date" interval="day" type="row"/>
<field name="estimated_cost_usd" type="measure"/>
</graph>
</field>
</record>
<!-- Usage Log: Search View -->
<record id="view_api_usage_search" model="ir.ui.view">
<field name="name">fusion.api.usage.search</field>
<field name="model">fusion.api.usage</field>
<field name="arch" type="xml">
<search string="Search Usage">
<field name="consumer_id"/>
<field name="provider_id"/>
<field name="user_id"/>
<field name="feature"/>
<field name="model_name"/>
<separator/>
<filter name="filter_success" string="Success"
domain="[('status', '=', 'success')]"/>
<filter name="filter_errors" string="Errors"
domain="[('status', '=', 'error')]"/>
<filter name="filter_rate_limited" string="Rate Limited"
domain="[('status', '=', 'rate_limited')]"/>
<filter name="filter_budget_exceeded" string="Budget Exceeded"
domain="[('status', '=', 'budget_exceeded')]"/>
<separator/>
<filter name="group_consumer" string="Consumer" context="{'group_by': 'consumer_id'}"/>
<filter name="group_provider" string="Provider" context="{'group_by': 'provider_id'}"/>
<filter name="group_user" string="User" context="{'group_by': 'user_id'}"/>
<filter name="group_feature" string="Feature" context="{'group_by': 'feature'}"/>
<filter name="group_model" string="AI Model" context="{'group_by': 'model_name'}"/>
<filter name="group_status" string="Status" context="{'group_by': 'status'}"/>
<filter name="group_day" string="Day" context="{'group_by': 'create_date:day'}"/>
<filter name="group_month" string="Month" context="{'group_by': 'create_date:month'}"/>
</search>
</field>
</record>
<!-- Usage Log: Action -->
<record id="action_api_usage" model="ir.actions.act_window">
<field name="name">Usage Log</field>
<field name="res_model">fusion.api.usage</field>
<field name="view_mode">list,pivot,graph,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No API calls recorded yet
</p>
<p>Usage data appears here as Fusion modules make API calls through the Fusion API service.</p>
</field>
</record>
<!-- Daily Summary: Tree View -->
<record id="view_api_usage_daily_tree" model="ir.ui.view">
<field name="name">fusion.api.usage.daily.tree</field>
<field name="model">fusion.api.usage.daily</field>
<field name="arch" type="xml">
<list create="false" edit="false" default_order="date desc">
<field name="date"/>
<field name="consumer_id"/>
<field name="provider_id"/>
<field name="user_id" optional="hide"/>
<field name="feature" optional="hide"/>
<field name="model_name" optional="show"/>
<field name="request_count"/>
<field name="error_count" optional="show"/>
<field name="total_tokens"/>
<field name="total_cost_usd" string="Cost ($)" widget="float" digits="[10,4]"/>
<field name="avg_response_time_ms" string="Avg Time (ms)" optional="hide"/>
</list>
</field>
</record>
<!-- Daily Summary: Pivot View -->
<record id="view_api_usage_daily_pivot" model="ir.ui.view">
<field name="name">fusion.api.usage.daily.pivot</field>
<field name="model">fusion.api.usage.daily</field>
<field name="arch" type="xml">
<pivot string="Daily Usage Summary">
<field name="date" interval="month" type="row"/>
<field name="consumer_id" type="row"/>
<field name="provider_id" type="col"/>
<field name="total_cost_usd" type="measure"/>
<field name="request_count" type="measure"/>
<field name="total_tokens" type="measure"/>
</pivot>
</field>
</record>
<!-- Daily Summary: Graph View -->
<record id="view_api_usage_daily_graph" model="ir.ui.view">
<field name="name">fusion.api.usage.daily.graph</field>
<field name="model">fusion.api.usage.daily</field>
<field name="arch" type="xml">
<graph string="Daily Usage Trends" type="line">
<field name="date" type="row"/>
<field name="total_cost_usd" type="measure"/>
</graph>
</field>
</record>
<!-- Daily Summary: Search View -->
<record id="view_api_usage_daily_search" model="ir.ui.view">
<field name="name">fusion.api.usage.daily.search</field>
<field name="model">fusion.api.usage.daily</field>
<field name="arch" type="xml">
<search string="Search Daily Summary">
<field name="consumer_id"/>
<field name="provider_id"/>
<field name="user_id"/>
<field name="feature"/>
<field name="model_name"/>
<separator/>
<filter name="group_consumer" string="Consumer" context="{'group_by': 'consumer_id'}"/>
<filter name="group_provider" string="Provider" context="{'group_by': 'provider_id'}"/>
<filter name="group_user" string="User" context="{'group_by': 'user_id'}"/>
<filter name="group_model" string="AI Model" context="{'group_by': 'model_name'}"/>
<filter name="group_month" string="Month" context="{'group_by': 'date:month'}"/>
</search>
</field>
</record>
<!-- Daily Summary: Action -->
<record id="action_api_usage_daily" model="ir.actions.act_window">
<field name="name">Daily Summary</field>
<field name="res_model">fusion.api.usage.daily</field>
<field name="view_mode">list,pivot,graph,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No daily summaries yet
</p>
<p>Daily usage summaries are aggregated automatically each night by a scheduled action.</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Tree View -->
<record id="view_api_user_limit_tree" model="ir.ui.view">
<field name="name">fusion.api.user.limit.tree</field>
<field name="model">fusion.api.user.limit</field>
<field name="arch" type="xml">
<list editable="bottom">
<field name="user_id"/>
<field name="provider_id"/>
<field name="monthly_budget_usd"/>
<field name="max_rpd"/>
<field name="is_blocked" widget="boolean_toggle"/>
<field name="current_month_cost" string="Month Spend ($)"/>
<field name="current_day_requests" string="Today Reqs"/>
</list>
</field>
</record>
<!-- Form View -->
<record id="view_api_user_limit_form" model="ir.ui.view">
<field name="name">fusion.api.user.limit.form</field>
<field name="model">fusion.api.user.limit</field>
<field name="arch" type="xml">
<form>
<sheet>
<group>
<group>
<field name="user_id"/>
<field name="provider_id"/>
<field name="is_blocked"/>
</group>
<group>
<field name="monthly_budget_usd"/>
<field name="max_rpd"/>
</group>
</group>
<group>
<group string="Current Usage">
<field name="current_month_cost"/>
<field name="current_day_requests"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- Action -->
<record id="action_api_user_limit" model="ir.actions.act_window">
<field name="name">User Limits</field>
<field name="res_model">fusion.api.user.limit</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No user limits configured
</p>
<p>Set per-user budget caps and request limits, or block individual users from API access.</p>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Root Menu -->
<menuitem id="menu_fusion_api_root"
name="Fusion API"
web_icon="fusion_api,static/description/icon.png"
sequence="90"/>
<!-- Dashboard -->
<menuitem id="menu_dashboard"
name="Dashboard"
parent="menu_fusion_api_root"
action="action_dashboard"
sequence="1"/>
<!-- Providers -->
<menuitem id="menu_providers"
name="Providers"
parent="menu_fusion_api_root"
action="action_api_provider"
sequence="10"/>
<!-- Consumers -->
<menuitem id="menu_consumers"
name="Consumers"
parent="menu_fusion_api_root"
action="action_api_consumer"
sequence="20"/>
<!-- Access Rules -->
<menuitem id="menu_access_rules"
name="Access Rules"
parent="menu_fusion_api_root"
action="action_api_access"
sequence="30"
groups="fusion_api.group_manager"/>
<!-- Usage Menu -->
<menuitem id="menu_usage"
name="Usage"
parent="menu_fusion_api_root"
sequence="40"/>
<menuitem id="menu_usage_log"
name="Usage Log"
parent="menu_usage"
action="action_api_usage"
sequence="1"/>
<menuitem id="menu_usage_daily"
name="Daily Summary"
parent="menu_usage"
action="action_api_usage_daily"
sequence="2"/>
<!-- Configuration Menu -->
<menuitem id="menu_configuration"
name="Configuration"
parent="menu_fusion_api_root"
sequence="90"
groups="fusion_api.group_manager"/>
<menuitem id="menu_user_limits"
name="User Limits"
parent="menu_configuration"
action="action_api_user_limit"
sequence="1"/>
</odoo>

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.fusion.api</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<app data-string="Fusion API" string="Fusion API"
name="fusion_api"
groups="fusion_api.group_manager">
<block title="General Settings">
<setting string="Default Environment"
help="Choose whether API calls default to production or sandbox keys.">
<field name="fusion_api_default_environment"/>
</setting>
<setting string="Auto-Detect Consumers"
help="Automatically register new Fusion modules when they first call the API.">
<field name="fusion_api_auto_detect"/>
</setting>
</block>
<block title="Budget &amp; Retention">
<setting string="Global Monthly Budget"
help="Set a global monthly cost cap across all providers. 0 = unlimited.">
<div class="content-group">
<div class="row mt8">
<label class="col-lg-3" for="fusion_api_global_budget"/>
<field name="fusion_api_global_budget" class="col-lg-3"/>
<span class="col-lg-2">USD</span>
</div>
</div>
</setting>
<setting string="Log Retention"
help="Keep detailed usage logs for this many days. Daily summaries are kept indefinitely.">
<div class="content-group">
<div class="row mt8">
<label class="col-lg-3" for="fusion_api_log_retention_days"/>
<field name="fusion_api_log_retention_days" class="col-lg-3"/>
<span class="col-lg-2">days</span>
</div>
</div>
</setting>
</block>
</app>
</xpath>
</field>
</record>
</odoo>

View File

@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
{ {
'name': 'Fusion Authorizer & Sales Portal', 'name': 'Fusion Authorizer & Sales Portal',
'version': '19.0.2.5.0', 'version': '19.0.2.7.0',
'category': 'Sales/Portal', 'category': 'Sales/Portal',
'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms', 'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms',
'description': """ 'description': """
@@ -50,10 +50,10 @@ This module provides external portal access for:
'website', 'website',
'mail', 'mail',
'calendar', 'calendar',
'appointment',
'knowledge', 'knowledge',
'fusion_claims', 'fusion_claims',
'fusion_tasks', 'fusion_tasks',
'fusion_loaners_management',
], ],
'data': [ 'data': [
# Security # Security
@@ -64,7 +64,6 @@ This module provides external portal access for:
'data/portal_menu_data.xml', 'data/portal_menu_data.xml',
'data/ir_actions_server_data.xml', 'data/ir_actions_server_data.xml',
'data/welcome_articles.xml', 'data/welcome_articles.xml',
'data/appointment_invite_data.xml',
# Views # Views
'views/res_partner_views.xml', 'views/res_partner_views.xml',
'views/sale_order_views.xml', 'views/sale_order_views.xml',
@@ -79,7 +78,6 @@ This module provides external portal access for:
'views/portal_accessibility_forms.xml', 'views/portal_accessibility_forms.xml',
'views/portal_technician_templates.xml', 'views/portal_technician_templates.xml',
'views/portal_book_assessment.xml', 'views/portal_book_assessment.xml',
'views/portal_schedule.xml',
'views/portal_page11_sign_templates.xml', 'views/portal_page11_sign_templates.xml',
], ],
'assets': { 'assets': {
@@ -93,11 +91,11 @@ This module provides external portal access for:
'fusion_authorizer_portal/static/src/js/portal_search.js', 'fusion_authorizer_portal/static/src/js/portal_search.js',
'fusion_authorizer_portal/static/src/js/assessment_form.js', 'fusion_authorizer_portal/static/src/js/assessment_form.js',
'fusion_authorizer_portal/static/src/js/signature_pad.js', 'fusion_authorizer_portal/static/src/js/signature_pad.js',
'fusion_authorizer_portal/static/src/js/loaner_portal.js',
'fusion_authorizer_portal/static/src/js/pdf_field_editor.js', 'fusion_authorizer_portal/static/src/js/pdf_field_editor.js',
'fusion_authorizer_portal/static/src/js/technician_push.js', 'fusion_authorizer_portal/static/src/js/technician_push.js',
'fusion_authorizer_portal/static/src/js/technician_location.js', 'fusion_authorizer_portal/static/src/js/technician_location.js',
'fusion_authorizer_portal/static/src/js/portal_schedule_booking.js', 'fusion_authorizer_portal/static/src/js/timezone_detect.js',
], ],
}, },
'images': ['static/description/icon.png'], 'images': ['static/description/icon.png'],

View File

@@ -3,5 +3,4 @@
from . import portal_main from . import portal_main
from . import portal_assessment from . import portal_assessment
from . import pdf_editor from . import pdf_editor
from . import portal_schedule
from . import portal_page11_sign from . import portal_page11_sign

View File

@@ -348,13 +348,13 @@ class AssessmentPortal(CustomerPortal):
vals = { vals = {
'signature_page_11': signature_data, 'signature_page_11': signature_data,
'signature_page_11_name': signer_name, 'signature_page_11_name': signer_name,
'signature_page_11_date': datetime.now(), 'signature_page_11_date': fields.Datetime.now(),
} }
elif signature_type == 'page_12': elif signature_type == 'page_12':
vals = { vals = {
'signature_page_12': signature_data, 'signature_page_12': signature_data,
'signature_page_12_name': signer_name, 'signature_page_12_name': signer_name,
'signature_page_12_date': datetime.now(), 'signature_page_12_date': fields.Datetime.now(),
} }
else: else:
return {'success': False, 'error': 'Invalid signature type'} return {'success': False, 'error': 'Invalid signature type'}
@@ -1018,227 +1018,6 @@ class AssessmentPortal(CustomerPortal):
('Nunavut', 'Nunavut'), ('Nunavut', 'Nunavut'),
] ]
# =========================================================================
# LOANER PORTAL ROUTES
# =========================================================================
@http.route('/my/loaner/categories', type='jsonrpc', auth='user', website=True)
def portal_loaner_categories(self, **kw):
"""Return loaner product categories."""
parent = request.env.ref('fusion_claims.product_category_loaner', raise_if_not_found=False)
if not parent:
return []
categories = request.env['product.category'].sudo().search([
('parent_id', '=', parent.id),
], order='name')
return [{'id': c.id, 'name': c.name} for c in categories]
@http.route('/my/loaner/products', type='jsonrpc', auth='user', website=True)
def portal_loaner_products(self, **kw):
"""Return available loaner products and their serial numbers."""
domain = [('x_fc_can_be_loaned', '=', True)]
category_id = kw.get('category_id')
if category_id:
domain.append(('categ_id', '=', int(category_id)))
products = request.env['product.product'].sudo().search(domain)
loaner_location = request.env.ref('fusion_claims.stock_location_loaner', raise_if_not_found=False)
result = []
for p in products:
lots = []
if loaner_location:
quants = request.env['stock.quant'].sudo().search([
('product_id', '=', p.id),
('location_id', '=', loaner_location.id),
('quantity', '>', 0),
])
for q in quants:
if q.lot_id:
lots.append({'id': q.lot_id.id, 'name': q.lot_id.name})
result.append({
'id': p.id,
'name': p.name,
'category_id': p.categ_id.id,
'period_days': p.product_tmpl_id.x_fc_loaner_period_days or 7,
'lots': lots,
})
return result
@http.route('/my/loaner/locations', type='jsonrpc', auth='user', website=True)
def portal_loaner_locations(self, **kw):
"""Return internal stock locations for return."""
locations = request.env['stock.location'].sudo().search([
('usage', '=', 'internal'),
('company_id', '=', request.env.company.id),
])
return [{'id': loc.id, 'name': loc.complete_name} for loc in locations]
@http.route('/my/loaner/checkout', type='jsonrpc', auth='user', website=True)
def portal_loaner_checkout(self, **kw):
"""Checkout a loaner from the portal."""
partner = request.env.user.partner_id
if not partner.is_sales_rep_portal and not partner.is_authorizer:
return {'error': 'Unauthorized'}
product_id = int(kw.get('product_id', 0))
lot_id = int(kw.get('lot_id', 0)) if kw.get('lot_id') else False
sale_order_id = int(kw.get('sale_order_id', 0)) if kw.get('sale_order_id') else False
client_id = int(kw.get('client_id', 0)) if kw.get('client_id') else False
loaner_period = int(kw.get('loaner_period_days', 7))
condition = kw.get('checkout_condition', 'good')
notes = kw.get('checkout_notes', '')
if not product_id:
return {'error': 'Product is required'}
vals = {
'product_id': product_id,
'loaner_period_days': loaner_period,
'checkout_condition': condition,
'checkout_notes': notes,
'sales_rep_id': request.env.user.id,
}
if lot_id:
vals['lot_id'] = lot_id
if sale_order_id:
so = request.env['sale.order'].sudo().browse(sale_order_id)
if so.exists():
vals['sale_order_id'] = so.id
vals['partner_id'] = so.partner_id.id
vals['authorizer_id'] = so.x_fc_authorizer_id.id if so.x_fc_authorizer_id else False
vals['delivery_address'] = so.partner_shipping_id.contact_address if so.partner_shipping_id else ''
if client_id and not vals.get('partner_id'):
vals['partner_id'] = client_id
if not vals.get('partner_id'):
return {'error': 'Client is required'}
try:
checkout = request.env['fusion.loaner.checkout'].sudo().create(vals)
checkout.action_checkout()
return {
'success': True,
'checkout_id': checkout.id,
'name': checkout.name,
'message': f'Loaner {checkout.name} checked out successfully',
}
except Exception as e:
_logger.error(f"Loaner checkout error: {e}")
return {'error': str(e)}
@http.route('/my/loaner/create-product', type='jsonrpc', auth='user', website=True)
def portal_loaner_create_product(self, **kw):
"""Quick-create a loaner product with serial number from the portal."""
partner = request.env.user.partner_id
if not partner.is_sales_rep_portal and not partner.is_authorizer:
return {'error': 'Unauthorized'}
product_name = kw.get('product_name', '').strip()
serial_number = kw.get('serial_number', '').strip()
if not product_name:
return {'error': 'Product name is required'}
if not serial_number:
return {'error': 'Serial number is required'}
try:
# Use provided category or default to Loaner Equipment
category_id = kw.get('category_id')
if category_id:
category = request.env['product.category'].sudo().browse(int(category_id))
if not category.exists():
category = None
else:
category = None
if not category:
category = request.env.ref('fusion_claims.product_category_loaner', raise_if_not_found=False)
if not category:
category = request.env['product.category'].sudo().search([
('name', '=', 'Loaner Equipment'),
], limit=1)
if not category:
category = request.env['product.category'].sudo().create({
'name': 'Loaner Equipment',
})
# Create product template
product_tmpl = request.env['product.template'].sudo().create({
'name': product_name,
'type': 'consu',
'tracking': 'serial',
'categ_id': category.id,
'x_fc_can_be_loaned': True,
'x_fc_loaner_period_days': 7,
'sale_ok': False,
'purchase_ok': False,
})
product = product_tmpl.product_variant_id
# Create serial number (lot)
lot = request.env['stock.lot'].sudo().create({
'name': serial_number,
'product_id': product.id,
'company_id': request.env.company.id,
})
# Add stock in loaner location
loaner_location = request.env.ref('fusion_claims.stock_location_loaner', raise_if_not_found=False)
if loaner_location:
request.env['stock.quant'].sudo().create({
'product_id': product.id,
'location_id': loaner_location.id,
'lot_id': lot.id,
'quantity': 1,
})
return {
'success': True,
'product_id': product.id,
'product_name': product.name,
'lot_id': lot.id,
'lot_name': lot.name,
}
except Exception as e:
_logger.error(f"Loaner product creation error: {e}")
return {'error': str(e)}
@http.route('/my/loaner/return', type='jsonrpc', auth='user', website=True)
def portal_loaner_return(self, **kw):
"""Return/pickup a loaner from the portal."""
partner = request.env.user.partner_id
if not partner.is_sales_rep_portal and not partner.is_authorizer:
return {'error': 'Unauthorized'}
checkout_id = int(kw.get('checkout_id', 0))
return_condition = kw.get('return_condition', 'good')
return_notes = kw.get('return_notes', '')
return_location_id = int(kw.get('return_location_id', 0)) if kw.get('return_location_id') else None
if not checkout_id:
return {'error': 'Checkout ID is required'}
try:
checkout = request.env['fusion.loaner.checkout'].sudo().browse(checkout_id)
if not checkout.exists():
return {'error': 'Checkout not found'}
if checkout.state not in ('checked_out', 'overdue', 'rental_pending'):
return {'error': 'This loaner is not currently checked out'}
checkout.action_process_return(
return_condition=return_condition,
return_notes=return_notes,
return_location_id=return_location_id,
)
return {
'success': True,
'message': f'Loaner {checkout.name} returned successfully',
}
except Exception as e:
_logger.error(f"Loaner return error: {e}")
return {'error': str(e)}
# ========================================================================== # ==========================================================================
# PUBLIC ASSESSMENT BOOKING # PUBLIC ASSESSMENT BOOKING
# ========================================================================== # ==========================================================================

View File

@@ -14,6 +14,45 @@ _logger = logging.getLogger(__name__)
class AuthorizerPortal(CustomerPortal): class AuthorizerPortal(CustomerPortal):
"""Portal controller for Authorizers (OTs/Therapists)""" """Portal controller for Authorizers (OTs/Therapists)"""
def _get_user_tz(self):
"""Return a pytz timezone from the best available source.
Priority: user tz > browser cookie > company calendar > UTC."""
candidates = [
request.env.user.tz,
]
try:
candidates.append(request.httprequest.cookies.get('tz'))
except Exception:
pass
try:
cal = request.env.company.resource_calendar_id
if cal:
candidates.append(cal.tz)
except Exception:
pass
for tz_name in candidates:
if tz_name:
try:
return pytz.timezone(tz_name)
except pytz.exceptions.UnknownTimeZoneError:
continue
return pytz.UTC
@http.route('/my/timezone/detect', type='jsonrpc', auth='user', website=True)
def timezone_auto_detect(self, timezone=None, **kw):
"""Auto-save browser-detected timezone to the user profile if not already set."""
if not timezone:
return {'status': 'ignored'}
user = request.env.user
if user.tz:
return {'status': 'already_set', 'tz': user.tz}
try:
pytz.timezone(timezone)
except pytz.exceptions.UnknownTimeZoneError:
return {'status': 'invalid'}
user.sudo().write({'tz': timezone})
return {'status': 'saved', 'tz': timezone}
@http.route(['/my', '/my/home'], type='http', auth='user', website=True) @http.route(['/my', '/my/home'], type='http', auth='user', website=True)
def home(self, **kw): def home(self, **kw):
"""Override home to add ADP posting info for Fusion users""" """Override home to add ADP posting info for Fusion users"""
@@ -111,15 +150,8 @@ class AuthorizerPortal(CustomerPortal):
except (ValueError, TypeError): except (ValueError, TypeError):
base_date = date(2026, 1, 23) base_date = date(2026, 1, 23)
# Get user's timezone for accurate date display
user_tz = request.env.user.tz or 'UTC'
try:
tz = pytz.timezone(user_tz)
except pytz.exceptions.UnknownTimeZoneError:
tz = pytz.UTC
# Get today's date in user's timezone
from datetime import datetime from datetime import datetime
tz = self._get_user_tz()
now_utc = datetime.now(pytz.UTC) now_utc = datetime.now(pytz.UTC)
now_local = now_utc.astimezone(tz) now_local = now_utc.astimezone(tz)
today = now_local.date() today = now_local.date()
@@ -1116,14 +1148,27 @@ class AuthorizerPortal(CustomerPortal):
('check_out', '=', False), ('check_out', '=', False),
], limit=1) ], limit=1)
if att: if att:
check_in_time = att.check_in.isoformat() if att.check_in else '' check_in_time = (att.check_in.isoformat() + 'Z') if att.check_in else ''
location_name = att.x_fclk_location_id.name if att.x_fclk_location_id else '' location_name = att.x_fclk_location_id.name if att.x_fclk_location_id else ''
from datetime import datetime
tz = self._get_user_tz()
now_local = pytz.utc.localize(fields.Datetime.now()).astimezone(tz)
today_local = now_local.date()
today_start = tz.localize(datetime.combine(today_local, datetime.min.time())).astimezone(pytz.utc).replace(tzinfo=None)
today_atts = request.env['hr.attendance'].sudo().search([
('employee_id', '=', employee.id),
('check_in', '>=', today_start),
('check_out', '!=', False),
])
today_hours = sum(a.x_fclk_net_hours or 0 for a in today_atts)
return { return {
'clock_enabled': True, 'clock_enabled': True,
'clock_checked_in': is_checked_in, 'clock_checked_in': is_checked_in,
'clock_check_in_time': check_in_time, 'clock_check_in_time': check_in_time,
'clock_location_name': location_name, 'clock_location_name': location_name,
'clock_today_hours': round(today_hours, 1),
} }
except Exception as e: except Exception as e:
_logger.warning("Clock status check failed: %s", e) _logger.warning("Clock status check failed: %s", e)
@@ -1310,10 +1355,18 @@ class AuthorizerPortal(CustomerPortal):
('id', '!=', task.id), ('id', '!=', task.id),
], order='time_start', limit=1) ], order='time_start', limit=1)
# Get order lines if linked to a sale order # Get order lines from linked sale order or purchase order
order_lines = [] order_lines = []
linked_order = False
linked_order_type = ''
if task.sale_order_id: if task.sale_order_id:
linked_order = task.sale_order_id
linked_order_type = 'sale'
order_lines = task.sale_order_id.order_line.filtered(lambda l: not l.display_type) order_lines = task.sale_order_id.order_line.filtered(lambda l: not l.display_type)
elif task.purchase_order_id:
linked_order = task.purchase_order_id
linked_order_type = 'purchase'
order_lines = task.purchase_order_id.order_line
# Get VAPID public key for push notifications # Get VAPID public key for push notifications
vapid_public = request.env['ir.config_parameter'].sudo().get_param( vapid_public = request.env['ir.config_parameter'].sudo().get_param(
@@ -1323,6 +1376,8 @@ class AuthorizerPortal(CustomerPortal):
values = { values = {
'task': task, 'task': task,
'order_lines': order_lines, 'order_lines': order_lines,
'linked_order': linked_order,
'linked_order_type': linked_order_type,
'vapid_public_key': vapid_public, 'vapid_public_key': vapid_public,
'page_name': 'technician_task_detail', 'page_name': 'technician_task_detail',
'earlier_incomplete': earlier_incomplete, 'earlier_incomplete': earlier_incomplete,
@@ -1384,7 +1439,9 @@ class AuthorizerPortal(CustomerPortal):
safe_notes = str(escape(notes or '')) safe_notes = str(escape(notes or ''))
formatted_notes = re.sub(r'\n', '<br/>', safe_notes) formatted_notes = re.sub(r'\n', '<br/>', safe_notes)
timestamp = fields.Datetime.now().strftime("%b %d, %Y %I:%M %p") tz = self._get_user_tz()
local_now = pytz.utc.localize(fields.Datetime.now()).astimezone(tz)
timestamp = local_now.strftime("%b %d, %Y %I:%M %p")
safe_user = str(escape(user.name)) safe_user = str(escape(user.name))
safe_task = str(escape(task.name)) safe_task = str(escape(task.name))
@@ -2077,7 +2134,7 @@ class AuthorizerPortal(CustomerPortal):
'x_fc_pod_client_name': client_name.strip(), 'x_fc_pod_client_name': client_name.strip(),
'x_fc_pod_signature_date': sig_date, 'x_fc_pod_signature_date': sig_date,
'x_fc_pod_signed_by_user_id': user.id, 'x_fc_pod_signed_by_user_id': user.id,
'x_fc_pod_signed_datetime': datetime.now(), 'x_fc_pod_signed_datetime': fields.Datetime.now(),
}) })
# Generate the signed POD PDF # Generate the signed POD PDF

View File

@@ -1,327 +0,0 @@
# -*- coding: utf-8 -*-
from odoo import http, _, fields
from odoo.http import request
from odoo.addons.portal.controllers.portal import CustomerPortal
from odoo.exceptions import AccessError, ValidationError
from datetime import datetime, timedelta
import json
import logging
import pytz
_logger = logging.getLogger(__name__)
class PortalSchedule(CustomerPortal):
"""Portal controller for appointment scheduling and calendar management."""
def _get_schedule_values(self):
"""Common values for schedule pages."""
ICP = request.env['ir.config_parameter'].sudo()
g_start = ICP.get_param('fusion_claims.portal_gradient_start', '#5ba848')
g_mid = ICP.get_param('fusion_claims.portal_gradient_mid', '#3a8fb7')
g_end = ICP.get_param('fusion_claims.portal_gradient_end', '#2e7aad')
gradient = 'linear-gradient(135deg, %s 0%%, %s 60%%, %s 100%%)' % (g_start, g_mid, g_end)
google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
return {
'portal_gradient': gradient,
'google_maps_api_key': google_maps_api_key,
}
def _get_user_timezone(self):
tz_name = request.env.user.tz or 'America/Toronto'
try:
return pytz.timezone(tz_name)
except pytz.exceptions.UnknownTimeZoneError:
return pytz.timezone('America/Toronto')
def _get_appointment_types(self):
"""Get appointment types available to the current user."""
return request.env['appointment.type'].sudo().search([
('staff_user_ids', 'in', [request.env.user.id]),
])
@http.route(['/my/schedule'], type='http', auth='user', website=True)
def schedule_page(self, **kw):
"""Schedule overview: upcoming appointments and shareable link."""
partner = request.env.user.partner_id
user = request.env.user
now = fields.Datetime.now()
upcoming_events = request.env['calendar.event'].sudo().search([
('partner_ids', 'in', [partner.id]),
('start', '>=', now),
], order='start asc', limit=20)
today_events = request.env['calendar.event'].sudo().search([
('partner_ids', 'in', [partner.id]),
('start', '>=', now.replace(hour=0, minute=0, second=0)),
('start', '<', (now + timedelta(days=1)).replace(hour=0, minute=0, second=0)),
], order='start asc')
invite = request.env['appointment.invite'].sudo().search([
('staff_user_ids', 'in', [user.id]),
], limit=1)
share_url = invite.book_url if invite else ''
appointment_types = self._get_appointment_types()
tz = self._get_user_timezone()
values = self._get_schedule_values()
values.update({
'page_name': 'schedule',
'upcoming_events': upcoming_events,
'today_events': today_events,
'share_url': share_url,
'appointment_types': appointment_types,
'user_tz': tz,
'now': now,
})
return request.render('fusion_authorizer_portal.portal_schedule_page', values)
@http.route(['/my/schedule/book'], type='http', auth='user', website=True)
def schedule_book(self, appointment_type_id=None, **kw):
"""Booking form for a new appointment."""
appointment_types = self._get_appointment_types()
if not appointment_types:
return request.redirect('/my/schedule')
if appointment_type_id:
selected_type = request.env['appointment.type'].sudo().browse(int(appointment_type_id))
if not selected_type.exists():
selected_type = appointment_types[0]
else:
selected_type = appointment_types[0]
values = self._get_schedule_values()
values.update({
'page_name': 'schedule_book',
'appointment_types': appointment_types,
'selected_type': selected_type,
'now': fields.Datetime.now(),
'error': kw.get('error'),
'success': kw.get('success'),
})
return request.render('fusion_authorizer_portal.portal_schedule_book', values)
@http.route('/my/schedule/available-slots', type='json', auth='user', website=True)
def schedule_available_slots(self, appointment_type_id, selected_date=None, **kw):
"""JSON-RPC endpoint: return available time slots for a date."""
appointment_type = request.env['appointment.type'].sudo().browse(int(appointment_type_id))
if not appointment_type.exists():
return {'error': 'Appointment type not found', 'slots': []}
user = request.env.user
tz_name = user.tz or 'America/Toronto'
tz = self._get_user_timezone()
ref_date = fields.Datetime.now()
slot_data = appointment_type._get_appointment_slots(
timezone=tz_name,
filter_users=request.env['res.users'].sudo().browse(user.id),
asked_capacity=1,
reference_date=ref_date,
)
filtered_slots = []
target_date = None
if selected_date:
try:
target_date = datetime.strptime(selected_date, '%Y-%m-%d').date()
except ValueError:
return {'error': 'Invalid date format', 'slots': []}
for month_data in slot_data:
for week in month_data.get('weeks', []):
for day_info in week:
if not day_info:
continue
day = day_info.get('day')
if target_date and day != target_date:
continue
for slot in day_info.get('slots', []):
slot_dt_str = slot.get('datetime')
if not slot_dt_str:
continue
filtered_slots.append({
'datetime': slot_dt_str,
'start_hour': slot.get('start_hour', ''),
'end_hour': slot.get('end_hour', ''),
'duration': slot.get('slot_duration', str(appointment_type.appointment_duration)),
'staff_user_id': slot.get('staff_user_id', user.id),
})
available_dates = []
if not target_date:
seen = set()
for month_data in slot_data:
for week in month_data.get('weeks', []):
for day_info in week:
if not day_info:
continue
day = day_info.get('day')
if day and day_info.get('slots') and str(day) not in seen:
seen.add(str(day))
available_dates.append(str(day))
return {
'slots': filtered_slots,
'available_dates': sorted(available_dates),
'duration': appointment_type.appointment_duration,
'timezone': tz_name,
}
@http.route('/my/schedule/week-events', type='json', auth='user', website=True)
def schedule_week_events(self, selected_date, **kw):
"""Return the user's calendar events for the Mon-Sun week containing selected_date."""
try:
target = datetime.strptime(selected_date, '%Y-%m-%d').date()
except (ValueError, TypeError):
return {'error': 'Invalid date format', 'events': [], 'week_days': []}
monday = target - timedelta(days=target.weekday())
sunday = monday + timedelta(days=6)
partner = request.env.user.partner_id
tz = self._get_user_timezone()
monday_start_local = tz.localize(datetime.combine(monday, datetime.min.time()))
sunday_end_local = tz.localize(datetime.combine(sunday, datetime.max.time()))
monday_start_utc = monday_start_local.astimezone(pytz.utc).replace(tzinfo=None)
sunday_end_utc = sunday_end_local.astimezone(pytz.utc).replace(tzinfo=None)
events = request.env['calendar.event'].sudo().search([
('partner_ids', 'in', [partner.id]),
('start', '>=', monday_start_utc),
('start', '<=', sunday_end_utc),
], order='start asc')
event_list = []
for ev in events:
start_utc = ev.start
stop_utc = ev.stop
start_local = pytz.utc.localize(start_utc).astimezone(tz)
stop_local = pytz.utc.localize(stop_utc).astimezone(tz)
event_list.append({
'name': ev.name or '',
'start': start_local.strftime('%Y-%m-%d %H:%M'),
'end': stop_local.strftime('%Y-%m-%d %H:%M'),
'start_time': start_local.strftime('%I:%M %p'),
'end_time': stop_local.strftime('%I:%M %p'),
'day_of_week': start_local.weekday(),
'date': start_local.strftime('%Y-%m-%d'),
'location': ev.location or '',
'duration': ev.duration,
})
day_labels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
week_days = []
for i in range(7):
day = monday + timedelta(days=i)
week_days.append({
'label': day_labels[i],
'date': day.strftime('%Y-%m-%d'),
'day_num': day.day,
'is_selected': day == target,
})
return {
'events': event_list,
'week_days': week_days,
'selected_date': selected_date,
}
@http.route('/my/schedule/book/submit', type='http', auth='user', website=True, methods=['POST'])
def schedule_book_submit(self, **post):
"""Process the booking form submission."""
appointment_type_id = int(post.get('appointment_type_id', 0))
appointment_type = request.env['appointment.type'].sudo().browse(appointment_type_id)
if not appointment_type.exists():
return request.redirect('/my/schedule/book?error=Invalid+appointment+type')
client_name = (post.get('client_name') or '').strip()
client_street = (post.get('client_street') or '').strip()
client_city = (post.get('client_city') or '').strip()
client_province = (post.get('client_province') or '').strip()
client_postal = (post.get('client_postal') or '').strip()
notes = (post.get('notes') or '').strip()
slot_datetime = (post.get('slot_datetime') or '').strip()
slot_duration = post.get('slot_duration', str(appointment_type.appointment_duration))
if not client_name or not slot_datetime:
return request.redirect('/my/schedule/book?error=Client+name+and+time+slot+are+required')
user = request.env.user
tz = self._get_user_timezone()
try:
start_dt_naive = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S')
start_dt_local = tz.localize(start_dt_naive)
start_dt_utc = start_dt_local.astimezone(pytz.utc).replace(tzinfo=None)
except (ValueError, Exception) as e:
_logger.error("Failed to parse slot datetime %s: %s", slot_datetime, e)
return request.redirect('/my/schedule/book?error=Invalid+time+slot')
duration = float(slot_duration)
stop_dt_utc = start_dt_utc + timedelta(hours=duration)
is_valid = appointment_type._check_appointment_is_valid_slot(
staff_user=user,
resources=request.env['appointment.resource'],
asked_capacity=1,
timezone=str(tz),
start_dt=start_dt_utc,
duration=duration,
allday=False,
)
if not is_valid:
return request.redirect('/my/schedule/book?error=This+slot+is+no+longer+available.+Please+choose+another+time.')
address_parts = [p for p in [client_street, client_city, client_province, client_postal] if p]
location = ', '.join(address_parts)
description_lines = []
if client_name:
description_lines.append(f"Client: {client_name}")
if location:
description_lines.append(f"Address: {location}")
if notes:
description_lines.append(f"Notes: {notes}")
description = '\n'.join(description_lines)
event_name = f"{client_name} - {appointment_type.name}"
booking_line_values = [{
'appointment_user_id': user.id,
'capacity_reserved': 1,
'capacity_used': 1,
}]
try:
event_vals = appointment_type._prepare_calendar_event_values(
asked_capacity=1,
booking_line_values=booking_line_values,
description=description,
duration=duration,
allday=False,
appointment_invite=request.env['appointment.invite'],
guests=request.env['res.partner'],
name=event_name,
customer=user.partner_id,
staff_user=user,
start=start_dt_utc,
stop=stop_dt_utc,
)
event_vals['location'] = location
event = request.env['calendar.event'].sudo().create(event_vals)
_logger.info(
"Appointment booked: %s at %s (event ID: %s)",
event_name, start_dt_utc, event.id,
)
except Exception as e:
_logger.error("Failed to create appointment: %s", e)
return request.redirect('/my/schedule/book?error=Failed+to+create+appointment.+Please+try+again.')
return request.redirect('/my/schedule?success=Appointment+booked+successfully')

View File

@@ -0,0 +1,65 @@
# -*- coding: utf-8 -*-
"""Clean up schedule-related views moved to fusion_schedule module.
The portal_schedule_page, portal_schedule_book templates and
appointment_invite_data have been moved to the standalone
fusion_schedule module. This migration removes stale ir.model.data
references so Odoo doesn't complain about orphaned records.
Also reactivates any views that Odoo silently deactivated.
"""
import logging
_logger = logging.getLogger(__name__)
MODULE = 'fusion_authorizer_portal'
MOVED_XMLIDS = [
'portal_schedule_page',
'portal_schedule_book',
'default_appointment_invite',
]
def migrate(cr, version):
if not version:
return
cr.execute("""
SELECT d.res_id FROM ir_model_data d
WHERE d.module = %s AND d.name = 'default_appointment_invite'
AND d.model = 'appointment.invite'
""", [MODULE])
row = cr.fetchone()
if row:
cr.execute("DELETE FROM appointment_invite WHERE id = %s", [row[0]])
_logger.info("Deleted old appointment.invite id=%d (moving to fusion_schedule)", row[0])
for xmlid in MOVED_XMLIDS:
cr.execute("""
DELETE FROM ir_model_data
WHERE module = %s AND name = %s
""", [MODULE, xmlid])
if cr.rowcount:
_logger.info(
"Removed stale ir.model.data %s.%s (moved to fusion_schedule)",
MODULE, xmlid,
)
cr.execute("""
UPDATE ir_ui_view v
SET active = true
FROM ir_model_data d
WHERE d.res_id = v.id
AND d.model = 'ir.ui.view'
AND d.module = %s
AND v.active = false
RETURNING v.id, v.name, v.key
""", [MODULE])
rows = cr.fetchall()
if rows:
_logger.warning(
"Reactivated %d deactivated views for %s: %s",
len(rows), MODULE, [r[2] or r[1] for r in rows],
)

View File

@@ -43,8 +43,24 @@
.tech-clock-card { .tech-clock-card {
background: var(--o-main-card-bg, #fff); background: var(--o-main-card-bg, #fff);
border: 1px solid var(--o-main-border-color, #e9ecef); border: 1px solid var(--o-main-border-color, #e9ecef);
border-radius: 14px; border-radius: 16px;
padding: 0.875rem 1rem; padding: 1.125rem 1.25rem;
transition: border-color 0.3s, box-shadow 0.3s;
}
.tech-clock-layout {
display: flex;
align-items: center;
justify-content: space-between;
}
.tech-clock-info {
flex: 1;
min-width: 0;
}
.tech-clock-status-line {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 2px;
} }
.tech-clock-dot { .tech-clock-dot {
width: 10px; width: 10px;
@@ -52,6 +68,7 @@
border-radius: 50%; border-radius: 50%;
background: #adb5bd; background: #adb5bd;
flex-shrink: 0; flex-shrink: 0;
transition: all 0.3s;
} }
.tech-clock-dot--active { .tech-clock-dot--active {
background: #10b981; background: #10b981;
@@ -60,61 +77,199 @@
} }
@keyframes tech-clock-pulse { @keyframes tech-clock-pulse {
0%, 100% { box-shadow: 0 0 6px rgba(16, 185, 129, 0.5); } 0%, 100% { box-shadow: 0 0 6px rgba(16, 185, 129, 0.5); }
50% { box-shadow: 0 0 12px rgba(16, 185, 129, 0.8); } 50% { box-shadow: 0 0 14px rgba(16, 185, 129, 0.8); }
} }
.tech-clock-status { .tech-clock-status {
font-size: 0.85rem; font-size: 0.8rem;
font-weight: 600; font-weight: 600;
color: var(--o-main-text-color, #212529); color: var(--o-main-text-color, #212529);
line-height: 1.2; line-height: 1.2;
} }
.tech-clock-timer { .tech-clock-timer {
font-size: 0.75rem; font-size: 1.6rem;
font-weight: 600; font-weight: 700;
font-variant-numeric: tabular-nums; font-variant-numeric: tabular-nums;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
color: var(--o-main-text-color, #212529);
letter-spacing: 1px;
line-height: 1.3;
}
.tech-clock-hours {
font-size: 0.72rem;
color: #6c757d; color: #6c757d;
font-weight: 500;
} }
.tech-clock-btn {
display: inline-flex; /* Circular orb button */
.tech-clock-action {
display: flex;
flex-direction: column;
align-items: center; align-items: center;
gap: 0.4rem; gap: 5px;
padding: 0.5rem 1rem; flex-shrink: 0;
border-radius: 10px; margin-left: 12px;
}
.tech-clock-orb-wrap {
position: relative;
width: 68px;
height: 68px;
display: flex;
align-items: center;
justify-content: center;
}
.tech-clock-orb {
width: 56px;
height: 56px;
border-radius: 50%;
border: none; border: none;
font-weight: 600; background: linear-gradient(135deg, #10b981, #059669);
font-size: 0.85rem; color: #fff;
cursor: pointer; cursor: pointer;
transition: all 0.15s; display: flex;
white-space: nowrap; align-items: center;
justify-content: center;
position: relative;
z-index: 2;
box-shadow: 0 4px 18px rgba(16, 185, 129, 0.35);
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
outline: none;
-webkit-tap-highlight-color: transparent;
} }
.tech-clock-btn:active { transform: scale(0.96); } .tech-clock-orb:hover {
.tech-clock-btn--in { transform: scale(1.08);
background: #10b981; box-shadow: 0 6px 24px rgba(16, 185, 129, 0.45);
color: #fff;
} }
.tech-clock-btn--in:hover { background: #059669; } .tech-clock-orb:active {
.tech-clock-btn--out { transform: scale(0.92);
background: #ef4444;
color: #fff;
} }
.tech-clock-btn--out:hover { background: #dc2626; } .tech-clock-orb--out {
.tech-clock-btn:disabled { background: linear-gradient(135deg, #ef4444, #dc2626);
opacity: 0.6; box-shadow: 0 4px 18px rgba(239, 68, 68, 0.35);
}
.tech-clock-orb--out:hover {
box-shadow: 0 6px 24px rgba(239, 68, 68, 0.45);
}
.tech-clock-orb:disabled {
opacity: 0.55;
cursor: not-allowed; cursor: not-allowed;
transform: none !important;
} }
.tech-clock-orb-icon {
display: flex;
align-items: center;
justify-content: center;
}
svg#clockIconPlay {
transform: translateX(1px);
}
/* Wave animation rings - active when clocked in */
.tech-clock-wave {
position: absolute;
top: 50%;
left: 50%;
width: 56px;
height: 56px;
border-radius: 50%;
border: 2px solid transparent;
transform: translate(-50%, -50%) scale(1);
pointer-events: none;
z-index: 1;
opacity: 0;
}
.tech-clock-orb-wrap--active .tech-clock-wave--1 {
animation: tech-clock-wave 2.4s ease-out infinite;
border-color: rgba(16, 185, 129, 0.5);
}
.tech-clock-orb-wrap--active .tech-clock-wave--2 {
animation: tech-clock-wave 2.4s ease-out 0.8s infinite;
border-color: rgba(16, 185, 129, 0.4);
}
.tech-clock-orb-wrap--active .tech-clock-wave--3 {
animation: tech-clock-wave 2.4s ease-out 1.6s infinite;
border-color: rgba(16, 185, 129, 0.3);
}
@keyframes tech-clock-wave {
0% {
transform: translate(-50%, -50%) scale(1);
opacity: 0.6;
}
100% {
transform: translate(-50%, -50%) scale(2.2);
opacity: 0;
}
}
.tech-clock-label {
font-size: 0.65rem;
color: #6c757d;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.tech-clock-error { .tech-clock-error {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.4rem; gap: 0.4rem;
margin-top: 0.5rem; margin-top: 0.75rem;
padding: 0.4rem 0.75rem; padding: 0.5rem 0.75rem;
border-radius: 8px; border-radius: 10px;
background: #fef2f2; background: #fef2f2;
color: #dc2626; color: #dc2626;
font-size: 0.8rem; font-size: 0.78rem;
font-weight: 500; font-weight: 500;
} }
/* Missed clock-out reason modal */
.tech-reason-overlay {
position: fixed;
inset: 0;
z-index: 1060;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.tech-reason-backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.tech-reason-dialog {
position: relative;
background: var(--o-main-card-bg, #fff);
border: 1px solid var(--o-main-border-color, #e5e7eb);
border-radius: 16px;
width: 100%;
max-width: 400px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.15);
animation: techReasonIn 0.25s ease;
}
@keyframes techReasonIn {
from { opacity: 0; transform: scale(0.95) translateY(8px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.tech-reason-header {
text-align: center;
padding: 1.25rem 1.25rem 0.75rem;
border-bottom: 1px solid var(--o-main-border-color, #e9ecef);
}
.tech-reason-header h5 {
margin: 0.5rem 0 0.25rem;
font-weight: 700;
}
.tech-reason-body {
padding: 1rem 1.25rem;
}
.tech-reason-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.75rem 1.25rem 1rem;
border-top: 1px solid var(--o-main-border-color, #e9ecef);
}
/* ---- Quick Links (All Tasks / Tomorrow / Repair Form) ---- */ /* ---- Quick Links (All Tasks / Tomorrow / Repair Form) ---- */
.tech-quick-links { .tech-quick-links {
display: flex; display: flex;
@@ -430,6 +585,40 @@
padding: 0.75rem; padding: 0.75rem;
} }
/* ---- Order Details Card ---- */
.tech-order-card {
border-left: 3px solid #17a2b8;
}
.tech-order-card .bg-purple-subtle {
background: #f3e8ff;
}
.tech-order-card .text-purple {
color: #7c3aed;
}
.tech-order-lines {
border-top: 1px solid var(--o-main-border-color, #eee);
padding-top: 0.75rem;
margin-top: 0.5rem;
}
.tech-order-line-item {
padding: 0.625rem 0;
}
.tech-order-line-border {
border-bottom: 1px solid var(--o-main-border-color, #f0f0f0);
}
.tech-qty-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 32px;
height: 32px;
border-radius: 8px;
background: #e9ecef;
font-weight: 700;
font-size: 0.9rem;
color: #495057;
}
/* ---- Action Buttons (Large Touch Targets) ---- */ /* ---- Action Buttons (Large Touch Targets) ---- */
.tech-action-btn { .tech-action-btn {
display: inline-flex; display: inline-flex;

View File

@@ -1,343 +0,0 @@
(function () {
'use strict';
var dateInput = document.getElementById('bookingDate');
var slotsContainer = document.getElementById('slotsContainer');
var slotsGrid = document.getElementById('slotsGrid');
var slotsLoading = document.getElementById('slotsLoading');
var noSlots = document.getElementById('noSlots');
var slotDatetimeInput = document.getElementById('slotDatetime');
var slotDurationInput = document.getElementById('slotDuration');
var submitBtn = document.getElementById('btnSubmitBooking');
var typeSelect = document.getElementById('appointmentTypeSelect');
var selectedSlotBtn = null;
var weekContainer = document.getElementById('weekCalendarContainer');
var weekLoading = document.getElementById('weekCalendarLoading');
var weekGrid = document.getElementById('weekCalendarGrid');
var weekHeader = document.getElementById('weekCalendarHeader');
var weekBody = document.getElementById('weekCalendarBody');
var weekEmpty = document.getElementById('weekCalendarEmpty');
function getAppointmentTypeId() {
if (typeSelect) return typeSelect.value;
var hidden = document.querySelector('input[name="appointment_type_id"]');
return hidden ? hidden.value : null;
}
function escapeHtml(str) {
var div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
function truncate(str, max) {
if (!str) return '';
return str.length > max ? str.substring(0, max) + '...' : str;
}
function fetchWeekEvents(date) {
if (!weekContainer || !date) return;
weekContainer.style.display = 'block';
weekLoading.style.display = 'block';
weekGrid.style.display = 'none';
weekEmpty.style.display = 'none';
fetch('/my/schedule/week-events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params: { selected_date: date },
}),
})
.then(function (resp) { return resp.json(); })
.then(function (data) {
weekLoading.style.display = 'none';
var result = data.result || {};
var events = result.events || [];
var weekDays = result.week_days || [];
if (result.error || !weekDays.length) {
weekEmpty.style.display = 'block';
return;
}
renderWeekCalendar(weekDays, events, date);
})
.catch(function () {
weekLoading.style.display = 'none';
weekEmpty.textContent = 'Failed to load calendar. Please try again.';
weekEmpty.style.display = 'block';
});
}
function renderWeekCalendar(weekDays, events, selectedDate) {
weekHeader.innerHTML = '';
weekBody.innerHTML = '';
var eventsByDate = {};
events.forEach(function (ev) {
if (!eventsByDate[ev.date]) eventsByDate[ev.date] = [];
eventsByDate[ev.date].push(ev);
});
var hasAnyEvents = events.length > 0;
weekDays.forEach(function (day) {
var isSelected = day.date === selectedDate;
var isWeekend = day.label === 'Sat' || day.label === 'Sun';
var dayEvents = eventsByDate[day.date] || [];
var headerCell = document.createElement('div');
headerCell.className = 'text-center py-2 flex-fill';
headerCell.style.cssText = 'min-width: 0; font-size: 12px; border-right: 1px solid #dee2e6;';
if (isSelected) {
headerCell.style.backgroundColor = '#e8f4fd';
}
if (isWeekend) {
headerCell.style.opacity = '0.6';
}
var labelEl = document.createElement('div');
labelEl.className = 'fw-semibold text-muted';
labelEl.textContent = day.label;
var numEl = document.createElement('div');
numEl.className = isSelected ? 'fw-bold text-primary' : 'fw-semibold';
numEl.style.fontSize = '14px';
numEl.textContent = day.day_num;
headerCell.appendChild(labelEl);
headerCell.appendChild(numEl);
weekHeader.appendChild(headerCell);
var bodyCell = document.createElement('div');
bodyCell.className = 'flex-fill p-1';
bodyCell.style.cssText = 'min-width: 0; min-height: 70px; border-right: 1px solid #dee2e6; overflow: hidden;';
if (isSelected) {
bodyCell.style.backgroundColor = '#f0f8ff';
}
if (dayEvents.length) {
dayEvents.forEach(function (ev) {
var card = document.createElement('div');
card.className = 'mb-1 px-1 py-1 rounded';
card.style.cssText = 'font-size: 11px; background: #eef6ff; border-left: 3px solid #3a8fb7; overflow: hidden; cursor: default;';
card.title = ev.start_time + ' - ' + ev.end_time + '\n' + ev.name + (ev.location ? '\n' + ev.location : '');
var timeEl = document.createElement('div');
timeEl.className = 'fw-semibold text-primary';
timeEl.style.fontSize = '10px';
timeEl.textContent = ev.start_time;
var nameEl = document.createElement('div');
nameEl.className = 'text-truncate';
nameEl.style.fontSize = '10px';
nameEl.textContent = truncate(ev.name, 18);
card.appendChild(timeEl);
card.appendChild(nameEl);
bodyCell.appendChild(card);
});
}
weekBody.appendChild(bodyCell);
});
if (hasAnyEvents) {
weekGrid.style.display = 'block';
weekEmpty.style.display = 'none';
} else {
weekGrid.style.display = 'none';
weekEmpty.style.display = 'block';
}
}
function fetchSlots(date) {
var typeId = getAppointmentTypeId();
if (!typeId || !date) return;
slotsContainer.style.display = 'block';
slotsLoading.style.display = 'block';
slotsGrid.innerHTML = '';
noSlots.style.display = 'none';
slotDatetimeInput.value = '';
if (submitBtn) submitBtn.disabled = true;
selectedSlotBtn = null;
fetch('/my/schedule/available-slots', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
jsonrpc: '2.0',
method: 'call',
params: {
appointment_type_id: parseInt(typeId),
selected_date: date,
},
}),
})
.then(function (resp) { return resp.json(); })
.then(function (data) {
slotsLoading.style.display = 'none';
var result = data.result || {};
var slots = result.slots || [];
if (result.error) {
noSlots.textContent = result.error;
noSlots.style.display = 'block';
return;
}
if (!slots.length) {
noSlots.style.display = 'block';
return;
}
var morningSlots = [];
var afternoonSlots = [];
slots.forEach(function (slot) {
var hour = parseInt(slot.start_hour);
if (isNaN(hour)) {
var match = slot.start_hour.match(/(\d+)/);
hour = match ? parseInt(match[1]) : 0;
if (slot.start_hour.toLowerCase().indexOf('pm') > -1 && hour !== 12) hour += 12;
if (slot.start_hour.toLowerCase().indexOf('am') > -1 && hour === 12) hour = 0;
}
if (hour < 12) {
morningSlots.push(slot);
} else {
afternoonSlots.push(slot);
}
});
function renderGroup(label, icon, groupSlots) {
if (!groupSlots.length) return;
var header = document.createElement('div');
header.className = 'w-100 mt-2 mb-1';
header.innerHTML = '<small class="text-muted fw-semibold"><i class="fa ' + icon + ' me-1"></i>' + label + '</small>';
slotsGrid.appendChild(header);
groupSlots.forEach(function (slot) {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'btn btn-outline-primary btn-sm slot-btn';
btn.style.cssText = 'min-width: 100px; border-radius: 8px; padding: 8px 14px;';
btn.textContent = slot.start_hour;
btn.dataset.datetime = slot.datetime;
btn.dataset.duration = slot.duration;
btn.addEventListener('click', function () {
if (selectedSlotBtn) {
selectedSlotBtn.classList.remove('btn-primary');
selectedSlotBtn.classList.add('btn-outline-primary');
}
btn.classList.remove('btn-outline-primary');
btn.classList.add('btn-primary');
selectedSlotBtn = btn;
slotDatetimeInput.value = slot.datetime;
slotDurationInput.value = slot.duration;
if (submitBtn) submitBtn.disabled = false;
});
slotsGrid.appendChild(btn);
});
}
renderGroup('Morning', 'fa-sun-o', morningSlots);
renderGroup('Afternoon', 'fa-cloud', afternoonSlots);
})
.catch(function (err) {
slotsLoading.style.display = 'none';
noSlots.textContent = 'Failed to load slots. Please try again.';
noSlots.style.display = 'block';
});
}
if (dateInput) {
dateInput.addEventListener('change', function () {
var val = this.value;
fetchWeekEvents(val);
fetchSlots(val);
});
}
if (typeSelect) {
typeSelect.addEventListener('change', function () {
if (dateInput && dateInput.value) {
fetchSlots(dateInput.value);
}
});
}
var bookingForm = document.getElementById('bookingForm');
if (bookingForm) {
bookingForm.addEventListener('submit', function (e) {
if (!slotDatetimeInput || !slotDatetimeInput.value) {
e.preventDefault();
alert('Please select a time slot before booking.');
return false;
}
var clientName = bookingForm.querySelector('input[name="client_name"]');
if (!clientName || !clientName.value.trim()) {
e.preventDefault();
alert('Please enter the client name.');
return false;
}
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.innerHTML = '<i class="fa fa-spinner fa-spin me-1"></i> Booking...';
}
});
}
window.initScheduleAddressAutocomplete = function () {
var streetInput = document.getElementById('clientStreet');
if (!streetInput) return;
var autocomplete = new google.maps.places.Autocomplete(streetInput, {
componentRestrictions: { country: 'ca' },
types: ['address'],
});
autocomplete.addListener('place_changed', function () {
var place = autocomplete.getPlace();
if (!place.address_components) return;
var streetNumber = '';
var streetName = '';
var city = '';
var province = '';
var postalCode = '';
for (var i = 0; i < place.address_components.length; i++) {
var component = place.address_components[i];
var types = component.types;
if (types.indexOf('street_number') > -1) {
streetNumber = component.long_name;
} else if (types.indexOf('route') > -1) {
streetName = component.long_name;
} else if (types.indexOf('locality') > -1) {
city = component.long_name;
} else if (types.indexOf('administrative_area_level_1') > -1) {
province = component.long_name;
} else if (types.indexOf('postal_code') > -1) {
postalCode = component.long_name;
}
}
streetInput.value = (streetNumber + ' ' + streetName).trim();
var cityInput = document.getElementById('clientCity');
if (cityInput) cityInput.value = city;
var provInput = document.getElementById('clientProvince');
if (provInput) provInput.value = province;
var postalInput = document.getElementById('clientPostal');
if (postalInput) postalInput.value = postalCode;
});
};
})();

View File

@@ -0,0 +1,34 @@
/** @odoo-module **/
import publicWidget from "@web/legacy/js/public/public_widget";
publicWidget.registry.TimezoneAutoDetect = publicWidget.Widget.extend({
selector: 'body',
start() {
this._super(...arguments);
this._detectAndSaveTimezone();
},
_detectAndSaveTimezone() {
let detectedTz;
try {
detectedTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
return;
}
if (!detectedTz) return;
const cookieTz = this._getCookie('tz');
if (cookieTz === detectedTz) return;
document.cookie = `tz=${detectedTz};path=/;max-age=${60 * 60 * 24 * 365};SameSite=Lax`;
this._rpc('/my/timezone/detect', { timezone: detectedTz }).catch(() => {});
},
_getCookie(name) {
const match = document.cookie.match(new RegExp('(?:^|;\\s*)' + name + '=([^;]*)'));
return match ? decodeURIComponent(match[1]) : null;
},
});

View File

@@ -5,7 +5,7 @@
<record id="view_fusion_loaner_checkout_form_assessment" model="ir.ui.view"> <record id="view_fusion_loaner_checkout_form_assessment" model="ir.ui.view">
<field name="name">fusion.loaner.checkout.form.assessment</field> <field name="name">fusion.loaner.checkout.form.assessment</field>
<field name="model">fusion.loaner.checkout</field> <field name="model">fusion.loaner.checkout</field>
<field name="inherit_id" ref="fusion_claims.view_fusion_loaner_checkout_form"/> <field name="inherit_id" ref="fusion_loaners_management.view_fusion_loaner_checkout_form"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//button[@name='action_view_partner']" position="before"> <xpath expr="//button[@name='action_view_partner']" position="before">
<button name="action_view_assessment" type="object" <button name="action_view_assessment" type="object"

View File

@@ -1,348 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ==================== SCHEDULE OVERVIEW PAGE ==================== -->
<template id="portal_schedule_page" name="My Schedule">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="True"/>
<div class="container py-4">
<!-- Success/Error Messages -->
<t t-if="request.params.get('success')">
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="fa fa-check-circle me-2"/><t t-out="request.params.get('success')"/>
<button type="button" class="btn-close" data-bs-dismiss="alert"/>
</div>
</t>
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4 flex-wrap gap-2">
<div>
<h3 class="mb-1"><i class="fa fa-calendar-check-o me-2"/>My Schedule</h3>
<p class="text-muted mb-0">View your appointments and book new ones</p>
</div>
<div class="d-flex gap-2 flex-wrap">
<t t-if="share_url">
<div class="input-group" style="max-width: 350px;">
<input type="text" class="form-control form-control-sm" t-att-value="share_url"
id="shareBookingUrl" readonly="readonly" style="font-size: 13px;"/>
<button class="btn btn-outline-secondary btn-sm" type="button"
id="btnCopyShareUrl">
<i class="fa fa-copy" id="copyIcon"/> <span id="copyText">Copy</span>
</button>
<script type="text/javascript">
(function() {
var btn = document.getElementById('btnCopyShareUrl');
if (!btn) return;
btn.addEventListener('click', function() {
var url = document.getElementById('shareBookingUrl').value;
navigator.clipboard.writeText(url);
var icon = document.getElementById('copyIcon');
var text = document.getElementById('copyText');
icon.className = 'fa fa-check';
text.textContent = 'Copied';
setTimeout(function() {
icon.className = 'fa fa-copy';
text.textContent = 'Copy';
}, 2000);
});
})();
</script>
</div>
</t>
<a href="/my/schedule/book" class="btn btn-primary">
<i class="fa fa-plus me-1"/> Book Appointment
</a>
</div>
</div>
<!-- Today's Appointments -->
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
<div class="card-header bg-white border-bottom-0 pt-3 pb-2 px-4"
style="border-radius: 12px 12px 0 0;">
<h5 class="mb-0"><i class="fa fa-sun-o me-2 text-warning"/>Today's Appointments</h5>
</div>
<div class="card-body px-4 pb-4 pt-2">
<t t-if="today_events">
<div class="list-group list-group-flush">
<t t-foreach="today_events" t-as="event">
<div class="list-group-item px-0 py-3 border-start-0 border-end-0">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-center">
<div class="rounded-3 text-center px-3 py-2 me-3"
t-attf-style="background: #{portal_gradient}; min-width: 70px;">
<div class="text-white fw-bold" style="font-size: 14px;">
<t t-out="event.start.astimezone(user_tz).strftime('%I:%M')"/>
</div>
<div class="text-white" style="font-size: 10px;">
<t t-out="event.start.astimezone(user_tz).strftime('%p')"/>
</div>
</div>
<div>
<h6 class="mb-0"><t t-out="event.name"/></h6>
<small class="text-muted">
<t t-if="event.location">
<i class="fa fa-map-marker me-1"/><t t-out="event.location"/>
</t>
</small>
</div>
</div>
<div class="text-end">
<span class="badge bg-light text-dark">
<t t-out="'%.0f' % (event.duration * 60)"/> min
</span>
</div>
</div>
</div>
</t>
</div>
</t>
<t t-else="">
<p class="text-muted mb-0 py-3 text-center">
<i class="fa fa-calendar-o me-1"/> No appointments scheduled for today.
</p>
</t>
</div>
</div>
<!-- Upcoming Appointments -->
<div class="card border-0 shadow-sm" style="border-radius: 12px;">
<div class="card-header bg-white border-bottom-0 pt-3 pb-2 px-4"
style="border-radius: 12px 12px 0 0;">
<h5 class="mb-0"><i class="fa fa-calendar me-2 text-primary"/>Upcoming Appointments</h5>
</div>
<div class="card-body px-4 pb-4 pt-2">
<t t-if="upcoming_events">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th style="border-top:none;">Date</th>
<th style="border-top:none;">Time</th>
<th style="border-top:none;">Appointment</th>
<th style="border-top:none;">Location</th>
<th style="border-top:none;">Duration</th>
</tr>
</thead>
<tbody>
<t t-foreach="upcoming_events" t-as="event">
<tr>
<td>
<strong><t t-out="event.start.astimezone(user_tz).strftime('%b %d')"/></strong>
<br/>
<small class="text-muted">
<t t-out="event.start.astimezone(user_tz).strftime('%A')"/>
</small>
</td>
<td>
<t t-out="event.start.astimezone(user_tz).strftime('%I:%M %p')"/>
</td>
<td><t t-out="event.name"/></td>
<td>
<t t-if="event.location">
<small><t t-out="event.location"/></small>
</t>
<t t-else="">
<small class="text-muted">-</small>
</t>
</td>
<td>
<span class="badge bg-light text-dark">
<t t-out="'%.0f' % (event.duration * 60)"/> min
</span>
</td>
</tr>
</t>
</tbody>
</table>
</div>
</t>
<t t-else="">
<p class="text-muted mb-0 py-3 text-center">
<i class="fa fa-calendar-o me-1"/> No upcoming appointments.
<a href="/my/schedule/book">Book one now</a>
</p>
</t>
</div>
</div>
</div>
</t>
</template>
<!-- ==================== BOOKING FORM ==================== -->
<template id="portal_schedule_book" name="Book Appointment">
<t t-call="portal.portal_layout">
<t t-set="breadcrumbs_searchbar" t-value="True"/>
<div class="container py-4" style="max-width: 800px;">
<!-- Error Messages -->
<t t-if="error">
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="fa fa-exclamation-circle me-2"/><t t-out="error"/>
<button type="button" class="btn-close" data-bs-dismiss="alert"/>
</div>
</t>
<!-- Header -->
<div class="mb-4">
<a href="/my/schedule" class="text-muted text-decoration-none mb-2 d-inline-block">
<i class="fa fa-arrow-left me-1"/> Back to Schedule
</a>
<h3 class="mb-1"><i class="fa fa-plus-circle me-2"/>Book Appointment</h3>
<p class="text-muted mb-0">Select a time slot and enter client details</p>
</div>
<form action="/my/schedule/book/submit" method="post" id="bookingForm">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<!-- Step 1: Appointment Type + Date/Time -->
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
<div class="card-header bg-white border-bottom pt-3 pb-2 px-4"
style="border-radius: 12px 12px 0 0;">
<h5 class="mb-0">
<span class="badge rounded-pill me-2"
t-attf-style="background: #{portal_gradient};">1</span>
Date &amp; Time
</h5>
</div>
<div class="card-body px-4 pb-4">
<!-- Appointment Type (if multiple) -->
<t t-if="len(appointment_types) > 1">
<div class="mb-3">
<label class="form-label fw-semibold">Appointment Type</label>
<select name="appointment_type_id" class="form-select"
id="appointmentTypeSelect">
<t t-foreach="appointment_types" t-as="atype">
<option t-att-value="atype.id"
t-att-selected="atype.id == selected_type.id"
t-att-data-duration="atype.appointment_duration">
<t t-out="atype.name"/>
(<t t-out="'%.0f' % (atype.appointment_duration * 60)"/> min)
</option>
</t>
</select>
</div>
</t>
<t t-else="">
<input type="hidden" name="appointment_type_id"
t-att-value="selected_type.id"/>
</t>
<!-- Date Picker -->
<div class="mb-3">
<label class="form-label fw-semibold">Select Date</label>
<input type="date" class="form-control" id="bookingDate"
required="required"
t-att-min="now.strftime('%Y-%m-%d')"/>
</div>
<!-- Week Calendar Preview -->
<div id="weekCalendarContainer" class="mb-3" style="display: none;">
<label class="form-label fw-semibold">
<i class="fa fa-calendar me-1"/>Your Week
</label>
<div id="weekCalendarLoading" class="text-center py-3" style="display: none;">
<div class="spinner-border spinner-border-sm text-primary me-2" role="status"/>
Loading calendar...
</div>
<div id="weekCalendarGrid" class="border rounded-3 overflow-hidden" style="display: none;">
<div id="weekCalendarHeader" class="d-flex bg-light border-bottom" style="min-height: 40px;"></div>
<div id="weekCalendarBody" class="d-flex" style="min-height: 80px;"></div>
</div>
<div id="weekCalendarEmpty" class="text-muted py-2 text-center" style="display: none;">
<i class="fa fa-calendar-o me-1"/> No events this week -- your schedule is open.
</div>
</div>
<!-- Available Slots -->
<div id="slotsContainer" style="display: none;">
<label class="form-label fw-semibold">Available Time Slots</label>
<div id="slotsLoading" class="text-center py-3" style="display: none;">
<div class="spinner-border spinner-border-sm text-primary me-2" role="status"/>
Loading available slots...
</div>
<div id="slotsGrid" class="d-flex flex-wrap gap-2 mb-2"></div>
<div id="noSlots" class="text-muted py-2" style="display: none;">
<i class="fa fa-info-circle me-1"/> No available slots for this date.
Try another date.
</div>
<input type="hidden" name="slot_datetime" id="slotDatetime"/>
<input type="hidden" name="slot_duration" id="slotDuration"
t-att-value="selected_type.appointment_duration"/>
</div>
</div>
</div>
<!-- Step 2: Client Details -->
<div class="card border-0 shadow-sm mb-4" style="border-radius: 12px;">
<div class="card-header bg-white border-bottom pt-3 pb-2 px-4"
style="border-radius: 12px 12px 0 0;">
<h5 class="mb-0">
<span class="badge rounded-pill me-2"
t-attf-style="background: #{portal_gradient};">2</span>
Client Details
</h5>
</div>
<div class="card-body px-4 pb-4">
<div class="mb-3">
<label class="form-label fw-semibold">Client Name <span class="text-danger">*</span></label>
<input type="text" name="client_name" class="form-control"
placeholder="Enter client's full name" required="required"/>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Address</label>
<input type="text" name="client_street" class="form-control mb-2"
id="clientStreet"
placeholder="Start typing address..."/>
</div>
<div class="row g-2 mb-3">
<div class="col-md-4">
<input type="text" name="client_city" class="form-control"
id="clientCity" placeholder="City"/>
</div>
<div class="col-md-4">
<input type="text" name="client_province" class="form-control"
id="clientProvince" placeholder="Province"/>
</div>
<div class="col-md-4">
<input type="text" name="client_postal" class="form-control"
id="clientPostal" placeholder="Postal Code"/>
</div>
</div>
<div class="mb-0">
<label class="form-label fw-semibold">Notes</label>
<textarea name="notes" class="form-control" rows="3"
placeholder="e.g. Equipment to bring, special instructions, reason for visit..."></textarea>
</div>
</div>
</div>
<!-- Submit -->
<div class="d-flex justify-content-between">
<a href="/my/schedule" class="btn btn-outline-secondary">
<i class="fa fa-arrow-left me-1"/> Cancel
</a>
<button type="submit" class="btn btn-primary btn-lg px-4" id="btnSubmitBooking"
disabled="disabled">
<i class="fa fa-calendar-check-o me-1"/> Book Appointment
</button>
</div>
</form>
</div>
<!-- Google Maps Places API -->
<t t-if="google_maps_api_key">
<script t-attf-src="https://maps.googleapis.com/maps/api/js?key=#{google_maps_api_key}&amp;libraries=places&amp;callback=initScheduleAddressAutocomplete"
async="async" defer="defer"></script>
</t>
<script t-attf-src="/fusion_authorizer_portal/static/src/js/portal_schedule_booking.js"></script>
</t>
</template>
</odoo>

View File

@@ -23,34 +23,79 @@
<div class="tech-clock-card mb-3" <div class="tech-clock-card mb-3"
id="techClockCard" id="techClockCard"
t-att-data-checked-in="'true' if clock_checked_in else 'false'" t-att-data-checked-in="'true' if clock_checked_in else 'false'"
t-att-data-check-in-time="clock_check_in_time or ''"> t-att-data-check-in-time="clock_check_in_time or ''"
<div class="d-flex align-items-center justify-content-between"> t-att-data-today-hours="clock_today_hours or 0">
<div class="d-flex align-items-center gap-2"> <div class="tech-clock-layout">
<div class="tech-clock-dot" t-att-class="'tech-clock-dot--active' if clock_checked_in else ''"/> <div class="tech-clock-info">
<div> <div class="tech-clock-status-line">
<div class="tech-clock-status" id="clockStatusText"> <span t-attf-class="tech-clock-dot {{ 'tech-clock-dot--active' if clock_checked_in else '' }}"/>
<span class="tech-clock-status" id="clockStatusText">
<t t-if="clock_checked_in">Clocked In</t> <t t-if="clock_checked_in">Clocked In</t>
<t t-else="">Not Clocked In</t> <t t-else="">Not Clocked In</t>
</div> </span>
<div class="tech-clock-timer" id="clockTimer">00:00:00</div> </div>
<div class="tech-clock-timer" id="clockTimer">00:00:00</div>
<div class="tech-clock-hours" id="clockTodayHours">
Today: <t t-esc="'%.1fh' % (clock_today_hours or 0)"/>
</div> </div>
</div> </div>
<button class="tech-clock-btn" id="clockActionBtn" <div class="tech-clock-action">
t-att-class="'tech-clock-btn--out' if clock_checked_in else 'tech-clock-btn--in'" <div t-attf-class="tech-clock-orb-wrap {{ 'tech-clock-orb-wrap--active' if clock_checked_in else '' }}" id="clockOrbWrap">
onclick="handleClockAction()"> <span class="tech-clock-wave tech-clock-wave--1"/>
<t t-if="clock_checked_in"> <span class="tech-clock-wave tech-clock-wave--2"/>
<i class="fa fa-stop-circle-o"/> Clock Out <span class="tech-clock-wave tech-clock-wave--3"/>
</t> <button t-attf-class="tech-clock-orb {{ 'tech-clock-orb--out' if clock_checked_in else '' }}"
<t t-else=""> id="clockActionBtn" onclick="handleClockAction()">
<i class="fa fa-play-circle-o"/> Clock In <svg class="tech-clock-orb-icon" id="clockIconPlay" width="22" height="22" viewBox="0 0 24 24" fill="white"
</t> t-attf-style="display:{{ 'none' if clock_checked_in else 'block' }}">
</button> <polygon points="6 3 20 12 6 21"/>
</svg>
<svg class="tech-clock-orb-icon" id="clockIconStop" width="18" height="18" viewBox="0 0 24 24" fill="white"
t-attf-style="display:{{ 'block' if clock_checked_in else 'none' }}">
<rect x="4" y="4" width="16" height="16" rx="2"/>
</svg>
</button>
</div>
<span class="tech-clock-label" id="clockBtnLabel">
<t t-if="clock_checked_in">Clock Out</t>
<t t-else="">Clock In</t>
</span>
</div>
</div> </div>
<div class="tech-clock-error" id="clockError" style="display:none;"> <div class="tech-clock-error" id="clockError" style="display:none;">
<i class="fa fa-exclamation-triangle"/> <i class="fa fa-exclamation-triangle"/>
<span id="clockErrorText"/> <span id="clockErrorText"/>
</div> </div>
</div> </div>
<!-- Missed Clock-Out Reason Modal -->
<div class="tech-reason-overlay" id="clockReasonModal" style="display:none;">
<div class="tech-reason-backdrop" onclick="document.getElementById('clockReasonModal').style.display='none'"/>
<div class="tech-reason-dialog">
<div class="tech-reason-header">
<i class="fa fa-exclamation-triangle text-warning" style="font-size:1.5rem;"/>
<h5>Missed Clock-Out</h5>
<p class="text-muted small mb-0">You didn't clock out on your last shift. Please provide details before clocking in.</p>
</div>
<div class="tech-reason-body">
<label class="form-label small fw-semibold" for="clockReasonText">
Reason <span class="text-danger">*</span>
</label>
<textarea id="clockReasonText" class="form-control mb-2" rows="3"
placeholder="Please explain why you didn't clock out..."/>
<label class="form-label small fw-semibold" for="clockReasonTime">
Departure Time <span class="text-muted">(optional)</span>
</label>
<input type="datetime-local" id="clockReasonTime" class="form-control"/>
</div>
<div class="tech-reason-footer">
<button class="btn btn-sm btn-secondary" onclick="document.getElementById('clockReasonModal').style.display='none'">Cancel</button>
<button class="btn btn-sm btn-success" id="clockReasonSubmitBtn" onclick="submitClockReason()">
<i class="fa fa-check"/> Submit
</button>
</div>
</div>
</div>
</t> </t>
<!-- Quick Stats Bar --> <!-- Quick Stats Bar -->
@@ -290,20 +335,150 @@
var statusEl = document.getElementById('clockStatusText'); var statusEl = document.getElementById('clockStatusText');
var btn = document.getElementById('clockActionBtn'); var btn = document.getElementById('clockActionBtn');
var timerEl = document.getElementById('clockTimer'); var timerEl = document.getElementById('clockTimer');
var labelEl = document.getElementById('clockBtnLabel');
var orbWrap = document.getElementById('clockOrbWrap');
var playIcon = document.getElementById('clockIconPlay');
var stopIcon = document.getElementById('clockIconStop');
if (dot) dot.className = 'tech-clock-dot' + (isCheckedIn ? ' tech-clock-dot--active' : ''); if (dot) dot.className = 'tech-clock-dot' + (isCheckedIn ? ' tech-clock-dot--active' : '');
if (statusEl) statusEl.textContent = isCheckedIn ? 'Clocked In' : 'Not Clocked In'; if (statusEl) statusEl.textContent = isCheckedIn ? 'Clocked In' : 'Not Clocked In';
if (btn) { if (btn) {
btn.className = 'tech-clock-btn ' + (isCheckedIn ? 'tech-clock-btn--out' : 'tech-clock-btn--in'); btn.className = 'tech-clock-orb' + (isCheckedIn ? ' tech-clock-orb--out' : '');
btn.innerHTML = isCheckedIn
? '&lt;i class="fa fa-stop-circle-o">&lt;/i> Clock Out'
: '&lt;i class="fa fa-play-circle-o">&lt;/i> Clock In';
} }
if (orbWrap) {
orbWrap.className = 'tech-clock-orb-wrap' + (isCheckedIn ? ' tech-clock-orb-wrap--active' : '');
}
if (playIcon) playIcon.style.display = isCheckedIn ? 'none' : 'block';
if (stopIcon) stopIcon.style.display = isCheckedIn ? 'block' : 'none';
if (labelEl) labelEl.textContent = isCheckedIn ? 'Clock Out' : 'Clock In';
if (!isCheckedIn &amp;&amp; timerEl) timerEl.textContent = '00:00:00'; if (!isCheckedIn &amp;&amp; timerEl) timerEl.textContent = '00:00:00';
} }
if (isCheckedIn &amp;&amp; checkInTime) startTimer(); if (isCheckedIn &amp;&amp; checkInTime) startTimer();
function showClockError(msg) {
var errEl = document.getElementById('clockError');
var errText = document.getElementById('clockErrorText');
var btn = document.getElementById('clockActionBtn');
if (errText) errText.textContent = msg;
if (errEl) errEl.style.display = 'flex';
if (btn) btn.disabled = false;
}
function doClockAction(lat, lng, accuracy) {
var btn = document.getElementById('clockActionBtn');
var errEl = document.getElementById('clockError');
fetch('/fusion_clock/clock_action', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', id: 1, method: 'call', params: {
latitude: lat,
longitude: lng,
accuracy: accuracy,
source: 'portal'
}})
})
.then(function(r) {
if (!r.ok) {
throw new Error('HTTP ' + r.status);
}
return r.json();
})
.then(function(data) {
if (data.error) {
var msg = (data.error.data &amp;&amp; data.error.data.message) || data.error.message || 'Server error';
showClockError(msg);
return;
}
var result = data.result;
if (!result) {
showClockError('No response from server. Please try again.');
return;
}
if (result.error) {
showClockError(result.error);
return;
}
if (result.requires_reason) {
var modal = document.getElementById('clockReasonModal');
if (modal) modal.style.display = 'flex';
btn.disabled = false;
return;
}
if (result.action === 'clock_in') {
isCheckedIn = true;
checkInTime = new Date(result.check_in + 'Z');
startTimer();
} else if (result.action === 'clock_out') {
isCheckedIn = false;
checkInTime = null;
stopTimer();
} else {
showClockError(result.message || 'Unexpected response. Please try again.');
return;
}
applyState();
btn.disabled = false;
})
.catch(function(e) {
showClockError('Network error. Please try again.');
});
}
window.submitClockReason = function() {
var reasonEl = document.getElementById('clockReasonText');
var timeEl = document.getElementById('clockReasonTime');
var submitBtn = document.getElementById('clockReasonSubmitBtn');
var reason = reasonEl ? reasonEl.value.trim() : '';
if (!reason) {
showClockError('Please provide a reason.');
return;
}
submitBtn.disabled = true;
submitBtn.innerHTML = '&lt;i class="fa fa-spinner fa-spin">&lt;/i> Submitting...';
var rawTime = timeEl ? timeEl.value.trim() : '';
var depTime = rawTime ? new Date(rawTime).toISOString() : '';
fetch('/fusion_clock/submit_reason', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({jsonrpc: '2.0', id: 1, method: 'call', params: {
reason: reason,
departure_time: depTime
}})
})
.then(function(r) { return r.json(); })
.then(function(data) {
submitBtn.disabled = false;
submitBtn.innerHTML = '&lt;i class="fa fa-check">&lt;/i> Submit';
if (data.error) {
showClockError((data.error.data &amp;&amp; data.error.data.message) || data.error.message || 'Server error');
return;
}
var result = data.result || {};
if (result.success) {
var modal = document.getElementById('clockReasonModal');
if (modal) modal.style.display = 'none';
if (reasonEl) reasonEl.value = '';
if (timeEl) timeEl.value = '';
var errEl = document.getElementById('clockError');
if (errEl) errEl.style.display = 'none';
handleClockAction();
} else {
showClockError(result.error || 'Failed to submit reason.');
}
})
.catch(function() {
submitBtn.disabled = false;
submitBtn.innerHTML = '&lt;i class="fa fa-check">&lt;/i> Submit';
showClockError('Network error. Please try again.');
});
};
window.handleClockAction = function() { window.handleClockAction = function() {
var btn = document.getElementById('clockActionBtn'); var btn = document.getElementById('clockActionBtn');
var errEl = document.getElementById('clockError'); var errEl = document.getElementById('clockError');
@@ -311,48 +486,29 @@
btn.disabled = true; btn.disabled = true;
errEl.style.display = 'none'; errEl.style.display = 'none';
window.fusionGetLocation().then(function(coords) { if (!navigator.geolocation) {
fetch('/fusion_clock/clock_action', { doClockAction(0, 0, 0);
method: 'POST', return;
headers: {'Content-Type': 'application/json'}, }
body: JSON.stringify({jsonrpc: '2.0', method: 'call', params: {
latitude: coords.latitude, navigator.geolocation.getCurrentPosition(
longitude: coords.longitude, function(pos) {
accuracy: coords.accuracy, doClockAction(pos.coords.latitude, pos.coords.longitude, pos.coords.accuracy);
source: 'portal' },
}}) function() {
}) fetch('https://ipapi.co/json/')
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(ipData) {
var result = data.result || {}; var lat = (ipData.latitude &amp;&amp; ipData.longitude) ? ipData.latitude : 0;
if (result.error) { var lng = (ipData.latitude &amp;&amp; ipData.longitude) ? ipData.longitude : 0;
errText.textContent = result.error; doClockAction(lat, lng, lat ? 5000 : 0);
errEl.style.display = 'flex'; })
btn.disabled = false; .catch(function() {
return; doClockAction(0, 0, 0);
} });
if (result.action === 'clock_in') { },
isCheckedIn = true; { enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }
checkInTime = new Date(result.check_in + 'Z'); );
startTimer();
} else {
isCheckedIn = false;
checkInTime = null;
stopTimer();
}
applyState();
btn.disabled = false;
})
.catch(function() {
errText.textContent = 'Network error. Please try again.';
errEl.style.display = 'flex';
btn.disabled = false;
});
}).catch(function() {
errText.textContent = 'Location access is required for clock in/out.';
errEl.style.display = 'flex';
btn.disabled = false;
});
}; };
})(); })();
</script> </script>
@@ -675,46 +831,107 @@
</div> </div>
</div> </div>
<!-- ===== TASK DETAILS (collapsible) ===== --> <!-- ===== INSTRUCTIONS ===== -->
<t t-if="task.description or task.equipment_needed"> <t t-if="task.description">
<div class="tech-card mb-3"> <div class="tech-card mb-3">
<t t-if="task.description"> <div class="text-muted small text-uppercase fw-semibold mb-1">
<div class="mb-2"> <i class="fa fa-file-text-o me-1"/>Instructions
<div class="text-muted small text-uppercase fw-semibold mb-1"> </div>
<i class="fa fa-file-text-o me-1"/>Instructions <div style="white-space:pre-wrap;font-size:0.9rem;"><t t-out="task.description"/></div>
</div>
</t>
<!-- ===== ORDER DETAILS (Sale Order or Purchase Order) ===== -->
<t t-if="linked_order">
<div class="tech-card tech-order-card mb-3">
<div class="d-flex align-items-center justify-content-between mb-2">
<div class="d-flex align-items-center">
<div t-attf-class="tech-card-icon #{linked_order_type == 'sale' and 'bg-info-subtle text-info' or 'bg-purple-subtle text-purple'}">
<i t-attf-class="fa #{linked_order_type == 'sale' and 'fa-shopping-cart' or 'fa-truck'}"/>
</div> </div>
<div style="white-space:pre-wrap;font-size:0.9rem;"><t t-out="task.description"/></div> <div>
<div class="text-muted small text-uppercase fw-semibold" style="font-size:0.68rem;letter-spacing:0.05em;">
<t t-if="linked_order_type == 'sale'">Sale Order</t>
<t t-else="">Purchase Order</t>
</div>
<div class="fw-bold" style="font-size:0.95rem;"><t t-out="linked_order.name"/></div>
</div>
</div>
<t t-if="linked_order_type == 'sale'">
<a t-attf-href="/my/orders/#{linked_order.id}" class="btn btn-sm btn-outline-secondary rounded-pill"
style="font-size:0.75rem;">
<i class="fa fa-external-link me-1"/>View
</a>
</t>
</div>
<!-- Order Lines: Products & Services -->
<t t-if="order_lines">
<div class="tech-order-lines">
<div class="text-muted small text-uppercase fw-semibold mb-2" style="font-size:0.68rem;letter-spacing:0.05em;">
<i class="fa fa-cube me-1"/>Items (<t t-out="len(order_lines)"/>)
</div>
<t t-foreach="order_lines" t-as="line">
<div t-attf-class="tech-order-line-item #{not line_last and 'tech-order-line-border' or ''}">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1 me-2">
<div class="fw-semibold" style="font-size:0.88rem;">
<t t-out="line.product_id.name"/>
</div>
<!-- Product description (differs from product name) -->
<t t-if="linked_order_type == 'sale'">
<t t-set="line_desc" t-value="line.name or ''"/>
</t>
<t t-else="">
<t t-set="line_desc" t-value="line.name or ''"/>
</t>
<t t-if="line_desc and line_desc != line.product_id.name">
<div class="text-muted" style="font-size:0.8rem;white-space:pre-wrap;line-height:1.35;">
<t t-out="line_desc"/>
</div>
</t>
<!-- Serial number if available -->
<t t-if="linked_order_type == 'sale' and line.sudo()._fields.get('x_fc_serial_number') and line.x_fc_serial_number">
<div class="mt-1">
<span class="badge text-bg-light border" style="font-size:0.72rem;">
<i class="fa fa-barcode me-1"/>S/N: <t t-out="line.x_fc_serial_number"/>
</span>
</div>
</t>
</div>
<div class="text-end flex-shrink-0">
<span class="tech-qty-badge">
<t t-if="linked_order_type == 'sale'">
<t t-out="int(line.product_uom_qty)"/>
</t>
<t t-else="">
<t t-out="int(line.product_qty)"/>
</t>
</span>
<div class="text-muted" style="font-size:0.68rem;">qty</div>
</div>
</div>
</div>
</t>
</div> </div>
</t> </t>
<t t-if="task.equipment_needed"> <t t-if="not order_lines">
<div class="tech-equipment-tag"> <div class="text-muted small text-center py-2">
<div class="text-muted small text-uppercase fw-semibold mb-1"> <i class="fa fa-info-circle me-1"/>No line items on this order
<i class="fa fa-wrench me-1"/>Equipment Needed
</div>
<div style="white-space:pre-wrap;font-size:0.9rem;"><t t-out="task.equipment_needed"/></div>
</div> </div>
</t> </t>
</div> </div>
</t> </t>
<!-- ===== PRODUCTS / ITEMS ===== --> <!-- ===== EQUIPMENT NEEDED ===== -->
<t t-if="order_lines"> <t t-if="task.equipment_needed">
<div class="tech-card mb-3"> <div class="tech-card mb-3">
<div class="text-muted small text-uppercase fw-semibold mb-2"> <div class="tech-equipment-tag">
<i class="fa fa-cube me-1"/>Products <div class="text-muted small text-uppercase fw-semibold mb-1">
</div> <i class="fa fa-wrench me-1"/>Equipment Needed
<t t-foreach="order_lines" t-as="line">
<div class="d-flex justify-content-between align-items-center py-2"
t-attf-style="#{not line_last and 'border-bottom:1px solid var(--o-main-border-color, #eee);' or ''}">
<div>
<div class="fw-medium" style="font-size:0.9rem;"><t t-out="line.product_id.name"/></div>
<t t-if="line.sudo()._fields.get('x_fc_serial_number') and line.x_fc_serial_number">
<div class="text-muted small">S/N: <t t-out="line.x_fc_serial_number"/></div>
</t>
</div>
<span class="badge text-bg-secondary rounded-pill">x<t t-out="int(line.product_uom_qty)"/></span>
</div> </div>
</t> <div style="white-space:pre-wrap;font-size:0.9rem;"><t t-out="task.equipment_needed"/></div>
</div>
</div> </div>
</t> </t>

View File

@@ -189,23 +189,6 @@
</div> </div>
</t> </t>
<!-- My Schedule (All portal roles) -->
<div class="col-md-6">
<a href="/my/schedule" class="card h-100 border-0 shadow-sm text-decoration-none" style="border-radius: 12px; min-height: 100px;">
<div class="card-body d-flex align-items-center p-4">
<div class="me-3">
<div class="rounded-circle d-flex align-items-center justify-content-center" t-attf-style="width: 50px; height: 50px; background: {{fc_gradient}};">
<i class="fa fa-calendar-check-o fa-lg text-white"/>
</div>
</div>
<div>
<h5 class="mb-1 text-dark">My Schedule</h5>
<small class="text-muted">View and book appointments</small>
</div>
</div>
</a>
</div>
<!-- Clock In/Out --> <!-- Clock In/Out -->
<t t-if="clock_enabled"> <t t-if="clock_enabled">
<div class="col-md-6"> <div class="col-md-6">

View File

@@ -196,29 +196,40 @@
.fce-toggle-btn { .fce-toggle-btn {
position: absolute; position: absolute;
top: 8px; top: 50%;
left: -14px; left: -11px;
width: 28px; transform: translateY(-50%);
height: 28px;
border-radius: 50%;
border: 1px solid var(--o-border-color, #dee2e6);
background: var(--o-bg-card, #fff);
color: var(--o-text-muted, #6c757d);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
z-index: 20; z-index: 20;
opacity: 0; width: 22px;
transition: opacity 0.2s ease, background-color 0.15s ease, color 0.15s ease; height: 22px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); border: none;
font-size: 12px; border-radius: 50%;
background: rgba(0, 0, 0, 0.06);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
color: var(--o-text-muted, #8b95a2);
font-size: 10px;
padding: 0; padding: 0;
opacity: 0;
transition: opacity 0.25s ease,
transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1),
background 0.2s ease,
color 0.2s ease,
box-shadow 0.2s ease;
&:hover { &:hover {
background: var(--o-action, #0077b6); background: var(--o-action, #714b67);
color: #fff; color: #fff;
border-color: var(--o-action, #0077b6); transform: translateY(-50%) scale(1.15);
box-shadow: 0 2px 10px rgba(113, 75, 103, 0.35);
}
&:active {
transform: translateY(-50%) scale(0.9);
} }
} }
@@ -229,8 +240,8 @@
} }
.fce-expand-btn { .fce-expand-btn {
position: fixed; position: absolute;
right: 8px; right: 0;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
width: 28px; width: 28px;
@@ -269,6 +280,37 @@
padding: 4px 8px !important; padding: 4px 8px !important;
} }
// =============================================================================
// MESSAGE ACTIONS: HOVER-ONLY
// Hide the per-message action icons (react, star, edit, menu) by default.
// Show them as a compact floating bar on message hover.
// =============================================================================
.o-mail-Message {
position: relative;
.o-mail-Message-actions {
opacity: 0;
pointer-events: none;
position: absolute !important;
top: 2px;
right: 4px;
z-index: 5;
background: var(--o-bg-card, #fff);
border: 1px solid var(--o-border-color, #dee2e6);
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
padding: 2px 4px;
gap: 2px;
transition: opacity 0.15s ease;
}
&:hover .o-mail-Message-actions {
opacity: 1;
pointer-events: auto;
}
}
// ============================================================================= // =============================================================================
// ICON-ONLY TOPBAR BUTTONS // ICON-ONLY TOPBAR BUTTONS
// ============================================================================= // =============================================================================

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Claims', 'name': 'Fusion Claims',
'version': '19.0.7.3.0', 'version': '19.0.8.0.0',
'category': 'Sales', 'category': 'Sales',
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.', 'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
'description': """ 'description': """
@@ -97,7 +97,6 @@
'data/mail_activity_type_data.xml', 'data/mail_activity_type_data.xml',
'data/ir_cron_data.xml', 'data/ir_cron_data.xml',
'data/ir_actions_server_data.xml', 'data/ir_actions_server_data.xml',
'data/stock_location_data.xml',
'data/product_labor_data.xml', 'data/product_labor_data.xml',
'wizard/status_change_reason_wizard_views.xml', 'wizard/status_change_reason_wizard_views.xml',
'views/res_company_views.xml', 'views/res_company_views.xml',
@@ -129,6 +128,7 @@
'wizard/odsp_pre_approved_wizard_views.xml', 'wizard/odsp_pre_approved_wizard_views.xml',
'wizard/odsp_ready_delivery_wizard_views.xml', 'wizard/odsp_ready_delivery_wizard_views.xml',
'wizard/send_page11_wizard_views.xml', 'wizard/send_page11_wizard_views.xml',
'wizard/adp_import_wizard_views.xml',
'views/res_partner_views.xml', 'views/res_partner_views.xml',
'views/pdf_template_inherit_views.xml', 'views/pdf_template_inherit_views.xml',
'views/dashboard_views.xml', 'views/dashboard_views.xml',
@@ -138,7 +138,6 @@
'views/adp_claims_views.xml', 'views/adp_claims_views.xml',
'views/submission_history_views.xml', 'views/submission_history_views.xml',
'views/product_template_adp_views.xml', 'views/product_template_adp_views.xml',
'views/fusion_loaner_views.xml',
'views/page11_sign_request_views.xml', 'views/page11_sign_request_views.xml',
'views/technician_task_views.xml', 'views/technician_task_views.xml',
'report/report_actions.xml', 'report/report_actions.xml',

View File

@@ -1,41 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2024-2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Part of the Fusion Claim Assistant product family.
-->
<odoo> <odoo>
<data noupdate="1"> <data noupdate="1">
<!-- Loaner Stock Location -->
<record id="stock_location_loaner" model="stock.location">
<field name="name">Loaner Stock</field>
<field name="usage">internal</field>
<field name="location_id" ref="stock.stock_location_stock"/>
</record>
<!-- Sequence for Loaner Checkout -->
<record id="seq_loaner_checkout" model="ir.sequence">
<field name="name">Loaner Checkout Sequence</field>
<field name="code">fusion.loaner.checkout</field>
<field name="prefix">LOAN/</field>
<field name="padding">5</field>
<field name="company_id" eval="False"/>
</record>
<!-- Loaner Product Categories -->
<record id="product_category_loaner" model="product.category">
<field name="name">Loaner Equipment</field>
</record>
<record id="product_category_loaner_rollator" model="product.category">
<field name="name">Rollators</field>
<field name="parent_id" ref="product_category_loaner"/>
</record>
<record id="product_category_loaner_wheelchair" model="product.category">
<field name="name">Wheelchairs</field>
<field name="parent_id" ref="product_category_loaner"/>
</record>
<record id="product_category_loaner_powerchair" model="product.category">
<field name="name">Powerchairs</field>
<field name="parent_id" ref="product_category_loaner"/>
</record>
</data> </data>
</odoo> </odoo>

View File

@@ -18,8 +18,6 @@ from . import account_move_line
from . import account_payment from . import account_payment
from . import account_payment_method_line from . import account_payment_method_line
from . import submission_history from . import submission_history
from . import fusion_loaner_checkout
from . import fusion_loaner_history
from . import client_profile from . import client_profile
from . import adp_application_data from . import adp_application_data
from . import xml_parser from . import xml_parser

View File

@@ -8,7 +8,7 @@ import re
import base64 import base64
import logging import logging
import zipfile import zipfile
from datetime import date, datetime from datetime import date, datetime, timedelta
from odoo import models, fields, api, _ from odoo import models, fields, api, _
from odoo.exceptions import UserError from odoo.exceptions import UserError
@@ -20,7 +20,30 @@ class ADPExportRecord(models.Model):
_name = 'fusion_claims.adp.export.record' _name = 'fusion_claims.adp.export.record'
_description = 'ADP Export File Record' _description = 'ADP Export File Record'
_inherit = ['fusion_claims.adp.posting.schedule.mixin'] _inherit = ['fusion_claims.adp.posting.schedule.mixin']
_order = 'export_date desc, id desc' _order = 'year desc, month asc, posting_period_date desc, export_date desc, id desc'
_GROUPBY_ORDER = {
'year': 'year desc',
'month': 'month asc',
'posting_period_label': 'posting_period_label asc',
'posting_period_date': 'posting_period_date desc',
'user_id': 'user_id asc',
}
@api.model
def _read_group(self, domain, groupby=(), aggregates=(), having=(), offset=0, limit=None, order=None):
if not order and groupby:
parts = []
for field in groupby:
fname = field.split(':')[0] if isinstance(field, str) else field
if fname in self._GROUPBY_ORDER:
parts.append(self._GROUPBY_ORDER[fname])
if parts:
order = ', '.join(parts)
return super()._read_group(
domain, groupby=groupby, aggregates=aggregates,
having=having, offset=offset, limit=limit, order=order,
)
name = fields.Char( name = fields.Char(
string='Filename', string='Filename',
@@ -61,6 +84,12 @@ class ADPExportRecord(models.Model):
store=True, store=True,
help='Numeric month for proper ordering', help='Numeric month for proper ordering',
) )
payment_date = fields.Date(
string='Payment Date',
compute='_compute_period_fields',
store=True,
help='Expected payment date (posting day + 10 calendar days = Monday)',
)
file_data = fields.Binary( file_data = fields.Binary(
string='Export File', string='Export File',
@@ -100,6 +129,9 @@ class ADPExportRecord(models.Model):
default=lambda self: self.env.company, default=lambda self: self.env.company,
index=True, index=True,
) )
file_preview = fields.Text(
string='File Content',
)
notes = fields.Text( notes = fields.Text(
string='Notes', string='Notes',
) )
@@ -110,20 +142,93 @@ class ADPExportRecord(models.Model):
ppd = record.posting_period_date ppd = record.posting_period_date
if ppd: if ppd:
record.year = str(ppd.year) record.year = str(ppd.year)
record.month = ppd.strftime('%B') record.month = '%02d - %s' % (ppd.month, ppd.strftime('%B'))
record.month_number = ppd.month record.month_number = ppd.month
record.posting_period_label = ppd.strftime('%b %d, %Y') record.posting_period_label = 'Posting Schedule - %s' % ppd.strftime('%d/%m/%Y')
record.payment_date = ppd + timedelta(days=10)
else: else:
record.year = '' record.year = ''
record.month = '' record.month = ''
record.month_number = 0 record.month_number = 0
record.posting_period_label = '' record.posting_period_label = ''
record.payment_date = False
@api.depends('invoice_ids') @api.depends('invoice_ids')
def _compute_invoice_count(self): def _compute_invoice_count(self):
for record in self: for record in self:
record.invoice_count = len(record.invoice_ids) record.invoice_count = len(record.invoice_ids)
@api.model_create_multi
def create(self, vals_list):
records = super().create(vals_list)
for record in records:
record._process_file_data()
return records
def _process_file_data(self):
"""Extract preview text and auto-link invoices from file content."""
self.ensure_one()
if not self.file_data:
return
try:
raw = base64.b64decode(self.file_data)
text = raw.decode('utf-8', errors='replace')
except Exception:
return
vals = {'file_preview': text}
lines = text.strip().splitlines()
if lines and not self.line_count:
vals['line_count'] = len(lines)
if not self.invoice_ids:
invoice_numbers = set()
for line in lines:
parts = line.split(',')
if len(parts) > 3 and parts[3].strip():
invoice_numbers.add(parts[3].strip())
if invoice_numbers:
invoices = self.env['account.move'].search([
('name', 'in', list(invoice_numbers)),
])
if invoices:
vals['invoice_ids'] = [(6, 0, invoices.ids)]
self.write(vals)
@api.model
def _get_posting_period_for_file(self, file_date):
"""Determine which posting period a file belongs to.
If the file was created/exported ON a posting date, it belongs to that
period. If it was created AFTER the posting date (even one day later),
it falls into the NEXT posting period because that posting has already
been submitted.
Handles dates before the configured base date correctly using Python's
floor division for negative offsets.
"""
if file_date is None:
file_date = date.today()
elif hasattr(file_date, 'date'):
file_date = file_date.date()
base_date = self._get_adp_posting_base_date()
frequency = self._get_adp_posting_frequency()
if frequency <= 0:
frequency = 14
days_diff = (file_date - base_date).days
cycles_passed = days_diff // frequency
current_posting = base_date + timedelta(days=cycles_passed * frequency)
if file_date <= current_posting:
return current_posting
return current_posting + timedelta(days=frequency)
def action_download(self): def action_download(self):
"""Download the export file.""" """Download the export file."""
self.ensure_one() self.ensure_one()
@@ -259,9 +364,9 @@ class ADPExportRecord(models.Model):
try: try:
vendor_code, file_date = self._parse_export_filename(doc.name) vendor_code, file_date = self._parse_export_filename(doc.name)
if file_date: if file_date:
posting_date = self._get_current_posting_date(file_date) posting_date = self._get_posting_period_for_file(file_date)
else: else:
posting_date = self._get_current_posting_date( posting_date = self._get_posting_period_for_file(
doc.create_date.date() if doc.create_date else date.today() doc.create_date.date() if doc.create_date else date.today()
) )

View File

@@ -65,86 +65,6 @@ class ProductTemplate(models.Model):
readonly=True, readonly=True,
) )
# ==========================================================================
# LOANER PRODUCT FIELDS
# ==========================================================================
x_fc_can_be_loaned = fields.Boolean(
string='Can be Loaned',
default=False,
help='If checked, this product can be loaned out to clients',
)
x_fc_loaner_period_days = fields.Integer(
string='Loaner Period (Days)',
default=7,
help='Default number of free loaner days before rental conversion',
)
x_fc_rental_price_weekly = fields.Float(
string='Weekly Rental Price',
digits='Product Price',
help='Rental price per week if loaner converts to rental',
)
x_fc_rental_price_monthly = fields.Float(
string='Monthly Rental Price',
digits='Product Price',
help='Rental price per month if loaner converts to rental',
)
# ==========================================================================
# LOANER EQUIPMENT FIELDS
# ==========================================================================
x_fc_equipment_type = fields.Selection([
('type_1_walker', 'Type 1 Walker'),
('type_2_mw', 'Type 2 MW'),
('type_2_pw', 'Type 2 PW'),
('type_2_walker', 'Type 2 Walker'),
('type_3_mw', 'Type 3 MW'),
('type_3_pw', 'Type 3 PW'),
('type_3_walker', 'Type 3 Walker'),
('type_4_mw', 'Type 4 MW'),
('type_5_mw', 'Type 5 MW'),
('ceiling_lift', 'Ceiling Lift'),
('mobility_scooter', 'Mobility Scooter'),
('patient_lift', 'Patient Lift'),
('transport_wheelchair', 'Transport Wheelchair'),
('standard_wheelchair', 'Standard Wheelchair'),
('power_wheelchair', 'Power Wheelchair'),
('cushion', 'Cushion'),
('backrest', 'Backrest'),
('stairlift', 'Stairlift'),
('others', 'Others'),
], string='Equipment Type')
x_fc_wheelchair_category = fields.Selection([
('type_1', 'Type 1'),
('type_2', 'Type 2'),
('type_3', 'Type 3'),
('type_4', 'Type 4'),
('type_5', 'Type 5'),
], string='Wheelchair Category')
x_fc_seat_width = fields.Char(string='Seat Width')
x_fc_seat_depth = fields.Char(string='Seat Depth')
x_fc_seat_height = fields.Char(string='Seat Height')
x_fc_storage_location = fields.Selection([
('warehouse', 'Warehouse'),
('westin_brampton', 'Westin Brampton'),
('mobility_etobicoke', 'Mobility Etobicoke'),
('scarborough_storage', 'Scarborough Storage'),
('client_loaned', 'Client/Loaned'),
('rented_out', 'Rented Out'),
], string='Storage Location')
x_fc_listing_type = fields.Selection([
('owned', 'Owned'),
('borrowed', 'Borrowed'),
], string='Listing Type')
x_fc_asset_number = fields.Char(string='Asset Number')
x_fc_package_info = fields.Text(string='Package Information')
# ========================================================================== # ==========================================================================
# ONCHANGE / CONSTRAINTS # ONCHANGE / CONSTRAINTS
# ========================================================================== # ==========================================================================

View File

@@ -263,96 +263,6 @@ class SaleOrder(models.Model):
action['res_id'] = self.x_fc_technician_task_ids.id action['res_id'] = self.x_fc_technician_task_ids.id
return action return action
# LOANER EQUIPMENT TRACKING
# ==========================================================================
x_fc_loaner_checkout_ids = fields.One2many(
'fusion.loaner.checkout',
'sale_order_id',
string='Loaner Checkouts',
help='Loaner equipment checked out for this order',
)
x_fc_loaner_count = fields.Integer(
string='Loaners',
compute='_compute_loaner_count',
)
x_fc_active_loaner_count = fields.Integer(
string='Active Loaners',
compute='_compute_loaner_count',
)
x_fc_has_overdue_loaner = fields.Boolean(
string='Has Overdue Loaner',
compute='_compute_loaner_count',
help='True if any active loaner is past its expected return date',
)
@api.depends('x_fc_loaner_checkout_ids', 'x_fc_loaner_checkout_ids.state',
'x_fc_loaner_checkout_ids.expected_return_date')
def _compute_loaner_count(self):
"""Compute loaner counts and overdue status for this order."""
today = fields.Date.today()
for order in self:
active = order.x_fc_loaner_checkout_ids.filtered(
lambda l: l.state in ('checked_out', 'overdue', 'rental_pending')
)
order.x_fc_loaner_count = len(order.x_fc_loaner_checkout_ids)
order.x_fc_active_loaner_count = len(active)
order.x_fc_has_overdue_loaner = any(
l.state == 'overdue' or (l.expected_return_date and l.expected_return_date < today)
for l in active
)
def action_view_loaners(self):
"""Open the loaner checkouts for this order."""
self.ensure_one()
action = {
'name': 'Loaner Checkouts',
'type': 'ir.actions.act_window',
'res_model': 'fusion.loaner.checkout',
'view_mode': 'tree,form',
'domain': [('sale_order_id', '=', self.id)],
'context': {'default_sale_order_id': self.id},
}
if len(self.x_fc_loaner_checkout_ids) == 1:
action['view_mode'] = 'form'
action['res_id'] = self.x_fc_loaner_checkout_ids.id
return action
def action_checkout_loaner(self):
"""Open the loaner checkout wizard."""
self.ensure_one()
return {
'name': 'Checkout Loaner',
'type': 'ir.actions.act_window',
'res_model': 'fusion.loaner.checkout.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_sale_order_id': self.id,
'default_partner_id': self.partner_id.id,
'default_authorizer_id': self.x_fc_authorizer_id.id if self.x_fc_authorizer_id else False,
},
}
def action_checkin_loaner(self):
"""Open the return wizard for the active loaner on this order."""
self.ensure_one()
active_loaners = self.x_fc_loaner_checkout_ids.filtered(
lambda l: l.state in ('checked_out', 'overdue', 'rental_pending')
)
if not active_loaners:
raise UserError("No active loaners to check in for this order.")
if len(active_loaners) == 1:
return active_loaners.action_return()
# Multiple active loaners - show the list so user can pick which one to return
return {
'name': 'Return Loaner',
'type': 'ir.actions.act_window',
'res_model': 'fusion.loaner.checkout',
'view_mode': 'tree,form',
'domain': [('id', 'in', active_loaners.ids)],
'target': 'current',
}
def action_ready_for_delivery(self): def action_ready_for_delivery(self):
"""Open the task scheduling form to schedule a delivery task. """Open the task scheduling form to schedule a delivery task.

View File

@@ -21,12 +21,6 @@ access_fusion_ready_for_submission_wizard,fusion.ready.for.submission.wizard.use
access_fusion_ready_to_bill_wizard,fusion.ready.to.bill.wizard.user,model_fusion_claims_ready_to_bill_wizard,sales_team.group_sale_salesman,1,1,1,1 access_fusion_ready_to_bill_wizard,fusion.ready.to.bill.wizard.user,model_fusion_claims_ready_to_bill_wizard,sales_team.group_sale_salesman,1,1,1,1
access_fusion_submission_history_user,fusion.submission.history.user,model_fusion_submission_history,sales_team.group_sale_salesman,1,1,1,0 access_fusion_submission_history_user,fusion.submission.history.user,model_fusion_submission_history,sales_team.group_sale_salesman,1,1,1,0
access_fusion_submission_history_manager,fusion.submission.history.manager,model_fusion_submission_history,sales_team.group_sale_manager,1,1,1,1 access_fusion_submission_history_manager,fusion.submission.history.manager,model_fusion_submission_history,sales_team.group_sale_manager,1,1,1,1
access_fusion_loaner_checkout_user,fusion.loaner.checkout.user,model_fusion_loaner_checkout,sales_team.group_sale_salesman,1,1,1,0
access_fusion_loaner_checkout_manager,fusion.loaner.checkout.manager,model_fusion_loaner_checkout,sales_team.group_sale_manager,1,1,1,1
access_fusion_loaner_history_user,fusion.loaner.history.user,model_fusion_loaner_history,sales_team.group_sale_salesman,1,0,0,0
access_fusion_loaner_history_manager,fusion.loaner.history.manager,model_fusion_loaner_history,sales_team.group_sale_manager,1,1,1,1
access_fusion_loaner_checkout_wizard,fusion.loaner.checkout.wizard.user,model_fusion_loaner_checkout_wizard,sales_team.group_sale_salesman,1,1,1,1
access_fusion_loaner_return_wizard,fusion.loaner.return.wizard.user,model_fusion_loaner_return_wizard,sales_team.group_sale_salesman,1,1,1,1
access_fusion_ready_for_delivery_wizard,fusion.ready.for.delivery.wizard.user,model_fusion_ready_for_delivery_wizard,sales_team.group_sale_salesman,1,1,1,1 access_fusion_ready_for_delivery_wizard,fusion.ready.for.delivery.wizard.user,model_fusion_ready_for_delivery_wizard,sales_team.group_sale_salesman,1,1,1,1
access_fusion_client_profile_user,fusion.client.profile.user,model_fusion_client_profile,sales_team.group_sale_salesman,1,1,1,0 access_fusion_client_profile_user,fusion.client.profile.user,model_fusion_client_profile,sales_team.group_sale_salesman,1,1,1,0
access_fusion_client_profile_manager,fusion.client.profile.manager,model_fusion_client_profile,sales_team.group_sale_manager,1,1,1,1 access_fusion_client_profile_manager,fusion.client.profile.manager,model_fusion_client_profile,sales_team.group_sale_manager,1,1,1,1
@@ -68,4 +62,5 @@ access_fusion_page11_sign_request_user,fusion.page11.sign.request.user,model_fus
access_fusion_page11_sign_request_manager,fusion.page11.sign.request.manager,model_fusion_page11_sign_request,sales_team.group_sale_manager,1,1,1,1 access_fusion_page11_sign_request_manager,fusion.page11.sign.request.manager,model_fusion_page11_sign_request,sales_team.group_sale_manager,1,1,1,1
access_fusion_page11_sign_request_public,fusion.page11.sign.request.public,model_fusion_page11_sign_request,base.group_public,1,0,0,0 access_fusion_page11_sign_request_public,fusion.page11.sign.request.public,model_fusion_page11_sign_request,base.group_public,1,0,0,0
access_fusion_send_page11_wizard_user,fusion_claims.send.page11.wizard.user,model_fusion_claims_send_page11_wizard,sales_team.group_sale_salesman,1,1,1,1 access_fusion_send_page11_wizard_user,fusion_claims.send.page11.wizard.user,model_fusion_claims_send_page11_wizard,sales_team.group_sale_salesman,1,1,1,1
access_fusion_send_page11_wizard_manager,fusion_claims.send.page11.wizard.manager,model_fusion_claims_send_page11_wizard,sales_team.group_sale_manager,1,1,1,1 access_fusion_send_page11_wizard_manager,fusion_claims.send.page11.wizard.manager,model_fusion_claims_send_page11_wizard,sales_team.group_sale_manager,1,1,1,1
access_fusion_adp_import_wizard_user,fusion_claims.adp.import.wizard.user,model_fusion_claims_adp_import_wizard,account.group_account_invoice,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
21 access_fusion_ready_to_bill_wizard fusion.ready.to.bill.wizard.user model_fusion_claims_ready_to_bill_wizard sales_team.group_sale_salesman 1 1 1 1
22 access_fusion_submission_history_user fusion.submission.history.user model_fusion_submission_history sales_team.group_sale_salesman 1 1 1 0
23 access_fusion_submission_history_manager fusion.submission.history.manager model_fusion_submission_history sales_team.group_sale_manager 1 1 1 1
access_fusion_loaner_checkout_user fusion.loaner.checkout.user model_fusion_loaner_checkout sales_team.group_sale_salesman 1 1 1 0
access_fusion_loaner_checkout_manager fusion.loaner.checkout.manager model_fusion_loaner_checkout sales_team.group_sale_manager 1 1 1 1
access_fusion_loaner_history_user fusion.loaner.history.user model_fusion_loaner_history sales_team.group_sale_salesman 1 0 0 0
access_fusion_loaner_history_manager fusion.loaner.history.manager model_fusion_loaner_history sales_team.group_sale_manager 1 1 1 1
access_fusion_loaner_checkout_wizard fusion.loaner.checkout.wizard.user model_fusion_loaner_checkout_wizard sales_team.group_sale_salesman 1 1 1 1
access_fusion_loaner_return_wizard fusion.loaner.return.wizard.user model_fusion_loaner_return_wizard sales_team.group_sale_salesman 1 1 1 1
24 access_fusion_ready_for_delivery_wizard fusion.ready.for.delivery.wizard.user model_fusion_ready_for_delivery_wizard sales_team.group_sale_salesman 1 1 1 1
25 access_fusion_client_profile_user fusion.client.profile.user model_fusion_client_profile sales_team.group_sale_salesman 1 1 1 0
26 access_fusion_client_profile_manager fusion.client.profile.manager model_fusion_client_profile sales_team.group_sale_manager 1 1 1 1
62 access_fusion_page11_sign_request_manager fusion.page11.sign.request.manager model_fusion_page11_sign_request sales_team.group_sale_manager 1 1 1 1
63 access_fusion_page11_sign_request_public fusion.page11.sign.request.public model_fusion_page11_sign_request base.group_public 1 0 0 0
64 access_fusion_send_page11_wizard_user fusion_claims.send.page11.wizard.user model_fusion_claims_send_page11_wizard sales_team.group_sale_salesman 1 1 1 1
65 access_fusion_send_page11_wizard_manager fusion_claims.send.page11.wizard.manager model_fusion_claims_send_page11_wizard sales_team.group_sale_manager 1 1 1 1
66 access_fusion_adp_import_wizard_user fusion_claims.adp.import.wizard.user model_fusion_claims_adp_import_wizard account.group_account_invoice 1 1 1 1

View File

@@ -756,4 +756,16 @@ html.dark, .o_dark {
} }
} }
.adp_file_preview {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', monospace;
font-size: 12px;
line-height: 1.6;
white-space: pre;
overflow-x: auto;
background: #f8f9fa;
border: 1px solid #dee2e6;
border-radius: 4px;
padding: 12px;
max-height: 500px;
overflow-y: auto;
}

View File

@@ -220,8 +220,7 @@
<field name="x_fc_client_portion_total" widget="monetary" sum="Total Client" optional="show"/> <field name="x_fc_client_portion_total" widget="monetary" sum="Total Client" optional="show"/>
<field name="amount_total" widget="monetary" sum="Grand Total" optional="show"/> <field name="amount_total" widget="monetary" sum="Grand Total" optional="show"/>
<!-- Loaner / Status --> <!-- Status -->
<field name="x_fc_loaner_count" string="Loaners" optional="hide"/>
<field name="x_fc_on_hold_date" optional="hide"/> <field name="x_fc_on_hold_date" optional="hide"/>
<field name="x_fc_case_locked" optional="hide"/> <field name="x_fc_case_locked" optional="hide"/>
<field name="state" widget="badge" decoration-success="state == 'sale'" <field name="state" widget="badge" decoration-success="state == 'sale'"
@@ -1733,105 +1732,73 @@ else:
parent="menu_adp_claims_root" parent="menu_adp_claims_root"
sequence="20"/> sequence="20"/>
<!-- Top-level quick access -->
<menuitem id="menu_adp_all_orders" name="All ADP Orders" parent="menu_fc_adp" <menuitem id="menu_adp_all_orders" name="All ADP Orders" parent="menu_fc_adp"
action="action_adp_orders_all" sequence="1"/> action="action_adp_orders_all" sequence="1"/>
<menuitem id="menu_adp_invoices" name="ADP Invoices" parent="menu_fc_adp"
action="action_adp_invoices" sequence="2"/>
<menuitem id="menu_adp_client_invoices" name="ADP Client Invoices" parent="menu_fc_adp"
action="action_adp_client_invoices" sequence="3"/>
<menuitem id="menu_adp_export_files" name="Export Files" parent="menu_fc_adp"
action="action_adp_export_records" sequence="4"/>
<menuitem id="menu_adp_quotations" <!-- Orders & Billing -->
name="Quotation Stage" <menuitem id="menu_adp_orders_billing"
name="Orders &amp; Billing"
parent="menu_fc_adp"
sequence="5"/>
<menuitem id="menu_adp_invoices" name="ADP Invoices" parent="menu_adp_orders_billing"
action="action_adp_invoices" sequence="1"/>
<menuitem id="menu_adp_client_invoices" name="Client Invoices" parent="menu_adp_orders_billing"
action="action_adp_client_invoices" sequence="2"/>
<menuitem id="menu_adp_ready_billing" name="Ready for Billing" parent="menu_adp_orders_billing"
action="action_adp_ready_billing" sequence="3"/>
<menuitem id="menu_adp_billed" name="Billed to ADP" parent="menu_adp_orders_billing"
action="action_adp_billed" sequence="4"/>
<menuitem id="menu_adp_export_files" name="Claim Submission Files" parent="menu_adp_orders_billing"
action="action_adp_export_records" sequence="5"/>
<!-- Pre-Submission Pipeline -->
<menuitem id="menu_adp_pre_submission"
name="Pre-Submission"
parent="menu_fc_adp" parent="menu_fc_adp"
action="action_adp_quotations"
sequence="10"/> sequence="10"/>
<menuitem id="menu_adp_quotations" name="Quotation Stage" parent="menu_adp_pre_submission"
action="action_adp_quotations" sequence="1"/>
<menuitem id="menu_adp_assessment_scheduled" name="Assessment Scheduled" parent="menu_adp_pre_submission"
action="action_adp_assessment_scheduled" sequence="2"/>
<menuitem id="menu_adp_assessment_completed" name="Waiting for Application" parent="menu_adp_pre_submission"
action="action_adp_assessment_completed" sequence="3"/>
<menuitem id="menu_adp_application_received" name="Application Received" parent="menu_adp_pre_submission"
action="action_adp_application_received" sequence="4"/>
<menuitem id="menu_adp_ready_submission" name="Ready for Submission" parent="menu_adp_pre_submission"
action="action_adp_ready_submission" sequence="5"/>
<menuitem id="menu_adp_assessment_scheduled" <!-- ADP Review -->
name="Assessment Scheduled" <menuitem id="menu_adp_review"
name="ADP Review"
parent="menu_fc_adp" parent="menu_fc_adp"
action="action_adp_assessment_scheduled"
sequence="12"/>
<menuitem id="menu_adp_assessment_completed"
name="Waiting for Application"
parent="menu_fc_adp"
action="action_adp_assessment_completed"
sequence="14"/>
<menuitem id="menu_adp_application_received"
name="Application Received"
parent="menu_fc_adp"
action="action_adp_application_received"
sequence="16"/>
<menuitem id="menu_adp_ready_submission"
name="Ready for Submission"
parent="menu_fc_adp"
action="action_adp_ready_submission"
sequence="18"/>
<menuitem id="menu_adp_pending_approval"
name="Application Submitted"
parent="menu_fc_adp"
action="action_adp_pending_approval"
sequence="20"/> sequence="20"/>
<menuitem id="menu_adp_pending_approval" name="Application Submitted" parent="menu_adp_review"
action="action_adp_pending_approval" sequence="1"/>
<menuitem id="menu_adp_accepted" name="Accepted by ADP" parent="menu_adp_review"
action="action_adp_accepted" sequence="2"/>
<menuitem id="menu_adp_approved" name="Application Approved" parent="menu_adp_review"
action="action_adp_approved" sequence="3"/>
<menuitem id="menu_adp_rejected" name="Rejected by ADP" parent="menu_adp_review"
action="action_adp_rejected" sequence="4"/>
<menuitem id="menu_adp_needs_correction" name="Needs Correction" parent="menu_adp_review"
action="action_adp_needs_correction" sequence="5"/>
<menuitem id="menu_adp_accepted" <!-- Fulfillment -->
name="Accepted by ADP" <menuitem id="menu_adp_fulfillment"
name="Fulfillment"
parent="menu_fc_adp" parent="menu_fc_adp"
action="action_adp_accepted"
sequence="21"/>
<menuitem id="menu_adp_rejected"
name="Rejected by ADP"
parent="menu_fc_adp"
action="action_adp_rejected"
sequence="22"/>
<menuitem id="menu_adp_needs_correction"
name="Needs Correction"
parent="menu_fc_adp"
action="action_adp_needs_correction"
sequence="23"/>
<menuitem id="menu_adp_approved"
name="Application Approved"
parent="menu_fc_adp"
action="action_adp_approved"
sequence="25"/>
<menuitem id="menu_adp_ready_delivery"
name="Ready for Delivery"
parent="menu_fc_adp"
action="action_adp_ready_delivery"
sequence="27"/>
<menuitem id="menu_adp_ready_billing"
name="Ready for Billing"
parent="menu_fc_adp"
action="action_adp_ready_billing"
sequence="30"/> sequence="30"/>
<menuitem id="menu_adp_ready_delivery" name="Ready for Delivery" parent="menu_adp_fulfillment"
action="action_adp_ready_delivery" sequence="1"/>
<menuitem id="menu_adp_closed" name="Case Closed" parent="menu_adp_fulfillment"
action="action_adp_closed" sequence="2"/>
<menuitem id="menu_adp_billed" <!-- Special Statuses -->
name="Billed to ADP"
parent="menu_fc_adp"
action="action_adp_billed"
sequence="35"/>
<menuitem id="menu_adp_closed"
name="Case Closed"
parent="menu_fc_adp"
action="action_adp_closed"
sequence="40"/>
<!-- ADP Special Statuses -->
<menuitem id="menu_adp_special_statuses" <menuitem id="menu_adp_special_statuses"
name="Special Statuses" name="Special Statuses"
parent="menu_fc_adp" parent="menu_fc_adp"
sequence="50"/> sequence="50"/>
<menuitem id="menu_adp_on_hold" name="On Hold" parent="menu_adp_special_statuses" <menuitem id="menu_adp_on_hold" name="On Hold" parent="menu_adp_special_statuses"
action="action_adp_on_hold" sequence="10"/> action="action_adp_on_hold" sequence="10"/>
<menuitem id="menu_adp_withdrawn" name="Withdrawn" parent="menu_adp_special_statuses" <menuitem id="menu_adp_withdrawn" name="Withdrawn" parent="menu_adp_special_statuses"
@@ -2037,6 +2004,8 @@ else:
action="action_device_import_wizard" sequence="20"/> action="action_device_import_wizard" sequence="20"/>
<menuitem id="menu_import_xml_files" name="Import XML Files" parent="menu_adp_config" <menuitem id="menu_import_xml_files" name="Import XML Files" parent="menu_adp_config"
action="action_xml_import_wizard" sequence="30"/> action="action_xml_import_wizard" sequence="30"/>
<menuitem id="menu_adp_import_files" name="Import Submission Files" parent="menu_adp_config"
action="action_adp_import_wizard" sequence="35"/>
<menuitem id="menu_fusion_claims_settings" name="Settings" parent="menu_adp_config" <menuitem id="menu_fusion_claims_settings" name="Settings" parent="menu_adp_config"
action="action_fusion_claims_settings" sequence="90"/> action="action_fusion_claims_settings" sequence="90"/>

View File

@@ -12,10 +12,12 @@
<field name="name">fusion_claims.adp.export.record.list</field> <field name="name">fusion_claims.adp.export.record.list</field>
<field name="model">fusion_claims.adp.export.record</field> <field name="model">fusion_claims.adp.export.record</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<list string="ADP Export Files" default_order="export_date desc"> <list string="ADP Claim Submission Files"
default_order="year desc, month asc, posting_period_date desc, export_date desc">
<field name="name"/> <field name="name"/>
<field name="export_date"/> <field name="export_date"/>
<field name="posting_period_label" string="Posting Period"/> <field name="posting_period_label" string="Posting Period"/>
<field name="payment_date" string="Payment Date"/>
<field name="vendor_code"/> <field name="vendor_code"/>
<field name="line_count" string="Lines"/> <field name="line_count" string="Lines"/>
<field name="invoice_count" string="Invoices"/> <field name="invoice_count" string="Invoices"/>
@@ -27,6 +29,44 @@
</field> </field>
</record> </record>
<!-- ===================================================================== -->
<!-- ADP EXPORT RECORD: Kanban View -->
<!-- ===================================================================== -->
<record id="view_adp_export_record_kanban" model="ir.ui.view">
<field name="name">fusion_claims.adp.export.record.kanban</field>
<field name="model">fusion_claims.adp.export.record</field>
<field name="arch" type="xml">
<kanban class="o_kanban_mobile" default_order="year desc, month asc, posting_period_date desc, export_date desc">
<templates>
<t t-name="card">
<div class="d-flex justify-content-between mb-1">
<strong><field name="name"/></strong>
<field name="vendor_code" class="text-muted"/>
</div>
<div class="text-muted small mb-1">
<field name="posting_period_label"/>
</div>
<div class="d-flex justify-content-between align-items-center">
<div class="small">
<span class="me-3"><i class="fa fa-list-ol me-1"/>
<field name="line_count"/> lines</span>
<span><i class="fa fa-file-text-o me-1"/>
<field name="invoice_count"/> invoices</span>
</div>
<field name="user_id" widget="many2one_avatar_user"/>
</div>
<div class="text-muted small mt-1">
<i class="fa fa-calendar me-1"/><field name="export_date"/>
</div>
<field name="year" invisible="1"/>
<field name="month" invisible="1"/>
<field name="month_number" invisible="1"/>
</t>
</templates>
</kanban>
</field>
</record>
<!-- ===================================================================== --> <!-- ===================================================================== -->
<!-- ADP EXPORT RECORD: Form View --> <!-- ADP EXPORT RECORD: Form View -->
<!-- ===================================================================== --> <!-- ===================================================================== -->
@@ -34,20 +74,29 @@
<field name="name">fusion_claims.adp.export.record.form</field> <field name="name">fusion_claims.adp.export.record.form</field>
<field name="model">fusion_claims.adp.export.record</field> <field name="model">fusion_claims.adp.export.record</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<form string="ADP Export File"> <form string="ADP Claim Submission File">
<header> <header>
<button name="action_download" string="Download File" <button name="action_download" string="Download File"
type="object" class="btn-primary" icon="fa-download"/> type="object" class="btn-primary" icon="fa-download"/>
</header> </header>
<sheet> <sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_invoices" type="object"
class="oe_stat_button" icon="fa-pencil-square-o"
invisible="invoice_count == 0">
<field name="invoice_count" widget="statinfo"
string="Invoices"/>
</button>
</div>
<div class="oe_title"> <div class="oe_title">
<h1><field name="name" readonly="1"/></h1> <h1><field name="name"/></h1>
</div> </div>
<group> <group>
<group string="Export Details"> <group string="Export Details">
<field name="export_date" readonly="1"/> <field name="export_date" readonly="1"/>
<field name="posting_period_date" readonly="1"/> <field name="posting_period_date" readonly="1"/>
<field name="posting_period_label" readonly="1"/> <field name="posting_period_label" readonly="1"/>
<field name="payment_date" readonly="1"/>
<field name="vendor_code" readonly="1"/> <field name="vendor_code" readonly="1"/>
<field name="line_count" readonly="1"/> <field name="line_count" readonly="1"/>
</group> </group>
@@ -59,13 +108,20 @@
</group> </group>
</group> </group>
<notebook> <notebook>
<page string="File Preview" name="preview"
invisible="not file_preview">
<field name="file_preview" readonly="1" nolabel="1"
widget="text"/>
</page>
<page string="Exported Invoices" name="invoices"> <page string="Exported Invoices" name="invoices">
<field name="invoice_ids" readonly="1" nolabel="1"> <field name="invoice_ids" readonly="1" nolabel="1">
<list string="Invoices" create="0" delete="0"> <list string="Invoices" create="0" delete="0">
<field name="name" string="Invoice"/> <field name="name" string="Invoice"/>
<field name="partner_id" string="Customer"/> <field name="partner_id" string="Customer"
widget="many2one"/>
<field name="invoice_date"/> <field name="invoice_date"/>
<field name="amount_total" string="Total"/> <field name="currency_id" column_invisible="True"/>
<field name="amount_total" string="Total" sum="Grand Total"/>
<field name="state" widget="badge" <field name="state" widget="badge"
decoration-success="state == 'posted'" decoration-success="state == 'posted'"
decoration-info="state == 'draft'"/> decoration-info="state == 'draft'"/>
@@ -88,7 +144,7 @@
<field name="name">fusion_claims.adp.export.record.search</field> <field name="name">fusion_claims.adp.export.record.search</field>
<field name="model">fusion_claims.adp.export.record</field> <field name="model">fusion_claims.adp.export.record</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<search string="ADP Export Files"> <search string="ADP Claim Submission Files">
<field name="name"/> <field name="name"/>
<field name="vendor_code"/> <field name="vendor_code"/>
<field name="user_id"/> <field name="user_id"/>
@@ -98,7 +154,7 @@
<filter string="Month" name="group_month" <filter string="Month" name="group_month"
context="{'group_by': 'month'}"/> context="{'group_by': 'month'}"/>
<filter string="Posting Period" name="group_posting" <filter string="Posting Period" name="group_posting"
context="{'group_by': 'posting_period_date'}"/> context="{'group_by': 'posting_period_label'}"/>
<filter string="Exported By" name="group_user" <filter string="Exported By" name="group_user"
context="{'group_by': 'user_id'}"/> context="{'group_by': 'user_id'}"/>
</search> </search>
@@ -109,17 +165,17 @@
<!-- ADP EXPORT RECORD: Action --> <!-- ADP EXPORT RECORD: Action -->
<!-- ===================================================================== --> <!-- ===================================================================== -->
<record id="action_adp_export_records" model="ir.actions.act_window"> <record id="action_adp_export_records" model="ir.actions.act_window">
<field name="name">Export Files</field> <field name="name">Claim Submission Files</field>
<field name="res_model">fusion_claims.adp.export.record</field> <field name="res_model">fusion_claims.adp.export.record</field>
<field name="view_mode">list,form</field> <field name="view_mode">list,kanban,form</field>
<field name="search_view_id" ref="view_adp_export_record_search"/> <field name="search_view_id" ref="view_adp_export_record_search"/>
<field name="context">{'search_default_group_year': 1, 'search_default_group_month': 1, 'search_default_group_posting': 1}</field> <field name="context">{'search_default_group_year': 1, 'search_default_group_month': 1, 'search_default_group_posting': 1}</field>
<field name="help" type="html"> <field name="help" type="html">
<p class="o_view_nocontent_smiling_face"> <p class="o_view_nocontent_smiling_face">
No export files yet No claim submission files yet
</p> </p>
<p> <p>
ADP export files will appear here after you export invoices using the ADP claim submission files will appear here after you export invoices using the
<strong>Export ADP</strong> button on ADP portion invoices. <strong>Export ADP</strong> button on ADP portion invoices.
</p> </p>
</field> </field>

View File

@@ -1074,13 +1074,6 @@
invisible="not x_fc_is_mod_sale or x_fc_mod_client_invoice_count == 0"> invisible="not x_fc_is_mod_sale or x_fc_mod_client_invoice_count == 0">
<field name="x_fc_mod_client_invoice_count" widget="statinfo" string="Client Invoice"/> <field name="x_fc_mod_client_invoice_count" widget="statinfo" string="Client Invoice"/>
</button> </button>
<!-- Loaner Equipment Button -->
<button name="action_view_loaners" type="object"
class="oe_stat_button" icon="fa-wheelchair"
invisible="x_fc_loaner_count == 0">
<field name="x_fc_loaner_count" widget="statinfo" string="Loaners"/>
</button>
<!-- Technician Tasks Button --> <!-- Technician Tasks Button -->
<button name="action_view_technician_tasks" type="object" <button name="action_view_technician_tasks" type="object"
@@ -1186,7 +1179,6 @@
<field name="x_fc_device_verification_complete" invisible="1"/> <field name="x_fc_device_verification_complete" invisible="1"/>
<field name="x_fc_submission_verified" invisible="1"/> <field name="x_fc_submission_verified" invisible="1"/>
<field name="x_fc_adp_application_status" invisible="1"/> <field name="x_fc_adp_application_status" invisible="1"/>
<field name="x_fc_active_loaner_count" invisible="1"/>
<field name="x_fc_early_delivery" invisible="1"/> <field name="x_fc_early_delivery" invisible="1"/>
<!-- ============================================================ --> <!-- ============================================================ -->
@@ -1340,36 +1332,6 @@
</xpath> </xpath>
<!-- ============================================================ -->
<!-- LOANER BUTTONS - Positioned AFTER all standard Odoo buttons -->
<!-- (after Cancel, before the statusbar widget) -->
<!-- ============================================================ -->
<xpath expr="//header/field[@name='state']" position="before">
<field name="x_fc_is_adp_sale" invisible="1"/>
<field name="x_fc_active_loaner_count" invisible="1"/>
<field name="x_fc_has_overdue_loaner" invisible="1"/>
<!-- Checkout Loaner: only when NO active loaners -->
<button name="action_checkout_loaner" type="object"
string="Checkout Loaner" class="btn-secondary"
icon="fa-wheelchair"
invisible="not x_fc_is_adp_sale or x_fc_active_loaner_count &gt; 0 or x_fc_adp_application_status not in ('assessment_scheduled', 'assessment_completed', 'waiting_for_application', 'application_received', 'ready_submission', 'submitted', 'resubmitted', 'accepted', 'approved', 'approved_deduction', 'ready_delivery')"
help="Checkout loaner equipment for the client"/>
<!-- Checkin Loaner: GREEN when within return period -->
<button name="action_checkin_loaner" type="object"
string="Checkin Loaner" class="fc-btn-status-good"
icon="fa-clock-o"
invisible="not x_fc_is_adp_sale or x_fc_active_loaner_count == 0 or x_fc_has_overdue_loaner"
help="Loaner within return period - click to check in"/>
<!-- Checkin Loaner: RED when past return period -->
<button name="action_checkin_loaner" type="object"
string="Checkin Loaner" class="fc-btn-status-bad"
icon="fa-exclamation-circle"
invisible="not x_fc_is_adp_sale or x_fc_active_loaner_count == 0 or not x_fc_has_overdue_loaner"
help="Loaner OVERDUE - click to check in"/>
</xpath>
</field> </field>
</record> </record>

View File

@@ -17,8 +17,6 @@ from . import application_received_wizard
from . import ready_for_submission_wizard from . import ready_for_submission_wizard
from . import ready_to_bill_wizard from . import ready_to_bill_wizard
from . import field_mapping_config_wizard from . import field_mapping_config_wizard
from . import loaner_checkout_wizard
from . import loaner_return_wizard
from . import ready_for_delivery_wizard from . import ready_for_delivery_wizard
from . import xml_import_wizard from . import xml_import_wizard
from . import send_to_mod_wizard from . import send_to_mod_wizard
@@ -30,4 +28,5 @@ from . import odsp_discretionary_wizard
from . import odsp_pre_approved_wizard from . import odsp_pre_approved_wizard
from . import odsp_ready_delivery_wizard from . import odsp_ready_delivery_wizard
from . import odsp_submit_to_odsp_wizard from . import odsp_submit_to_odsp_wizard
from . import send_page11_wizard from . import send_page11_wizard
from . import adp_import_wizard

View File

@@ -429,7 +429,7 @@ class FusionCentralExportWizard(models.TransientModel):
"""Save export file to the ADP Export Records model (filestore-backed).""" """Save export file to the ADP Export Records model (filestore-backed)."""
try: try:
ExportRecord = self.env['fusion_claims.adp.export.record'] ExportRecord = self.env['fusion_claims.adp.export.record']
posting_date = ExportRecord._get_current_posting_date(self.export_date) posting_date = ExportRecord._get_posting_period_for_file(self.export_date)
ExportRecord.create({ ExportRecord.create({
'name': filename, 'name': filename,

View File

@@ -0,0 +1,163 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2025 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Claim Assistant product family.
import base64
import io
import logging
import zipfile
from datetime import date
from odoo import models, fields, api, _
from odoo.exceptions import UserError
_logger = logging.getLogger(__name__)
class ADPImportWizard(models.TransientModel):
_name = 'fusion_claims.adp.import.wizard'
_description = 'Import ADP Export Files'
txt_files = fields.Many2many(
'ir.attachment',
string='TXT Files',
help='Select one or more ADP export .txt files to import',
)
zip_file = fields.Binary(string='ZIP File')
zip_filename = fields.Char()
result_message = fields.Text(string='Import Results', readonly=True)
state = fields.Selection([
('draft', 'Upload'),
('done', 'Done'),
], default='draft')
def action_import(self):
"""Process uploaded files and create export records."""
self.ensure_one()
if not self.txt_files and not self.zip_file:
raise UserError(_('Please upload .txt files or a ZIP file.'))
ExportRecord = self.env['fusion_claims.adp.export.record']
imported = 0
skipped = 0
errors = []
file_list, skipped_non_txt = self._collect_files()
for filename, file_data_b64 in file_list:
try:
existing = ExportRecord.search([('name', '=', filename)], limit=1)
if existing:
skipped += 1
continue
vendor_code, file_date = ExportRecord._parse_export_filename(filename)
posting_date = ExportRecord._get_posting_period_for_file(
file_date if file_date else date.today()
)
ExportRecord.create({
'name': filename,
'filename': filename,
'file_data': file_data_b64,
'export_date': fields.Datetime.now(),
'posting_period_date': posting_date,
'vendor_code': vendor_code or '',
'user_id': self.env.uid,
'company_id': self.env.company.id,
'notes': 'Imported on %s' % date.today(),
})
imported += 1
except Exception as e:
errors.append('%s: %s' % (filename, str(e)))
_logger.exception('Error importing file %s', filename)
lines = [
'Import Complete!',
'- Files imported: %d' % imported,
'- Files skipped (already exist): %d' % skipped,
'- Non-txt files ignored: %d' % skipped_non_txt,
]
if errors:
lines.append('\nErrors (%d):' % len(errors))
for err in errors:
lines.append(' - %s' % err)
self.result_message = '\n'.join(lines)
self.state = 'done'
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'view_mode': 'form',
'res_id': self.id,
'target': 'new',
}
def _collect_files(self):
"""Collect all .txt files from both individual uploads and ZIP.
Returns (file_list, skipped_count) where file_list is
a list of (filename, base64_data) tuples.
"""
files = []
skipped_types = []
for attachment in self.txt_files:
name = attachment.name or ''
if not name.lower().endswith('.txt'):
skipped_types.append(name)
continue
files.append((name, attachment.datas))
if skipped_types and not files and not self.zip_file:
raise UserError(_(
'Only .txt files are supported. Skipped: %s',
', '.join(skipped_types),
))
if self.zip_file:
files.extend(self._extract_txt_from_zip())
return files, len(skipped_types)
def _extract_txt_from_zip(self):
"""Scan all folders/subfolders in the ZIP and extract .txt files.
Returns list of (filename, base64_data) tuples.
Uses only the base filename (no folder path) as the record name.
"""
raw = base64.b64decode(self.zip_file)
buf = io.BytesIO(raw)
if not zipfile.is_zipfile(buf):
raise UserError(_('The uploaded file is not a valid ZIP archive.'))
buf.seek(0)
results = []
seen = set()
with zipfile.ZipFile(buf, 'r') as zf:
for entry in zf.infolist():
if entry.is_dir():
continue
lower = entry.filename.lower()
if not lower.endswith('.txt'):
continue
if lower.startswith('__macosx'):
continue
basename = entry.filename.rsplit('/', 1)[-1]
if not basename or basename in seen:
continue
seen.add(basename)
data = zf.read(entry.filename)
results.append((basename, base64.b64encode(data).decode('ascii')))
if not results:
raise UserError(_('No .txt files found in the ZIP archive.'))
return results

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_adp_import_wizard_form" model="ir.ui.view">
<field name="name">fusion_claims.adp.import.wizard.form</field>
<field name="model">fusion_claims.adp.import.wizard</field>
<field name="arch" type="xml">
<form string="Import Claim Submission Files">
<group invisible="state != 'draft'">
<group string="Individual Files">
<field name="txt_files" widget="many2many_binary" string="TXT Files"/>
</group>
<group string="Folder Upload (ZIP)">
<field name="zip_file" filename="zip_filename"/>
<field name="zip_filename" invisible="1"/>
</group>
<div class="alert alert-info" role="alert">
<strong>Import Claim Submission Files</strong>
<p class="mb-0">
<b>Option 1:</b> Drag and drop individual .txt files above.<br/>
<b>Option 2:</b> ZIP your folder(s) and upload. All subfolders
will be scanned for .txt files automatically.<br/>
You can use both options at once. Duplicates are skipped.
</p>
</div>
</group>
<group invisible="state != 'done'">
<field name="result_message" widget="text" nolabel="1" colspan="2"/>
</group>
<field name="state" invisible="1"/>
<footer>
<button string="Import" name="action_import" type="object"
class="btn-primary" invisible="state != 'draft'"/>
<button string="Close" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_adp_import_wizard" model="ir.actions.act_window">
<field name="name">Import Submission Files</field>
<field name="res_model">fusion_claims.adp.import.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -8,6 +8,7 @@ import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from odoo import http, fields, _ from odoo import http, fields, _
from odoo.http import request from odoo.http import request
from odoo.addons.fusion_clock.models.tz_utils import get_local_today, get_local_day_boundaries
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -126,7 +127,7 @@ class FusionClockAPI(http.Controller):
'scheduled_time': scheduled_dt, 'scheduled_time': scheduled_dt,
'actual_time': actual_dt, 'actual_time': actual_dt,
'penalty_minutes': deduction, 'penalty_minutes': deduction,
'date': actual_dt.date() if isinstance(actual_dt, datetime) else fields.Date.today(), 'date': actual_dt.date() if isinstance(actual_dt, datetime) else get_local_today(request.env, employee),
}) })
# Deduct penalty minutes from attendance (adds to break deduction) # Deduct penalty minutes from attendance (adds to break deduction)
@@ -266,7 +267,7 @@ class FusionClockAPI(http.Controller):
) )
now = fields.Datetime.now() now = fields.Datetime.now()
today = now.date() today = get_local_today(request.env, employee)
geo_info = { geo_info = {
'latitude': latitude, 'latitude': latitude,
@@ -529,24 +530,23 @@ class FusionClockAPI(http.Controller):
'location_id': att.x_fclk_location_id.id or False, 'location_id': att.x_fclk_location_id.id or False,
}) })
today_start = fields.Datetime.to_string( local_today = get_local_today(request.env, employee)
datetime.combine(fields.Date.today(), datetime.min.time()) today_start_utc, today_end_utc = get_local_day_boundaries(request.env, local_today, employee)
)
today_atts = request.env['hr.attendance'].sudo().search([ today_atts = request.env['hr.attendance'].sudo().search([
('employee_id', '=', employee.id), ('employee_id', '=', employee.id),
('check_in', '>=', today_start), ('check_in', '>=', fields.Datetime.to_string(today_start_utc)),
('check_in', '<', fields.Datetime.to_string(today_end_utc)),
('check_out', '!=', False), ('check_out', '!=', False),
]) ])
result['today_hours'] = round(sum(a.x_fclk_net_hours or 0 for a in today_atts), 2) result['today_hours'] = round(sum(a.x_fclk_net_hours or 0 for a in today_atts), 2)
today = fields.Date.today() today = get_local_today(request.env, employee)
week_start = today - timedelta(days=today.weekday()) week_start = today - timedelta(days=today.weekday())
week_start_dt = fields.Datetime.to_string( week_start_utc, _ = get_local_day_boundaries(request.env, week_start, employee)
datetime.combine(week_start, datetime.min.time())
)
week_atts = request.env['hr.attendance'].sudo().search([ week_atts = request.env['hr.attendance'].sudo().search([
('employee_id', '=', employee.id), ('employee_id', '=', employee.id),
('check_in', '>=', week_start_dt), ('check_in', '>=', fields.Datetime.to_string(week_start_utc)),
('check_in', '<', fields.Datetime.to_string(today_end_utc)),
('check_out', '!=', False), ('check_out', '!=', False),
]) ])
result['week_hours'] = round(sum(a.x_fclk_net_hours or 0 for a in week_atts), 2) result['week_hours'] = round(sum(a.x_fclk_net_hours or 0 for a in week_atts), 2)
@@ -614,8 +614,8 @@ class FusionClockAPI(http.Controller):
return {'error': 'Access denied.'} return {'error': 'Access denied.'}
now = fields.Datetime.now() now = fields.Datetime.now()
today = fields.Date.today() today = get_local_today(request.env)
today_start = datetime.combine(today, datetime.min.time()) today_start, _ = get_local_day_boundaries(request.env, today)
Attendance = request.env['hr.attendance'].sudo() Attendance = request.env['hr.attendance'].sudo()
Employee = request.env['hr.employee'].sudo() Employee = request.env['hr.employee'].sudo()

View File

@@ -9,6 +9,7 @@ from datetime import datetime, timedelta
from odoo import http, fields, _ from odoo import http, fields, _
from odoo.http import request from odoo.http import request
from odoo.addons.portal.controllers.portal import CustomerPortal from odoo.addons.portal.controllers.portal import CustomerPortal
from odoo.addons.fusion_clock.models.tz_utils import get_local_today, get_local_day_boundaries
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -48,7 +49,7 @@ class FusionClockPortal(CustomerPortal):
if 'clock_count' in counters: if 'clock_count' in counters:
employee = self._get_portal_employee() employee = self._get_portal_employee()
if employee: if employee:
today_start = datetime.combine(fields.Date.today(), datetime.min.time()) today_start, _ = get_local_day_boundaries(request.env, get_local_today(request.env, employee), employee)
count = request.env['hr.attendance'].sudo().search_count([ count = request.env['hr.attendance'].sudo().search_count([
('employee_id', '=', employee.id), ('employee_id', '=', employee.id),
('check_in', '>=', today_start), ('check_in', '>=', today_start),
@@ -99,7 +100,7 @@ class FusionClockPortal(CustomerPortal):
], limit=1) ], limit=1)
# Today stats # Today stats
today_start = datetime.combine(fields.Date.today(), datetime.min.time()) today_start, _ = get_local_day_boundaries(request.env, get_local_today(request.env, employee), employee)
today_atts = request.env['hr.attendance'].sudo().search([ today_atts = request.env['hr.attendance'].sudo().search([
('employee_id', '=', employee.id), ('employee_id', '=', employee.id),
('check_in', '>=', today_start), ('check_in', '>=', today_start),
@@ -108,9 +109,9 @@ class FusionClockPortal(CustomerPortal):
today_hours = sum(a.x_fclk_net_hours or 0 for a in today_atts) today_hours = sum(a.x_fclk_net_hours or 0 for a in today_atts)
# Week stats # Week stats
today = fields.Date.today() today = get_local_today(request.env, employee)
week_start = today - timedelta(days=today.weekday()) week_start = today - timedelta(days=today.weekday())
week_start_dt = datetime.combine(week_start, datetime.min.time()) week_start_dt, _ = get_local_day_boundaries(request.env, week_start, employee)
week_atts = request.env['hr.attendance'].sudo().search([ week_atts = request.env['hr.attendance'].sudo().search([
('employee_id', '=', employee.id), ('employee_id', '=', employee.id),
('check_in', '>=', week_start_dt), ('check_in', '>=', week_start_dt),
@@ -164,7 +165,7 @@ class FusionClockPortal(CustomerPortal):
schedule_type = ICP.get_param('fusion_clock.pay_period_type', 'biweekly') schedule_type = ICP.get_param('fusion_clock.pay_period_type', 'biweekly')
period_start_str = ICP.get_param('fusion_clock.pay_period_start', '') period_start_str = ICP.get_param('fusion_clock.pay_period_start', '')
today = fields.Date.today() today = get_local_today(request.env, employee)
# Calculate period dates # Calculate period dates
FusionReport = request.env['fusion.clock.report'].sudo() FusionReport = request.env['fusion.clock.report'].sudo()
@@ -190,10 +191,12 @@ class FusionClockPortal(CustomerPortal):
period_end -= timedelta(days=14) period_end -= timedelta(days=14)
# Get attendance records # Get attendance records
period_start_utc, _ = get_local_day_boundaries(request.env, period_start, employee)
_, period_end_utc = get_local_day_boundaries(request.env, period_end, employee)
attendances = request.env['hr.attendance'].sudo().search([ attendances = request.env['hr.attendance'].sudo().search([
('employee_id', '=', employee.id), ('employee_id', '=', employee.id),
('check_in', '>=', datetime.combine(period_start, datetime.min.time())), ('check_in', '>=', period_start_utc),
('check_in', '<', datetime.combine(period_end + timedelta(days=1), datetime.min.time())), ('check_in', '<', period_end_utc),
], order='check_in desc') ], order='check_in desc')
total_hours = sum(a.worked_hours or 0 for a in attendances if a.check_out) total_hours = sum(a.worked_hours or 0 for a in attendances if a.check_out)

View File

@@ -1,164 +1,190 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!--
Copyright 2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
Email templates for Fusion Clock.
Design: Matches the Fusion email design system (thin accent bar, system fonts,
dark/light mode safe, no user signatures).
-->
<odoo noupdate="1"> <odoo noupdate="1">
<!-- =============================================================== -->
<!-- Employee Individual Report Email --> <!-- Employee Individual Report Email -->
<!-- =============================================================== -->
<record id="mail_template_clock_employee_report" model="mail.template"> <record id="mail_template_clock_employee_report" model="mail.template">
<field name="name">Fusion Clock: Employee Report</field> <field name="name">Fusion Clock: Employee Report</field>
<field name="model_id" ref="fusion_clock.model_fusion_clock_report"/> <field name="model_id" ref="fusion_clock.model_fusion_clock_report"/>
<field name="subject">Your Attendance Report - {{ object.date_start }} to {{ object.date_end }}</field> <field name="subject">Your Attendance Report - {{ object.date_start }} to {{ object.date_end }}</field>
<field name="email_from">{{ (object.company_id.email or user.email_formatted) }}</field> <field name="email_from">{{ (object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="email_to">{{ object.employee_id.work_email or '' }}</field> <field name="email_to">{{ object.employee_id.work_email or '' }}</field>
<field name="body_html"><![CDATA[ <field name="body_html"><![CDATA[
<div style="margin:0;padding:0;font-family:Arial,Helvetica,sans-serif;"> <div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
<table width="600" style="margin:0 auto;background:#ffffff;border:1px solid #e0e0e0;border-radius:8px;"> <div style="height:4px;background-color:#10B981;"></div>
<tr> <div style="padding:32px 28px;">
<td style="padding:24px 32px;background:#1a1d23;border-radius:8px 8px 0 0;"> <p style="color:#10B981;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
<h2 style="color:#10B981;margin:0;">Fusion Clock</h2> <t t-out="object.company_id.name"/>
<p style="color:#9ca3af;margin:4px 0 0;">Attendance Report</p> </p>
</td> <h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Attendance Report</h2>
</tr> <p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
<tr> Hello <strong><t t-out="object.employee_id.name"/></strong>,
<td style="padding:24px 32px;"> your attendance report for
<p>Hello <strong>{{ object.employee_id.name }}</strong>,</p> <strong><t t-out="object.date_start" t-options="{'widget': 'date'}"/></strong> to
<p>Your attendance report for the period <strong>{{ object.date_start }}</strong> to <strong>{{ object.date_end }}</strong> is ready.</p> <strong><t t-out="object.date_end" t-options="{'widget': 'date'}"/></strong> is ready.
</p>
<table width="100%" style="margin:16px 0;border-collapse:collapse;"> <table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
<tr style="background:#f8f9fa;"> <tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;opacity:0.55;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid rgba(128,128,128,0.25);">Report Summary</td></tr>
<td style="padding:8px 12px;border:1px solid #e0e0e0;"><strong>Days Worked</strong></td> <tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">Days Worked</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.days_worked"/></td></tr>
<td style="padding:8px 12px;border:1px solid #e0e0e0;">{{ object.days_worked }}</td> <tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Total Hours</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="'%.1f' % object.total_hours"/>h</td></tr>
</tr> <tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Net Hours</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="'%.1f' % object.net_hours"/>h</td></tr>
<tr> <tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Total Breaks</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="'%.0f' % object.total_breaks"/> min</td></tr>
<td style="padding:8px 12px;border:1px solid #e0e0e0;"><strong>Total Hours</strong></td> <tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Penalties</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="object.total_penalties"/></td></tr>
<td style="padding:8px 12px;border:1px solid #e0e0e0;">{{ '%.1f' % object.total_hours }}h</td> </table>
</tr>
<tr style="background:#f8f9fa;">
<td style="padding:8px 12px;border:1px solid #e0e0e0;"><strong>Net Hours</strong></td>
<td style="padding:8px 12px;border:1px solid #e0e0e0;">{{ '%.1f' % object.net_hours }}h</td>
</tr>
<tr>
<td style="padding:8px 12px;border:1px solid #e0e0e0;"><strong>Total Breaks</strong></td>
<td style="padding:8px 12px;border:1px solid #e0e0e0;">{{ '%.0f' % object.total_breaks }} min</td>
</tr>
</table>
<p>The full PDF report is attached. You can also download it from your portal at any time.</p> <div style="padding:10px 14px;border:1px dashed rgba(128,128,128,0.35);border-radius:6px;margin:0 0 24px 0;">
<p style="color:#6b7280;font-size:12px;">This is an automated message from Fusion Clock.</p> <p style="margin:0;font-size:13px;opacity:0.65;"><strong style="opacity:1;">Attached:</strong> Attendance Report (PDF)</p>
</td> </div>
</tr>
</table> <div style="border-left:3px solid #10B981;padding:12px 16px;margin:0 0 24px 0;">
<p style="margin:0;font-size:14px;line-height:1.5;">You can also download this report from your portal at any time.</p>
</div>
</div>
<div style="padding:16px 28px;text-align:center;">
<p style="opacity:0.5;font-size:11px;line-height:1.5;margin:0;">
<t t-out="object.company_id.name"/><br/>
This is an automated notification from Fusion Clock.
</p>
</div>
</div> </div>
]]></field> ]]></field>
<field name="report_template_ids" eval="[(4, ref('fusion_clock.action_report_clock_employee'))]"/> <field name="report_template_ids" eval="[(4, ref('fusion_clock.action_report_clock_employee'))]"/>
<field name="auto_delete" eval="False"/> <field name="auto_delete" eval="False"/>
</record> </record>
<!-- =============================================================== -->
<!-- Batch Report Email (to managers) --> <!-- Batch Report Email (to managers) -->
<!-- =============================================================== -->
<record id="mail_template_clock_batch_report" model="mail.template"> <record id="mail_template_clock_batch_report" model="mail.template">
<field name="name">Fusion Clock: Batch Report</field> <field name="name">Fusion Clock: Batch Report</field>
<field name="model_id" ref="fusion_clock.model_fusion_clock_report"/> <field name="model_id" ref="fusion_clock.model_fusion_clock_report"/>
<field name="subject">Employee Attendance Batch Report - {{ object.date_start }} to {{ object.date_end }}</field> <field name="subject">Employee Attendance Batch Report - {{ object.date_start }} to {{ object.date_end }}</field>
<field name="email_from">{{ (object.company_id.email or user.email_formatted) }}</field> <field name="email_from">{{ (object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="email_to">{{ (object.company_id.sudo().env['ir.config_parameter'].get_param('fusion_clock.report_recipient_emails') or '') }}</field> <field name="email_to">{{ (object.company_id.sudo().env['ir.config_parameter'].get_param('fusion_clock.report_recipient_emails') or '') }}</field>
<field name="body_html"><![CDATA[ <field name="body_html"><![CDATA[
<div style="margin:0;padding:0;font-family:Arial,Helvetica,sans-serif;"> <div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
<table width="600" style="margin:0 auto;background:#ffffff;border:1px solid #e0e0e0;border-radius:8px;"> <div style="height:4px;background-color:#10B981;"></div>
<tr> <div style="padding:32px 28px;">
<td style="padding:24px 32px;background:#1a1d23;border-radius:8px 8px 0 0;"> <p style="color:#10B981;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
<h2 style="color:#10B981;margin:0;">Fusion Clock</h2> <t t-out="object.company_id.name"/>
<p style="color:#9ca3af;margin:4px 0 0;">Batch Attendance Report</p> </p>
</td> <h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Batch Attendance Report</h2>
</tr> <p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
<tr> The attendance batch report for
<td style="padding:24px 32px;"> <strong><t t-out="object.date_start" t-options="{'widget': 'date'}"/></strong> to
<p>The attendance batch report for <strong>{{ object.date_start }}</strong> to <strong>{{ object.date_end }}</strong> is attached.</p> <strong><t t-out="object.date_end" t-options="{'widget': 'date'}"/></strong> is attached.
<p>This report includes all employees' attendance summaries with daily breakdowns, total hours, and penalty information.</p> </p>
<p style="color:#6b7280;font-size:12px;">This is an automated message from Fusion Clock.</p>
</td> <div style="padding:10px 14px;border:1px dashed rgba(128,128,128,0.35);border-radius:6px;margin:0 0 24px 0;">
</tr> <p style="margin:0;font-size:13px;opacity:0.65;"><strong style="opacity:1;">Attached:</strong> Batch Attendance Report (PDF)</p>
</table> </div>
<div style="border-left:3px solid #10B981;padding:12px 16px;margin:0 0 24px 0;">
<p style="margin:0;font-size:14px;line-height:1.5;">This report includes all employees' attendance summaries with daily breakdowns, total hours, and penalty information.</p>
</div>
</div>
<div style="padding:16px 28px;text-align:center;">
<p style="opacity:0.5;font-size:11px;line-height:1.5;margin:0;">
<t t-out="object.company_id.name"/><br/>
This is an automated notification from Fusion Clock.
</p>
</div>
</div> </div>
]]></field> ]]></field>
<field name="report_template_ids" eval="[(4, ref('fusion_clock.action_report_clock_batch'))]"/> <field name="report_template_ids" eval="[(4, ref('fusion_clock.action_report_clock_batch'))]"/>
<field name="auto_delete" eval="False"/> <field name="auto_delete" eval="False"/>
</record> </record>
<!-- =============================================================== -->
<!-- Weekly Summary Email --> <!-- Weekly Summary Email -->
<!-- =============================================================== -->
<record id="mail_template_weekly_summary" model="mail.template"> <record id="mail_template_weekly_summary" model="mail.template">
<field name="name">Fusion Clock: Weekly Summary</field> <field name="name">Fusion Clock: Weekly Summary</field>
<field name="model_id" ref="hr.model_hr_employee"/> <field name="model_id" ref="hr.model_hr_employee"/>
<field name="subject">Your Weekly Attendance Summary</field> <field name="subject">Your Weekly Attendance Summary</field>
<field name="email_from">{{ (object.company_id.email or user.email_formatted) }}</field> <field name="email_from">{{ (object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="email_to">{{ object.work_email or '' }}</field> <field name="email_to">{{ object.work_email or '' }}</field>
<field name="body_html"><![CDATA[ <field name="body_html"><![CDATA[
<div style="margin:0;padding:0;font-family:Arial,Helvetica,sans-serif;"> <div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
<table width="600" style="margin:0 auto;background:#ffffff;border:1px solid #e0e0e0;border-radius:8px;"> <div style="height:4px;background-color:#10B981;"></div>
<tr> <div style="padding:32px 28px;">
<td style="padding:24px 32px;background:#1a1d23;border-radius:8px 8px 0 0;"> <p style="color:#10B981;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
<h2 style="color:#10B981;margin:0;">Fusion Clock</h2> <t t-out="object.company_id.name"/>
<p style="color:#9ca3af;margin:4px 0 0;">Weekly Summary</p> </p>
</td> <h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Weekly Summary</h2>
</tr> <p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
<tr> Hello <strong><t t-out="object.name"/></strong>,
<td style="padding:24px 32px;"> here is your attendance summary for the past week.
<p>Hello <strong>{{ object.name }}</strong>,</p> </p>
<p>Here is your attendance summary for the past week:</p>
<table width="100%" style="margin:16px 0;border-collapse:collapse;"> <table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">
<tr style="background:#f8f9fa;"> <tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;opacity:0.55;text-transform:uppercase;letter-spacing:0.5px;border-bottom:2px solid rgba(128,128,128,0.25);">Summary</td></tr>
<td style="padding:8px 12px;border:1px solid #e0e0e0;"><strong>Total Hours</strong></td> <tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">Total Hours</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="ctx.get('total_hours', 0)"/>h</td></tr>
<td style="padding:8px 12px;border:1px solid #e0e0e0;">{{ ctx.get('total_hours', 0) }}h</td> <tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Overtime</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="ctx.get('overtime_hours', 0)"/>h</td></tr>
</tr> <tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Penalties</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="ctx.get('penalty_count', 0)"/></td></tr>
<tr> <tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">Absences</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="ctx.get('absence_count', 0)"/></td></tr>
<td style="padding:8px 12px;border:1px solid #e0e0e0;"><strong>Overtime</strong></td> <tr><td style="padding:10px 14px;opacity:0.6;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);">On-Time Streak</td><td style="padding:10px 14px;font-size:14px;border-bottom:1px solid rgba(128,128,128,0.15);"><t t-out="ctx.get('streak', 0)"/> days</td></tr>
<td style="padding:8px 12px;border:1px solid #e0e0e0;">{{ ctx.get('overtime_hours', 0) }}h</td> </table>
</tr>
<tr style="background:#f8f9fa;">
<td style="padding:8px 12px;border:1px solid #e0e0e0;"><strong>Penalties</strong></td>
<td style="padding:8px 12px;border:1px solid #e0e0e0;">{{ ctx.get('penalty_count', 0) }}</td>
</tr>
<tr>
<td style="padding:8px 12px;border:1px solid #e0e0e0;"><strong>Absences</strong></td>
<td style="padding:8px 12px;border:1px solid #e0e0e0;">{{ ctx.get('absence_count', 0) }}</td>
</tr>
<tr style="background:#f8f9fa;">
<td style="padding:8px 12px;border:1px solid #e0e0e0;"><strong>On-Time Streak</strong></td>
<td style="padding:8px 12px;border:1px solid #e0e0e0;">{{ ctx.get('streak', 0) }} days</td>
</tr>
</table>
<p>Log in to <a href="/my/clock">your portal</a> to view details.</p> <div style="border-left:3px solid #10B981;padding:12px 16px;margin:0 0 24px 0;">
<p style="color:#6b7280;font-size:12px;">This is an automated message from Fusion Clock.</p> <p style="margin:0;font-size:14px;line-height:1.5;">Log in to <a href="/my/clock" style="color:#10B981;">your portal</a> to view full details.</p>
</td> </div>
</tr> </div>
</table> <div style="padding:16px 28px;text-align:center;">
<p style="opacity:0.5;font-size:11px;line-height:1.5;margin:0;">
<t t-out="object.company_id.name"/><br/>
This is an automated notification from Fusion Clock.
</p>
</div>
</div> </div>
]]></field> ]]></field>
<field name="auto_delete" eval="False"/> <field name="auto_delete" eval="False"/>
</record> </record>
<!-- =============================================================== -->
<!-- Correction Request Notification --> <!-- Correction Request Notification -->
<!-- =============================================================== -->
<record id="mail_template_correction_request" model="mail.template"> <record id="mail_template_correction_request" model="mail.template">
<field name="name">Fusion Clock: Correction Request</field> <field name="name">Fusion Clock: Correction Request</field>
<field name="model_id" ref="fusion_clock.model_fusion_clock_correction"/> <field name="model_id" ref="fusion_clock.model_fusion_clock_correction"/>
<field name="subject">Timesheet Correction Request: {{ object.employee_id.name }}</field> <field name="subject">Timesheet Correction Request: {{ object.employee_id.name }}</field>
<field name="email_from">{{ (object.company_id.email or user.email_formatted) }}</field> <field name="email_from">{{ (object.company_id.email_formatted or user.email_formatted) }}</field>
<field name="body_html"><![CDATA[ <field name="body_html"><![CDATA[
<div style="margin:0;padding:0;font-family:Arial,Helvetica,sans-serif;"> <div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;max-width:600px;margin:0 auto;">
<table width="600" style="margin:0 auto;background:#ffffff;border:1px solid #e0e0e0;border-radius:8px;"> <div style="height:4px;background-color:#d69e2e;"></div>
<tr> <div style="padding:32px 28px;">
<td style="padding:24px 32px;background:#1a1d23;border-radius:8px 8px 0 0;"> <p style="color:#d69e2e;font-size:13px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">
<h2 style="color:#10B981;margin:0;">Fusion Clock</h2> <t t-out="object.company_id.name"/>
<p style="color:#9ca3af;margin:4px 0 0;">Correction Request</p> </p>
</td> <h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;line-height:1.3;">Correction Request</h2>
</tr> <p style="opacity:0.65;font-size:15px;line-height:1.5;margin:0 0 24px 0;">
<tr> <strong><t t-out="object.employee_id.name"/></strong> has submitted a timesheet correction request.
<td style="padding:24px 32px;"> </p>
<p><strong>{{ object.employee_id.name }}</strong> has submitted a timesheet correction request.</p>
<p><strong>Reason:</strong> {{ object.reason }}</p> <div style="border-left:3px solid #d69e2e;padding:12px 16px;margin:0 0 24px 0;">
<p>Please review and approve/reject from the Fusion Clock backend.</p> <p style="margin:0;font-size:14px;line-height:1.5;"><strong>Reason:</strong> <t t-out="object.reason"/></p>
</td> </div>
</tr>
</table> <div style="border-left:3px solid #10B981;padding:12px 16px;margin:0 0 24px 0;">
<p style="margin:0;font-size:14px;line-height:1.5;">Please review and approve or reject from the Fusion Clock backend.</p>
</div>
</div>
<div style="padding:16px 28px;text-align:center;">
<p style="opacity:0.5;font-size:11px;line-height:1.5;margin:0;">
<t t-out="object.company_id.name"/><br/>
This is an automated notification from Fusion Clock.
</p>
</div>
</div> </div>
]]></field> ]]></field>
<field name="auto_delete" eval="False"/> <field name="auto_delete" eval="False"/>

View File

@@ -4,9 +4,12 @@
import base64 import base64
import logging import logging
import pytz
from datetime import timedelta from datetime import timedelta
from odoo import models, fields, api, _ from odoo import models, fields, api, _
from odoo.exceptions import UserError from odoo.exceptions import UserError
from .hr_attendance import _fclk_email_wrap, _fclk_utc_to_local_str
from .tz_utils import get_local_today, get_local_day_boundaries, _resolve_tz
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@@ -67,6 +70,7 @@ class FusionClockReport(models.Model):
total_breaks = fields.Float(string='Total Breaks (min)', compute='_compute_totals', store=True) total_breaks = fields.Float(string='Total Breaks (min)', compute='_compute_totals', store=True)
total_penalties = fields.Integer(string='Penalty Count', compute='_compute_totals', store=True) total_penalties = fields.Integer(string='Penalty Count', compute='_compute_totals', store=True)
days_worked = fields.Integer(string='Days Worked', compute='_compute_totals', store=True) days_worked = fields.Integer(string='Days Worked', compute='_compute_totals', store=True)
leave_days = fields.Integer(string='Leave Days', compute='_compute_totals', store=True)
attendance_ids = fields.Many2many( attendance_ids = fields.Many2many(
'hr.attendance', 'hr.attendance',
'fusion_clock_report_attendance_rel', 'fusion_clock_report_attendance_rel',
@@ -74,6 +78,13 @@ class FusionClockReport(models.Model):
'attendance_id', 'attendance_id',
string='Attendance Records', string='Attendance Records',
) )
leave_request_ids = fields.Many2many(
'fusion.clock.leave.request',
'fusion_clock_report_leave_rel',
'report_id',
'leave_request_id',
string='Leave Requests',
)
# PDF # PDF
report_pdf = fields.Binary(string='Report PDF', attachment=True) report_pdf = fields.Binary(string='Report PDF', attachment=True)
@@ -93,7 +104,8 @@ class FusionClockReport(models.Model):
rec.is_batch = not bool(rec.employee_id) rec.is_batch = not bool(rec.employee_id)
@api.depends('attendance_ids', 'attendance_ids.worked_hours', @api.depends('attendance_ids', 'attendance_ids.worked_hours',
'attendance_ids.x_fclk_net_hours', 'attendance_ids.x_fclk_break_minutes') 'attendance_ids.x_fclk_net_hours', 'attendance_ids.x_fclk_break_minutes',
'leave_request_ids')
def _compute_totals(self): def _compute_totals(self):
for rec in self: for rec in self:
atts = rec.attendance_ids atts = rec.attendance_ids
@@ -105,12 +117,14 @@ class FusionClockReport(models.Model):
('date', '>=', rec.date_start), ('date', '>=', rec.date_start),
('date', '<=', rec.date_end), ('date', '<=', rec.date_end),
]) if rec.employee_id else 0 ]) if rec.employee_id else 0
# Count unique dates tz = _resolve_tz(rec.env, rec.employee_id)
dates = set() dates = set()
for a in atts: for a in atts:
if a.check_in: if a.check_in:
dates.add(a.check_in.date()) local_dt = pytz.UTC.localize(a.check_in).astimezone(tz)
dates.add(local_dt.date())
rec.days_worked = len(dates) rec.days_worked = len(dates)
rec.leave_days = len(rec.leave_request_ids)
def action_generate_report(self): def action_generate_report(self):
"""Generate the PDF report for this record.""" """Generate the PDF report for this record."""
@@ -119,6 +133,11 @@ class FusionClockReport(models.Model):
self._generate_pdf() self._generate_pdf()
self.state = 'generated' self.state = 'generated'
def action_reset_draft(self):
"""Reset the report back to draft so the user can make changes."""
for rec in self:
rec.state = 'draft'
def action_send_report(self): def action_send_report(self):
"""Send the report via email.""" """Send the report via email."""
self.ensure_one() self.ensure_one()
@@ -128,11 +147,14 @@ class FusionClockReport(models.Model):
self.state = 'sent' self.state = 'sent'
def _collect_attendance_records(self): def _collect_attendance_records(self):
"""Link attendance records for the period and employee.""" """Link attendance and leave records for the period and employee."""
self.ensure_one() self.ensure_one()
employee = self.employee_id or None
start_utc, _ = get_local_day_boundaries(self.env, self.date_start, employee)
_, end_utc = get_local_day_boundaries(self.env, self.date_end, employee)
domain = [ domain = [
('check_in', '>=', fields.Datetime.to_datetime(self.date_start)), ('check_in', '>=', start_utc),
('check_in', '<', fields.Datetime.to_datetime(self.date_end + timedelta(days=1))), ('check_in', '<', end_utc),
('check_out', '!=', False), ('check_out', '!=', False),
] ]
if self.employee_id: if self.employee_id:
@@ -143,6 +165,18 @@ class FusionClockReport(models.Model):
attendances = self.env['hr.attendance'].search(domain) attendances = self.env['hr.attendance'].search(domain)
self.attendance_ids = [(6, 0, attendances.ids)] self.attendance_ids = [(6, 0, attendances.ids)]
leave_domain = [
('leave_date', '>=', self.date_start),
('leave_date', '<=', self.date_end),
]
if self.employee_id:
leave_domain.append(('employee_id', '=', self.employee_id.id))
else:
leave_domain.append(('company_id', '=', self.company_id.id))
leaves = self.env['fusion.clock.leave.request'].search(leave_domain)
self.leave_request_ids = [(6, 0, leaves.ids)]
def _generate_pdf(self): def _generate_pdf(self):
"""Render the QWeb report to PDF and store it.""" """Render the QWeb report to PDF and store it."""
self.ensure_one() self.ensure_one()
@@ -172,52 +206,63 @@ class FusionClockReport(models.Model):
def _send_report_email(self): def _send_report_email(self):
"""Send the report with the PDF attached.""" """Send the report with the PDF attached."""
self.ensure_one() self.ensure_one()
company_email = self.company_id.email or '' company = self.company_id or self.env.company
company_email = company.email or ''
company_name = company.name or ''
ds_fmt = self.date_start.strftime('%b %d, %Y') if self.date_start else ''
de_fmt = self.date_end.strftime('%b %d, %Y') if self.date_end else ''
if self.employee_id: if self.employee_id:
email_to = self.employee_id.work_email or '' email_to = self.employee_id.work_email or ''
subject = f"Your Attendance Report - {self.date_start} to {self.date_end}" subject = f"Your Attendance Report - {ds_fmt} to {de_fmt}"
body = ( body = _fclk_email_wrap(
'<div style="margin:0;padding:0;font-family:Arial,Helvetica,sans-serif;">' company_name=company_name,
'<table width="600" style="margin:0 auto;background:#ffffff;border:1px solid #e0e0e0;border-radius:8px;overflow:hidden;">' title='Attendance Report',
'<tr><td style="padding:24px 32px;background:#1a1d23;">' summary=(
'<h2 style="color:#10B981;margin:0;">Fusion Clock</h2>' f'Hello <strong>{self.employee_id.name}</strong>, your attendance '
'<p style="color:#9ca3af;margin:4px 0 0;">Attendance Report</p>' f'report for <strong>{ds_fmt}</strong> to '
'</td></tr><tr><td style="padding:24px 32px;">' f'<strong>{de_fmt}</strong> is ready.'
f'<p>Hello <strong>{self.employee_id.name}</strong>,</p>' ),
f'<p>Your attendance report for <strong>{self.date_start}</strong> to ' sections=[('Report Summary', [
f'<strong>{self.date_end}</strong> is ready.</p>' ('Pay Period', f'{ds_fmt} &mdash; {de_fmt}'),
'<table width="100%" style="margin:16px 0;border-collapse:collapse;">' ('Days Worked', str(self.days_worked)),
f'<tr style="background:#f8f9fa;"><td style="padding:10px 14px;border:1px solid #e0e0e0;font-weight:600;">Days Worked</td>' ('Leave Days', str(self.leave_days)),
f'<td style="padding:10px 14px;border:1px solid #e0e0e0;">{self.days_worked}</td></tr>' ('Total Hours', f'{self.total_hours:.1f}h'),
f'<tr><td style="padding:10px 14px;border:1px solid #e0e0e0;font-weight:600;">Total Hours</td>' ('Net Hours', f'{self.net_hours:.1f}h'),
f'<td style="padding:10px 14px;border:1px solid #e0e0e0;">{self.total_hours:.1f}h</td></tr>' ('Total Breaks', f'{self.total_breaks:.0f} min'),
f'<tr style="background:#f8f9fa;"><td style="padding:10px 14px;border:1px solid #e0e0e0;font-weight:600;">Net Hours</td>' ('Penalties', str(self.total_penalties)),
f'<td style="padding:10px 14px;border:1px solid #e0e0e0;">{self.net_hours:.1f}h</td></tr>' ])],
f'<tr><td style="padding:10px 14px;border:1px solid #e0e0e0;font-weight:600;">Total Breaks</td>' note='The full PDF report is attached. You can also download '
f'<td style="padding:10px 14px;border:1px solid #e0e0e0;">{self.total_breaks:.0f} min</td></tr>' 'it from <a href="/my/clock" style="color:#10B981;">'
'</table>' 'your portal</a> at any time.',
'<p>The full PDF report is attached.</p>' attachments_note='Attendance Report (PDF)',
'<p style="color:#6b7280;font-size:12px;margin-top:16px;">This is an automated message from Fusion Clock.</p>'
'</td></tr></table></div>'
) )
else: else:
ICP = self.env['ir.config_parameter'].sudo() ICP = self.env['ir.config_parameter'].sudo()
email_to = ICP.get_param('fusion_clock.report_recipient_emails', '') email_to = ICP.get_param('fusion_clock.report_recipient_emails', '')
subject = f"Employee Attendance Batch Report - {self.date_start} to {self.date_end}" user_ids_str = ICP.get_param('fusion_clock.report_recipient_user_ids', '')
body = ( if user_ids_str:
'<div style="margin:0;padding:0;font-family:Arial,Helvetica,sans-serif;">' try:
'<table width="600" style="margin:0 auto;background:#ffffff;border:1px solid #e0e0e0;border-radius:8px;overflow:hidden;">' user_ids = [int(x) for x in user_ids_str.split(',') if x.strip()]
'<tr><td style="padding:24px 32px;background:#1a1d23;">' users = self.env['res.users'].sudo().browse(user_ids).filtered('email')
'<h2 style="color:#10B981;margin:0;">Fusion Clock</h2>' user_emails = ','.join(u.email for u in users if u.email)
'<p style="color:#9ca3af;margin:4px 0 0;">Batch Attendance Report</p>' if email_to and user_emails:
'</td></tr><tr><td style="padding:24px 32px;">' email_to = f"{email_to},{user_emails}"
f'<p>The attendance batch report for <strong>{self.date_start}</strong> to ' elif user_emails:
f'<strong>{self.date_end}</strong> is attached.</p>' email_to = user_emails
'<p>This report includes all employees\' attendance summaries with daily breakdowns, ' except (ValueError, TypeError):
'total hours, and penalty information.</p>' pass
'<p style="color:#6b7280;font-size:12px;margin-top:16px;">This is an automated message from Fusion Clock.</p>' subject = f"Employee Attendance Batch Report - {ds_fmt} to {de_fmt}"
'</td></tr></table></div>' body = _fclk_email_wrap(
company_name=company_name,
title='Batch Attendance Report',
summary=(
f'The attendance batch report for <strong>{ds_fmt}</strong> to '
f'<strong>{de_fmt}</strong> is attached.'
),
note='This report includes all employees\' attendance summaries '
'with daily breakdowns, total hours, and penalty information.',
attachments_note='Batch Attendance Report (PDF)',
) )
if not email_to: if not email_to:
@@ -283,8 +328,8 @@ class FusionClockReport(models.Model):
for att in self.attendance_ids.sorted(key=lambda a: a.check_in): for att in self.attendance_ids.sorted(key=lambda a: a.check_in):
date_str = att.check_in.strftime('%Y-%m-%d') if att.check_in else '' date_str = att.check_in.strftime('%Y-%m-%d') if att.check_in else ''
in_str = att.check_in.strftime('%H:%M') if att.check_in else '' in_str = _fclk_utc_to_local_str(att.check_in, att.employee_id, '%H:%M') if att.check_in else ''
out_str = att.check_out.strftime('%H:%M') if att.check_out else '' out_str = _fclk_utc_to_local_str(att.check_out, att.employee_id, '%H:%M') if att.check_out else ''
penalties = self.env['fusion.clock.penalty'].search_count([ penalties = self.env['fusion.clock.penalty'].search_count([
('attendance_id', '=', att.id), ('attendance_id', '=', att.id),
]) ])
@@ -301,6 +346,20 @@ class FusionClockReport(models.Model):
att.x_fclk_location_id.name or '', att.x_fclk_location_id.name or '',
]) ])
for lv in self.leave_request_ids.sorted(key=lambda l: l.leave_date):
writer.writerow([
lv.employee_id.name or '',
str(lv.leave_date),
'LEAVE',
'LEAVE',
0,
0,
0,
0,
0,
lv.reason or '',
])
csv_data = output.getvalue().encode('utf-8') csv_data = output.getvalue().encode('utf-8')
output.close() output.close()
@@ -333,11 +392,10 @@ class FusionClockReport(models.Model):
schedule_type = ICP.get_param('fusion_clock.pay_period_type', 'biweekly') schedule_type = ICP.get_param('fusion_clock.pay_period_type', 'biweekly')
period_start_str = ICP.get_param('fusion_clock.pay_period_start', '') period_start_str = ICP.get_param('fusion_clock.pay_period_start', '')
today = fields.Date.today() today = get_local_today(self.env)
period_start, period_end = self._calculate_current_period(schedule_type, period_start_str, today) yesterday = today - timedelta(days=1)
period_start, period_end = self._calculate_current_period(schedule_type, period_start_str, yesterday)
# Only generate if yesterday was the end of a period if period_end != yesterday:
if period_end != today - timedelta(days=1):
return return
_logger.info("Fusion Clock: Generating reports for period %s to %s", period_start, period_end) _logger.info("Fusion Clock: Generating reports for period %s to %s", period_start, period_end)
@@ -443,3 +501,86 @@ class FusionClockReport(models.Model):
period_end = period_start + timedelta(days=13) period_end = period_start + timedelta(days=13)
return period_start, period_end return period_start, period_end
@api.model
def action_generate_historical_reports(self):
"""Generate reports for all past pay periods from historical attendance data.
Iterates per-employee so each starts from their own earliest
attendance record rather than the global earliest. Includes ALL
employees who have completed attendance records, not only those
with ``x_fclk_enable_clock``.
"""
ICP = self.env['ir.config_parameter'].sudo()
schedule_type = ICP.get_param('fusion_clock.pay_period_type', 'biweekly')
period_start_str = ICP.get_param('fusion_clock.pay_period_start', '')
today = get_local_today(self.env)
created_count = 0
emp_groups = self.env['hr.attendance'].sudo().read_group(
[('check_out', '!=', False)],
['employee_id'],
['employee_id'],
)
for emp_data in emp_groups:
emp_id = emp_data['employee_id'][0]
employee = self.env['hr.employee'].sudo().browse(emp_id)
if not employee.exists():
continue
earliest = self.env['hr.attendance'].sudo().search(
[('employee_id', '=', emp_id), ('check_out', '!=', False)],
order='check_in asc',
limit=1,
)
if not earliest:
continue
current = earliest.check_in.date()
while current <= today:
period_start, period_end = self._calculate_current_period(
schedule_type, period_start_str, current,
)
if period_end > today:
break
existing = self.sudo().search([
('employee_id', '=', emp_id),
('date_start', '=', period_start),
('date_end', '=', period_end),
], limit=1)
if not existing:
att_count = self.env['hr.attendance'].sudo().search_count([
('employee_id', '=', emp_id),
('check_in', '>=', fields.Datetime.to_datetime(period_start)),
('check_in', '<', fields.Datetime.to_datetime(
period_end + timedelta(days=1),
)),
('check_out', '!=', False),
])
if att_count > 0:
report = self.sudo().create({
'date_start': period_start,
'date_end': period_end,
'schedule_type': schedule_type,
'employee_id': emp_id,
'company_id': employee.company_id.id,
})
report._collect_attendance_records()
created_count += 1
current = period_end + timedelta(days=1)
_logger.info("Fusion Clock: Generated %d historical reports", created_count)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Historical Reports',
'message': f'{created_count} reports generated from historical data.',
'type': 'success',
'sticky': False,
},
}

View File

@@ -3,13 +3,118 @@
# License OPL-1 (Odoo Proprietary License v1.0) # License OPL-1 (Odoo Proprietary License v1.0)
import logging import logging
import pytz
from datetime import datetime, timedelta from datetime import datetime, timedelta
from odoo import models, fields, api from odoo import models, fields, api
from odoo.tools import float_round from odoo.tools import float_round
from .tz_utils import get_local_today, get_local_day_boundaries
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
def _fclk_utc_to_local_str(dt, employee, fmt='%I:%M %p'):
"""Convert a naive UTC datetime to a formatted string in the employee's timezone."""
import pytz
tz_name = (
employee.resource_id.tz
or (employee.user_id.partner_id.tz if employee.user_id else False)
or employee.company_id.partner_id.tz
or 'UTC'
)
utc_dt = pytz.UTC.localize(dt)
local_dt = utc_dt.astimezone(pytz.timezone(tz_name))
return local_dt.strftime(fmt)
_FCLK_ACCENT = '#10B981'
_FCLK_FONT = ("-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif")
def _fclk_email_section(heading, rows):
"""Build a details table matching the Fusion email design system."""
if not rows:
return ''
html = (
'<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">'
f'<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;'
f'opacity:0.55;text-transform:uppercase;letter-spacing:0.5px;'
f'border-bottom:2px solid rgba(128,128,128,0.25);">{heading}</td></tr>'
)
for label, value in rows:
if value is None or value == '' or value is False:
continue
html += (
f'<tr>'
f'<td style="padding:10px 14px;opacity:0.6;font-size:14px;'
f'border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">{label}</td>'
f'<td style="padding:10px 14px;font-size:14px;'
f'border-bottom:1px solid rgba(128,128,128,0.15);">{value}</td>'
f'</tr>'
)
html += '</table>'
return html
def _fclk_email_wrap(
company_name,
title,
summary,
sections=None,
note=None,
attachments_note=None,
extra_html='',
):
"""Build a complete Fusion Clock email matching the Fusion design system.
No user signatures are appended.
"""
parts = [
f'<div style="font-family:{_FCLK_FONT};max-width:600px;margin:0 auto;">',
f'<div style="height:4px;background-color:{_FCLK_ACCENT};"></div>',
'<div style="padding:32px 28px;">',
f'<p style="color:{_FCLK_ACCENT};font-size:13px;font-weight:600;'
f'letter-spacing:0.5px;text-transform:uppercase;margin:0 0 24px 0;">'
f'{company_name}</p>',
f'<h2 style="font-size:22px;font-weight:700;margin:0 0 6px 0;'
f'line-height:1.3;">{title}</h2>',
f'<p style="opacity:0.65;font-size:15px;line-height:1.5;'
f'margin:0 0 24px 0;">{summary}</p>',
]
if sections:
for heading, rows in sections:
parts.append(_fclk_email_section(heading, rows))
if note:
parts.append(
f'<div style="border-left:3px solid {_FCLK_ACCENT};padding:12px 16px;'
f'margin:0 0 24px 0;">'
f'<p style="margin:0;font-size:14px;line-height:1.5;">{note}</p></div>'
)
if extra_html:
parts.append(extra_html)
if attachments_note:
parts.append(
'<div style="padding:10px 14px;border:1px dashed rgba(128,128,128,0.35);'
'border-radius:6px;margin:0 0 24px 0;">'
'<p style="margin:0;font-size:13px;opacity:0.65;">'
f'<strong style="opacity:1;">Attached:</strong> {attachments_note}</p></div>'
)
parts.append('</div>')
parts.append(
'<div style="padding:16px 28px;text-align:center;">'
'<p style="opacity:0.5;font-size:11px;line-height:1.5;margin:0;">'
f'{company_name}<br/>'
'This is an automated notification from Fusion Clock.</p></div>'
)
parts.append('</div>')
return ''.join(parts)
class HrAttendance(models.Model): class HrAttendance(models.Model):
_inherit = 'hr.attendance' _inherit = 'hr.attendance'
@@ -146,7 +251,9 @@ class HrAttendance(models.Model):
continue continue
employee = att.employee_id employee = att.employee_id
_, scheduled_out = employee._get_fclk_scheduled_times(check_in.date()) emp_tz = pytz.timezone(employee.tz or self.env.company.tz or 'UTC')
check_in_date = pytz.UTC.localize(check_in).astimezone(emp_tz).date()
_, scheduled_out = employee._get_fclk_scheduled_times(check_in_date)
deadline = scheduled_out + timedelta(minutes=grace_min) deadline = scheduled_out + timedelta(minutes=grace_min)
max_deadline = check_in + timedelta(hours=max_shift) max_deadline = check_in + timedelta(hours=max_shift)
@@ -169,7 +276,7 @@ class HrAttendance(models.Model):
att.sudo().write({'x_fclk_break_minutes': break_min}) att.sudo().write({'x_fclk_break_minutes': break_min})
att.sudo().message_post( att.sudo().message_post(
body=f"Auto clocked out at {clock_out_time.strftime('%H:%M')} " body=f"Auto clocked out at {_fclk_utc_to_local_str(clock_out_time, employee, '%H:%M')} "
f"(grace period expired). Net hours: {att.x_fclk_net_hours:.1f}h", f"(grace period expired). Net hours: {att.x_fclk_net_hours:.1f}h",
message_type='comment', message_type='comment',
subtype_xmlid='mail.mt_note', subtype_xmlid='mail.mt_note',
@@ -179,7 +286,7 @@ class HrAttendance(models.Model):
ActivityLog.create({ ActivityLog.create({
'employee_id': employee.id, 'employee_id': employee.id,
'log_type': 'auto_clock_out', 'log_type': 'auto_clock_out',
'description': f"Auto clocked out at {clock_out_time.strftime('%H:%M')}. " 'description': f"Auto clocked out at {_fclk_utc_to_local_str(clock_out_time, employee, '%H:%M')}. "
f"Net hours: {att.x_fclk_net_hours:.1f}h", f"Net hours: {att.x_fclk_net_hours:.1f}h",
'attendance_id': att.id, 'attendance_id': att.id,
'location_id': att.x_fclk_location_id.id if att.x_fclk_location_id else False, 'location_id': att.x_fclk_location_id.id if att.x_fclk_location_id else False,
@@ -193,7 +300,7 @@ class HrAttendance(models.Model):
self._fclk_notify_office( self._fclk_notify_office(
office_user_id, office_user_id,
f"Auto Clock-Out: {employee.name}", f"Auto Clock-Out: {employee.name}",
f"{employee.name} was auto-clocked out at {clock_out_time.strftime('%H:%M')}. " f"{employee.name} was auto-clocked out at {_fclk_utc_to_local_str(clock_out_time, employee, '%H:%M')}. "
f"Please review and correct if needed.", f"Please review and correct if needed.",
'hr.attendance', 'hr.attendance',
att.id, att.id,
@@ -216,21 +323,6 @@ class HrAttendance(models.Model):
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0')) office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
max_absences = int(ICP.get_param('fusion_clock.max_monthly_absences', '3')) max_absences = int(ICP.get_param('fusion_clock.max_monthly_absences', '3'))
yesterday = fields.Date.today() - timedelta(days=1)
# Skip weekends
if yesterday.weekday() >= 5:
return
# Skip public holidays
holidays = self.env['resource.calendar.leaves'].sudo().search([
('resource_id', '=', False),
('date_from', '<=', datetime.combine(yesterday, datetime.max.time())),
('date_to', '>=', datetime.combine(yesterday, datetime.min.time())),
])
if holidays:
return
employees = self.env['hr.employee'].sudo().search([ employees = self.env['hr.employee'].sudo().search([
('x_fclk_enable_clock', '=', True), ('x_fclk_enable_clock', '=', True),
]) ])
@@ -239,16 +331,29 @@ class HrAttendance(models.Model):
LeaveRequest = self.env['fusion.clock.leave.request'].sudo() LeaveRequest = self.env['fusion.clock.leave.request'].sudo()
for emp in employees: for emp in employees:
# Check for attendance yesterday yesterday = get_local_today(self.env, emp) - timedelta(days=1)
if yesterday.weekday() >= 5:
continue
day_start, day_end = get_local_day_boundaries(self.env, yesterday, emp)
holidays = self.env['resource.calendar.leaves'].sudo().search([
('resource_id', '=', False),
('date_from', '<=', day_end),
('date_to', '>=', day_start),
])
if holidays:
continue
att_count = self.sudo().search_count([ att_count = self.sudo().search_count([
('employee_id', '=', emp.id), ('employee_id', '=', emp.id),
('check_in', '>=', datetime.combine(yesterday, datetime.min.time())), ('check_in', '>=', day_start),
('check_in', '<', datetime.combine(yesterday + timedelta(days=1), datetime.min.time())), ('check_in', '<', day_end),
]) ])
if att_count > 0: if att_count > 0:
continue continue
# Check for approved leave
leave = LeaveRequest.search([ leave = LeaveRequest.search([
('employee_id', '=', emp.id), ('employee_id', '=', emp.id),
('leave_date', '=', yesterday), ('leave_date', '=', yesterday),
@@ -256,23 +361,22 @@ class HrAttendance(models.Model):
if leave: if leave:
continue continue
# Mark absent
ActivityLog.create({ ActivityLog.create({
'employee_id': emp.id, 'employee_id': emp.id,
'log_type': 'absent', 'log_type': 'absent',
'log_date': datetime.combine(yesterday, datetime.min.time().replace(hour=9)), 'log_date': day_start,
'description': f"No attendance recorded for {yesterday}", 'description': f"No attendance recorded for {yesterday}",
'source': 'system', 'source': 'system',
}) })
emp.sudo().write({'x_fclk_pending_reason': True}) emp.sudo().write({'x_fclk_pending_reason': True})
# Check monthly threshold
month_start = yesterday.replace(day=1) month_start = yesterday.replace(day=1)
month_boundary_start, _ = get_local_day_boundaries(self.env, month_start, emp)
absence_count = ActivityLog.search_count([ absence_count = ActivityLog.search_count([
('employee_id', '=', emp.id), ('employee_id', '=', emp.id),
('log_type', '=', 'absent'), ('log_type', '=', 'absent'),
('log_date', '>=', datetime.combine(month_start, datetime.min.time())), ('log_date', '>=', month_boundary_start),
]) ])
if absence_count >= max_absences: if absence_count >= max_absences:
@@ -298,17 +402,17 @@ class HrAttendance(models.Model):
reminder_out_min = float(ICP.get_param('fusion_clock.reminder_before_end_minutes', '15')) reminder_out_min = float(ICP.get_param('fusion_clock.reminder_before_end_minutes', '15'))
now = fields.Datetime.now() now = fields.Datetime.now()
today = fields.Date.today()
# Skip weekends
if today.weekday() >= 5:
return
employees = self.env['hr.employee'].sudo().search([ employees = self.env['hr.employee'].sudo().search([
('x_fclk_enable_clock', '=', True), ('x_fclk_enable_clock', '=', True),
]) ])
for emp in employees: for emp in employees:
today = get_local_today(self.env, emp)
if today.weekday() >= 5:
continue
if emp.x_fclk_last_reminder_date == today: if emp.x_fclk_last_reminder_date == today:
continue continue
@@ -318,16 +422,17 @@ class HrAttendance(models.Model):
# Missed clock-in reminder # Missed clock-in reminder
reminder_deadline = scheduled_in + timedelta(minutes=reminder_in_min) reminder_deadline = scheduled_in + timedelta(minutes=reminder_in_min)
if not is_checked_in and now > reminder_deadline: if not is_checked_in and now > reminder_deadline:
today_start, _ = get_local_day_boundaries(self.env, today, emp)
has_attendance = self.sudo().search_count([ has_attendance = self.sudo().search_count([
('employee_id', '=', emp.id), ('employee_id', '=', emp.id),
('check_in', '>=', datetime.combine(today, datetime.min.time())), ('check_in', '>=', today_start),
]) ])
if has_attendance == 0: if has_attendance == 0:
self._fclk_send_employee_reminder( self._fclk_send_employee_reminder(
emp, emp,
"Clock-In Reminder", "Clock-In Reminder",
f"Hi {emp.name}, you haven't clocked in yet today. " f"Hi {emp.name}, you haven't clocked in yet today. "
f"Your shift started at {scheduled_in.strftime('%I:%M %p')}.", f"Your shift started at {_fclk_utc_to_local_str(scheduled_in, emp)}.",
) )
emp.sudo().write({'x_fclk_last_reminder_date': today}) emp.sudo().write({'x_fclk_last_reminder_date': today})
@@ -337,7 +442,7 @@ class HrAttendance(models.Model):
self._fclk_send_employee_reminder( self._fclk_send_employee_reminder(
emp, emp,
"Clock-Out Reminder", "Clock-Out Reminder",
f"Hi {emp.name}, your shift ends at {scheduled_out.strftime('%I:%M %p')}. " f"Hi {emp.name}, your shift ends at {_fclk_utc_to_local_str(scheduled_out, emp)}. "
f"Don't forget to clock out.", f"Don't forget to clock out.",
) )
emp.sudo().write({'x_fclk_last_reminder_date': today}) emp.sudo().write({'x_fclk_last_reminder_date': today})
@@ -349,27 +454,34 @@ class HrAttendance(models.Model):
if ICP.get_param('fusion_clock.send_weekly_summary', 'True') != 'True': if ICP.get_param('fusion_clock.send_weekly_summary', 'True') != 'True':
return return
today = fields.Date.today()
if today.weekday() != 0:
return
week_start = today - timedelta(days=7)
week_end = today - timedelta(days=1)
employees = self.env['hr.employee'].sudo().search([ employees = self.env['hr.employee'].sudo().search([
('x_fclk_enable_clock', '=', True), ('x_fclk_enable_clock', '=', True),
]) ])
company_email = self.env.company.email or '' company = self.env.company
company_email = company.email or ''
company_name = company.name or ''
for emp in employees: for emp in employees:
if not emp.work_email: if not emp.work_email:
continue continue
today = get_local_today(self.env, emp)
if today.weekday() != 0:
continue
week_start = today - timedelta(days=7)
week_end = today - timedelta(days=1)
ws_fmt = week_start.strftime('%b %d, %Y')
we_fmt = week_end.strftime('%b %d, %Y')
ws_start, _ = get_local_day_boundaries(self.env, week_start, emp)
_, we_end = get_local_day_boundaries(self.env, week_end, emp)
atts = self.sudo().search([ atts = self.sudo().search([
('employee_id', '=', emp.id), ('employee_id', '=', emp.id),
('check_in', '>=', datetime.combine(week_start, datetime.min.time())), ('check_in', '>=', ws_start),
('check_in', '<', datetime.combine(week_end + timedelta(days=1), datetime.min.time())), ('check_in', '<', we_end),
('check_out', '!=', False), ('check_out', '!=', False),
]) ])
@@ -385,47 +497,36 @@ class HrAttendance(models.Model):
absence_count = ActivityLog.search_count([ absence_count = ActivityLog.search_count([
('employee_id', '=', emp.id), ('employee_id', '=', emp.id),
('log_type', '=', 'absent'), ('log_type', '=', 'absent'),
('log_date', '>=', datetime.combine(week_start, datetime.min.time())), ('log_date', '>=', ws_start),
('log_date', '<', datetime.combine(week_end + timedelta(days=1), datetime.min.time())), ('log_date', '<', we_end),
]) ])
streak = emp.x_fclk_ontime_streak or 0 streak = emp.x_fclk_ontime_streak or 0
emp_company = emp.company_id or company
def _row(label, value, bg=False): body = _fclk_email_wrap(
bg_style = 'background:#f8f9fa;' if bg else '' company_name=emp_company.name or company_name,
return ( title='Weekly Summary',
f'<tr style="{bg_style}">' summary=(
f'<td style="padding:10px 14px;border:1px solid #e0e0e0;font-weight:600;">{label}</td>' f'Hello <strong>{emp.name}</strong>, here is your attendance '
f'<td style="padding:10px 14px;border:1px solid #e0e0e0;">{value}</td>' f'summary for <strong>{ws_fmt}</strong> to <strong>{we_fmt}</strong>.'
f'</tr>' ),
) sections=[('Summary', [
('Total Hours', f'{total_net}h'),
body = ( ('Overtime', f'{total_ot}h'),
'<div style="margin:0;padding:0;font-family:Arial,Helvetica,sans-serif;">' ('Penalties', str(penalty_count)),
'<table width="600" style="margin:0 auto;background:#ffffff;border:1px solid #e0e0e0;border-radius:8px;overflow:hidden;">' ('Absences', str(absence_count)),
'<tr><td style="padding:24px 32px;background:#1a1d23;border-radius:8px 8px 0 0;">' ('On-Time Streak', f'{streak} days'),
'<h2 style="color:#10B981;margin:0;">Fusion Clock</h2>' ])],
'<p style="color:#9ca3af;margin:4px 0 0;">Weekly Summary</p>' note='Log in to <a href="/my/clock" style="color:#10B981;">'
'</td></tr>' 'your portal</a> to view full details.',
'<tr><td style="padding:24px 32px;">'
f'<p>Hello <strong>{emp.name}</strong>,</p>'
f'<p>Here is your attendance summary for <strong>{week_start}</strong> to <strong>{week_end}</strong>:</p>'
'<table width="100%" style="margin:16px 0;border-collapse:collapse;">'
+ _row('Total Hours', f'{total_net}h', True)
+ _row('Overtime', f'{total_ot}h', False)
+ _row('Penalties', str(penalty_count), True)
+ _row('Absences', str(absence_count), False)
+ _row('On-Time Streak', f'{streak} days', True)
+ '</table>'
'<p>Log in to <a href="/my/clock" style="color:#10B981;">your portal</a> to view details.</p>'
'<p style="color:#6b7280;font-size:12px;margin-top:16px;">This is an automated message from Fusion Clock.</p>'
'</td></tr></table></div>'
) )
try: try:
from_email = emp_company.email or company_email
mail = self.env['mail.mail'].sudo().create({ mail = self.env['mail.mail'].sudo().create({
'subject': f'Your Weekly Attendance Summary ({week_start} - {week_end})', 'subject': f'Your Weekly Attendance Summary ({ws_fmt} - {we_fmt})',
'email_from': company_email, 'email_from': from_email,
'email_to': emp.work_email, 'email_to': emp.work_email,
'body_html': body, 'body_html': body,
'auto_delete': True, 'auto_delete': True,
@@ -450,7 +551,7 @@ class HrAttendance(models.Model):
'user_id': office_user_id, 'user_id': office_user_id,
'res_model_id': self.env['ir.model']._get_id(res_model), 'res_model_id': self.env['ir.model']._get_id(res_model),
'res_id': res_id, 'res_id': res_id,
'date_deadline': fields.Date.today(), 'date_deadline': get_local_today(self.env),
}) })
except Exception as e: except Exception as e:
_logger.error("Fusion Clock: Failed to create office activity: %s", e) _logger.error("Fusion Clock: Failed to create office activity: %s", e)
@@ -469,9 +570,20 @@ class HrAttendance(models.Model):
pass pass
try: try:
if employee.work_email: if employee.work_email:
company = employee.company_id or self.env.company
company_email = company.email or ''
company_name = company.name or ''
html_body = _fclk_email_wrap(
company_name=company_name,
title=subject,
summary=body,
note='Log in to <a href="/my/clock" style="color:#10B981;">'
'your portal</a> to view your attendance details.',
)
mail_values = { mail_values = {
'subject': f"Fusion Clock: {subject}", 'subject': f"Fusion Clock: {subject}",
'body_html': f"<p>{body}</p>", 'email_from': company_email,
'body_html': html_body,
'email_to': employee.work_email, 'email_to': employee.work_email,
'auto_delete': True, 'auto_delete': True,
} }

View File

@@ -4,6 +4,7 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from odoo import models, fields, api from odoo import models, fields, api
from .tz_utils import get_local_today, get_local_day_boundaries
class HrEmployee(models.Model): class HrEmployee(models.Model):
@@ -117,8 +118,14 @@ class HrEmployee(models.Model):
def _get_fclk_scheduled_times(self, date): def _get_fclk_scheduled_times(self, date):
"""Return (scheduled_in_dt, scheduled_out_dt) for a given date. """Return (scheduled_in_dt, scheduled_out_dt) for a given date.
Uses employee shift if assigned, otherwise global settings. Uses employee shift if assigned, otherwise global settings.
The configured hours are interpreted in the employee's local
timezone and converted to naive-UTC datetimes so they can be
compared with Odoo's UTC-based ``fields.Datetime.now()``.
""" """
import pytz
self.ensure_one() self.ensure_one()
if self.x_fclk_shift_id: if self.x_fclk_shift_id:
in_hour = self.x_fclk_shift_id.start_time in_hour = self.x_fclk_shift_id.start_time
@@ -133,8 +140,24 @@ class HrEmployee(models.Model):
out_h = int(out_hour) out_h = int(out_hour)
out_m = int((out_hour - out_h) * 60) out_m = int((out_hour - out_h) * 60)
scheduled_in = datetime.combine(date, datetime.min.time().replace(hour=in_h, minute=in_m)) tz_name = (
scheduled_out = datetime.combine(date, datetime.min.time().replace(hour=out_h, minute=out_m)) self.resource_id.tz
or (self.user_id.partner_id.tz if self.user_id else False)
or self.company_id.partner_id.tz
or 'UTC'
)
local_tz = pytz.timezone(tz_name)
utc = pytz.UTC
local_in = local_tz.localize(
datetime.combine(date, datetime.min.time().replace(hour=in_h, minute=in_m))
)
local_out = local_tz.localize(
datetime.combine(date, datetime.min.time().replace(hour=out_h, minute=out_m))
)
scheduled_in = local_in.astimezone(utc).replace(tzinfo=None)
scheduled_out = local_out.astimezone(utc).replace(tzinfo=None)
return scheduled_in, scheduled_out return scheduled_in, scheduled_out
def _get_fclk_scheduled_hours(self): def _get_fclk_scheduled_hours(self):
@@ -150,39 +173,44 @@ class HrEmployee(models.Model):
def _compute_absence_counts(self): def _compute_absence_counts(self):
ActivityLog = self.env['fusion.clock.activity.log'].sudo() ActivityLog = self.env['fusion.clock.activity.log'].sudo()
today = fields.Date.today()
month_start = today.replace(day=1)
year_start = today.replace(month=1, day=1)
for emp in self: for emp in self:
today = get_local_today(self.env, emp)
month_start = today.replace(day=1)
year_start = today.replace(month=1, day=1)
month_start_utc, _ = get_local_day_boundaries(self.env, month_start, emp)
year_start_utc, _ = get_local_day_boundaries(self.env, year_start, emp)
emp.x_fclk_absences_this_month = ActivityLog.search_count([ emp.x_fclk_absences_this_month = ActivityLog.search_count([
('employee_id', '=', emp.id), ('employee_id', '=', emp.id),
('log_type', '=', 'absent'), ('log_type', '=', 'absent'),
('log_date', '>=', datetime.combine(month_start, datetime.min.time())), ('log_date', '>=', month_start_utc),
]) ])
emp.x_fclk_absences_this_year = ActivityLog.search_count([ emp.x_fclk_absences_this_year = ActivityLog.search_count([
('employee_id', '=', emp.id), ('employee_id', '=', emp.id),
('log_type', '=', 'absent'), ('log_type', '=', 'absent'),
('log_date', '>=', datetime.combine(year_start, datetime.min.time())), ('log_date', '>=', year_start_utc),
]) ])
def _compute_overtime(self): def _compute_overtime(self):
Attendance = self.env['hr.attendance'].sudo() Attendance = self.env['hr.attendance'].sudo()
today = fields.Date.today()
week_start = today - timedelta(days=today.weekday())
month_start = today.replace(day=1)
for emp in self: for emp in self:
today = get_local_today(self.env, emp)
week_start = today - timedelta(days=today.weekday())
month_start = today.replace(day=1)
week_start_utc, _ = get_local_day_boundaries(self.env, week_start, emp)
month_start_utc, _ = get_local_day_boundaries(self.env, month_start, emp)
week_atts = Attendance.search([ week_atts = Attendance.search([
('employee_id', '=', emp.id), ('employee_id', '=', emp.id),
('check_in', '>=', datetime.combine(week_start, datetime.min.time())), ('check_in', '>=', week_start_utc),
('check_out', '!=', False), ('check_out', '!=', False),
]) ])
emp.x_fclk_overtime_this_week = sum(a.x_fclk_overtime_hours or 0 for a in week_atts) emp.x_fclk_overtime_this_week = sum(a.x_fclk_overtime_hours or 0 for a in week_atts)
month_atts = Attendance.search([ month_atts = Attendance.search([
('employee_id', '=', emp.id), ('employee_id', '=', emp.id),
('check_in', '>=', datetime.combine(month_start, datetime.min.time())), ('check_in', '>=', month_start_utc),
('check_out', '!=', False), ('check_out', '!=', False),
]) ])
emp.x_fclk_overtime_this_month = sum(a.x_fclk_overtime_hours or 0 for a in month_atts) emp.x_fclk_overtime_this_month = sum(a.x_fclk_overtime_hours or 0 for a in month_atts)

View File

@@ -8,7 +8,7 @@ from odoo import models, fields, api
class ResConfigSettings(models.TransientModel): class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings' _inherit = 'res.config.settings'
# -- Work Schedule -- # ── Work Schedule ──────────────────────────────────────────────────
fclk_default_clock_in_time = fields.Float( fclk_default_clock_in_time = fields.Float(
string='Default Clock-In Time', string='Default Clock-In Time',
config_parameter='fusion_clock.default_clock_in_time', config_parameter='fusion_clock.default_clock_in_time',
@@ -21,20 +21,18 @@ class ResConfigSettings(models.TransientModel):
default=17.0, default=17.0,
help="Default scheduled clock-out time (24h format, e.g. 17.0 = 5:00 PM).", help="Default scheduled clock-out time (24h format, e.g. 17.0 = 5:00 PM).",
) )
# -- Break --
fclk_default_break_minutes = fields.Float(
string='Default Break Duration (min)',
config_parameter='fusion_clock.default_break_minutes',
default=30.0,
help="Default unpaid break duration in minutes.",
)
fclk_auto_deduct_break = fields.Boolean( fclk_auto_deduct_break = fields.Boolean(
string='Auto-Deduct Break', string='Auto-Deduct Break',
config_parameter='fusion_clock.auto_deduct_break', config_parameter='fusion_clock.auto_deduct_break',
default=True, default=True,
help="Automatically deduct break from worked hours on clock-out.", help="Automatically deduct break from worked hours on clock-out.",
) )
fclk_default_break_minutes = fields.Float(
string='Default Break Duration (min)',
config_parameter='fusion_clock.default_break_minutes',
default=30.0,
help="Default unpaid break duration in minutes.",
)
fclk_break_threshold_hours = fields.Float( fclk_break_threshold_hours = fields.Float(
string='Break Threshold (hours)', string='Break Threshold (hours)',
config_parameter='fusion_clock.break_threshold_hours', config_parameter='fusion_clock.break_threshold_hours',
@@ -42,30 +40,30 @@ class ResConfigSettings(models.TransientModel):
help="Only deduct break if shift is longer than this many hours.", help="Only deduct break if shift is longer than this many hours.",
) )
# -- Grace Period & Auto Clock-Out -- # ── Attendance Rules ───────────────────────────────────────────────
fclk_enable_auto_clockout = fields.Boolean(
string='Enable Auto Clock-Out',
config_parameter='fusion_clock.enable_auto_clockout',
default=True,
help="Automatically clock out employees who forget. Triggers after shift end time plus grace period, or after max shift hours.",
)
fclk_grace_period_minutes = fields.Float( fclk_grace_period_minutes = fields.Float(
string='Grace Period (min)', string='Grace Period (min)',
config_parameter='fusion_clock.grace_period_minutes', config_parameter='fusion_clock.grace_period_minutes',
default=15.0, default=15.0,
help="Minutes allowed after scheduled end before auto clock-out.", help="Minutes allowed after scheduled end before auto clock-out.",
) )
fclk_enable_auto_clockout = fields.Boolean(
string='Enable Auto Clock-Out',
config_parameter='fusion_clock.enable_auto_clockout',
default=True,
)
fclk_max_shift_hours = fields.Float( fclk_max_shift_hours = fields.Float(
string='Max Shift Length (hours)', string='Max Shift Length (hours)',
config_parameter='fusion_clock.max_shift_hours', config_parameter='fusion_clock.max_shift_hours',
default=12.0, default=12.0,
help="Maximum shift length before auto clock-out (safety net).", help="Maximum shift length before auto clock-out (safety net).",
) )
# -- Penalties --
fclk_enable_penalties = fields.Boolean( fclk_enable_penalties = fields.Boolean(
string='Enable Penalty Tracking', string='Enable Penalty Tracking',
config_parameter='fusion_clock.enable_penalties', config_parameter='fusion_clock.enable_penalties',
default=True, default=True,
help="Deduct minutes from worked hours when employees clock in late or clock out early.",
) )
fclk_penalty_grace_minutes = fields.Float( fclk_penalty_grace_minutes = fields.Float(
string='Penalty Grace (min)', string='Penalty Grace (min)',
@@ -79,8 +77,26 @@ class ResConfigSettings(models.TransientModel):
default=15.0, default=15.0,
help="Minutes deducted from worked hours per penalty occurrence.", help="Minutes deducted from worked hours per penalty occurrence.",
) )
fclk_enable_overtime = fields.Boolean(
string='Enable Overtime Tracking',
config_parameter='fusion_clock.enable_overtime',
default=True,
help="Calculate and track overtime when net hours exceed the daily or weekly threshold.",
)
fclk_daily_overtime_threshold = fields.Float(
string='Daily OT Threshold (hours)',
config_parameter='fusion_clock.daily_overtime_threshold',
default=8.0,
help="Net hours beyond this threshold count as daily overtime.",
)
fclk_weekly_overtime_threshold = fields.Float(
string='Weekly OT Threshold (hours)',
config_parameter='fusion_clock.weekly_overtime_threshold',
default=40.0,
help="Net hours beyond this threshold count as weekly overtime.",
)
# -- Office User & Notifications -- # ── Notifications ──────────────────────────────────────────────────
fclk_office_user_id = fields.Many2one( fclk_office_user_id = fields.Many2one(
'res.users', 'res.users',
string='Office User', string='Office User',
@@ -123,26 +139,7 @@ class ResConfigSettings(models.TransientModel):
help="Send weekly attendance summary to each employee on Monday.", help="Send weekly attendance summary to each employee on Monday.",
) )
# -- Overtime -- # ── Location & Verification ────────────────────────────────────────
fclk_enable_overtime = fields.Boolean(
string='Enable Overtime Tracking',
config_parameter='fusion_clock.enable_overtime',
default=True,
)
fclk_daily_overtime_threshold = fields.Float(
string='Daily OT Threshold (hours)',
config_parameter='fusion_clock.daily_overtime_threshold',
default=8.0,
help="Net hours beyond this threshold count as daily overtime.",
)
fclk_weekly_overtime_threshold = fields.Float(
string='Weekly OT Threshold (hours)',
config_parameter='fusion_clock.weekly_overtime_threshold',
default=40.0,
help="Net hours beyond this threshold count as weekly overtime.",
)
# -- Location --
fclk_enable_ip_fallback = fields.Boolean( fclk_enable_ip_fallback = fields.Boolean(
string='Enable IP Fallback', string='Enable IP Fallback',
config_parameter='fusion_clock.enable_ip_fallback', config_parameter='fusion_clock.enable_ip_fallback',
@@ -155,12 +152,17 @@ class ResConfigSettings(models.TransientModel):
default=False, default=False,
help="Global toggle for selfie verification on clock-in (per-location control).", help="Global toggle for selfie verification on clock-in (per-location control).",
) )
fclk_google_maps_api_key = fields.Char(
string='Google Maps API Key',
config_parameter='fusion_clock.google_maps_api_key',
)
# -- Kiosk -- # ── Kiosk & Portal ─────────────────────────────────────────────────
fclk_enable_kiosk = fields.Boolean( fclk_enable_kiosk = fields.Boolean(
string='Enable Kiosk Mode', string='Enable Kiosk Mode',
config_parameter='fusion_clock.enable_kiosk', config_parameter='fusion_clock.enable_kiosk',
default=False, default=False,
help="Allow employees to clock in/out from a shared device using their PIN code.",
) )
fclk_kiosk_pin_required = fields.Boolean( fclk_kiosk_pin_required = fields.Boolean(
string='Require PIN for Kiosk', string='Require PIN for Kiosk',
@@ -168,23 +170,20 @@ class ResConfigSettings(models.TransientModel):
default=True, default=True,
help="Require employees to enter a PIN when using kiosk mode.", help="Require employees to enter a PIN when using kiosk mode.",
) )
# -- Corrections --
fclk_enable_correction_requests = fields.Boolean( fclk_enable_correction_requests = fields.Boolean(
string='Enable Correction Requests', string='Enable Correction Requests',
config_parameter='fusion_clock.enable_correction_requests', config_parameter='fusion_clock.enable_correction_requests',
default=True, default=True,
help="Allow employees to request timesheet corrections from the portal.", help="Allow employees to request timesheet corrections from the portal.",
) )
fclk_enable_sounds = fields.Boolean(
# -- CSV Export -- string='Enable Clock Sounds',
fclk_csv_column_mapping = fields.Char( config_parameter='fusion_clock.enable_sounds',
string='CSV Column Mapping', default=True,
config_parameter='fusion_clock.csv_column_mapping', help="Play audio confirmation sounds when employees clock in or out.",
help="Custom column names for CSV export (JSON format). Leave blank for defaults.",
) )
# -- Pay Period -- # ── Pay Period & Reports ───────────────────────────────────────────
fclk_pay_period_type = fields.Selection( fclk_pay_period_type = fields.Selection(
[ [
('weekly', 'Weekly'), ('weekly', 'Weekly'),
@@ -195,42 +194,42 @@ class ResConfigSettings(models.TransientModel):
string='Pay Period', string='Pay Period',
config_parameter='fusion_clock.pay_period_type', config_parameter='fusion_clock.pay_period_type',
default='biweekly', default='biweekly',
help="How often attendance reports are generated.",
) )
fclk_pay_period_start = fields.Char( fclk_pay_period_start = fields.Char(
string='Pay Period Anchor Date', string='Pay Period Anchor Date',
config_parameter='fusion_clock.pay_period_start', config_parameter='fusion_clock.pay_period_start',
help="Start date for pay period calculations (YYYY-MM-DD format).", help="Start date for pay period calculations (YYYY-MM-DD format).",
) )
# -- Reports --
fclk_auto_generate_reports = fields.Boolean( fclk_auto_generate_reports = fields.Boolean(
string='Auto-Generate Reports', string='Auto-Generate Reports',
config_parameter='fusion_clock.auto_generate_reports', config_parameter='fusion_clock.auto_generate_reports',
default=True, default=True,
help="Automatically create attendance reports at the end of each pay period.",
)
fclk_send_employee_reports = fields.Boolean(
string='Send Employee Copies',
config_parameter='fusion_clock.send_employee_reports',
default=True,
help="Send each employee a copy of their individual attendance report.",
)
fclk_report_recipient_user_ids = fields.Many2many(
'res.users',
'fclk_report_recipient_user_rel',
'config_id',
'user_id',
string='Internal Report Recipients',
help="Internal users who will receive batch reports.",
) )
fclk_report_recipient_emails = fields.Char( fclk_report_recipient_emails = fields.Char(
string='Report Recipient Emails', string='Report Recipient Emails',
config_parameter='fusion_clock.report_recipient_emails', config_parameter='fusion_clock.report_recipient_emails',
help="Comma-separated email addresses for batch report delivery.", help="Comma-separated email addresses for batch report delivery.",
) )
fclk_send_employee_reports = fields.Boolean( fclk_csv_column_mapping = fields.Char(
string='Send Employee Copies', string='CSV Column Mapping',
config_parameter='fusion_clock.send_employee_reports', config_parameter='fusion_clock.csv_column_mapping',
default=True, help="Custom column names for CSV export (JSON format). Leave blank for defaults.",
help="Send individual report copies to each employee's work email.",
)
# -- Google Maps --
fclk_google_maps_api_key = fields.Char(
string='Google Maps API Key',
config_parameter='fusion_clock.google_maps_api_key',
)
# -- Sounds --
fclk_enable_sounds = fields.Boolean(
string='Enable Clock Sounds',
config_parameter='fusion_clock.enable_sounds',
default=True,
) )
def set_values(self): def set_values(self):
@@ -240,6 +239,11 @@ class ResConfigSettings(models.TransientModel):
ICP.set_param('fusion_clock.office_user_id', str(self.fclk_office_user_id.id)) ICP.set_param('fusion_clock.office_user_id', str(self.fclk_office_user_id.id))
else: else:
ICP.set_param('fusion_clock.office_user_id', '0') ICP.set_param('fusion_clock.office_user_id', '0')
if self.fclk_report_recipient_user_ids:
ICP.set_param('fusion_clock.report_recipient_user_ids',
','.join(str(uid) for uid in self.fclk_report_recipient_user_ids.ids))
else:
ICP.set_param('fusion_clock.report_recipient_user_ids', '')
@api.model @api.model
def get_values(self): def get_values(self):
@@ -248,4 +252,11 @@ class ResConfigSettings(models.TransientModel):
office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0')) office_user_id = int(ICP.get_param('fusion_clock.office_user_id', '0'))
if office_user_id: if office_user_id:
res['fclk_office_user_id'] = office_user_id res['fclk_office_user_id'] = office_user_id
user_ids_str = ICP.get_param('fusion_clock.report_recipient_user_ids', '')
if user_ids_str:
try:
user_ids = [int(x) for x in user_ids_str.split(',') if x.strip()]
res['fclk_report_recipient_user_ids'] = [(6, 0, user_ids)]
except (ValueError, TypeError):
pass
return res return res

View File

@@ -0,0 +1,75 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""
Timezone helpers for Fusion Clock.
All Odoo datetimes are stored in UTC. These helpers derive "today",
date boundaries, and display strings in the **user's local timezone**
so that queries, penalties, and UI all reflect the real calendar day.
Timezone resolution order:
1. Explicit employee.tz (if an employee record is available)
2. env.user.tz (logged-in portal / backend user)
3. env.company.tz (company-level default)
4. 'UTC' (last resort — should rarely happen)
"""
import pytz
from datetime import datetime, timedelta
def _resolve_tz(env, employee=None):
"""Return a pytz timezone from the best available source."""
tz_name = (
(employee.tz if employee else None)
or env.user.tz
or env.company.tz
or 'UTC'
)
try:
return pytz.timezone(tz_name)
except pytz.UnknownTimeZoneError:
return pytz.UTC
def get_local_now(env, employee=None):
"""Return the current datetime in the resolved local timezone (aware)."""
tz = _resolve_tz(env, employee)
return datetime.now(pytz.UTC).astimezone(tz)
def get_local_today(env, employee=None):
"""Return today's date in the resolved local timezone."""
return get_local_now(env, employee).date()
def get_local_day_boundaries(env, date_val, employee=None):
"""
Return (start_utc, end_utc) as **naive** UTC datetimes representing
midnight-to-midnight of *date_val* in the local timezone.
Suitable for Odoo domain filters like:
('check_in', '>=', start_utc),
('check_in', '<', end_utc),
"""
tz = _resolve_tz(env, employee)
local_start = tz.localize(datetime.combine(date_val, datetime.min.time()))
local_end = tz.localize(datetime.combine(date_val + timedelta(days=1), datetime.min.time()))
return (
local_start.astimezone(pytz.UTC).replace(tzinfo=None),
local_end.astimezone(pytz.UTC).replace(tzinfo=None),
)
def utc_to_local_str(dt_utc, env, employee=None, fmt='%I:%M %p'):
"""
Convert a naive-UTC datetime to a formatted string in local timezone.
Returns '' if dt_utc is falsy.
"""
if not dt_utc:
return ''
tz = _resolve_tz(env, employee)
aware = pytz.UTC.localize(dt_utc)
return aware.astimezone(tz).strftime(fmt)

View File

@@ -27,25 +27,37 @@
</div> </div>
<!-- Employee Info --> <!-- Employee Info -->
<table style="width:100%; margin-bottom:16px;"> <table style="width:100%; margin-bottom:16px; border-collapse:collapse;">
<tr> <tr>
<td style="width:50%;"> <td style="width:50%; padding:8px 12px; border:1px solid #e0e0e0;">
<strong>Employee:</strong> <t t-esc="doc.employee_id.name"/> <strong>Employee:</strong> <t t-esc="doc.employee_id.name"/>
</td> </td>
<td style="width:50%; text-align:right;"> <td style="width:50%; padding:8px 12px; border:1px solid #e0e0e0; text-align:right;">
<strong>Company:</strong> <t t-esc="doc.company_id.name"/> <strong>Company:</strong> <t t-esc="doc.company_id.name"/>
</td> </td>
</tr> </tr>
<tr> <tr>
<td> <td style="padding:8px 12px; border:1px solid #e0e0e0;">
<strong>Department:</strong> <t t-esc="doc.employee_id.department_id.name or 'N/A'"/> <strong>Department:</strong> <t t-esc="doc.employee_id.department_id.name or 'N/A'"/>
</td> </td>
<td style="text-align:right;"> <td style="padding:8px 12px; border:1px solid #e0e0e0; text-align:right;">
<strong>Schedule:</strong> <t t-esc="dict(doc._fields['schedule_type'].selection).get(doc.schedule_type, '')"/> <strong>Schedule:</strong> <t t-esc="dict(doc._fields['schedule_type'].selection).get(doc.schedule_type, '')"/>
</td> </td>
</tr> </tr>
</table> </table>
<!-- Pay Period -->
<table style="width:100%; margin-bottom:16px; border-collapse:collapse;">
<tr>
<td style="padding:8px 12px; border:1px solid #e0e0e0; background:#f8f9fa;">
<strong>Pay Period:</strong>
<t t-esc="doc.date_start" t-options="{'widget': 'date', 'format': 'MMM d, yyyy'}"/>
&#8212;
<t t-esc="doc.date_end" t-options="{'widget': 'date', 'format': 'MMM d, yyyy'}"/>
</td>
</tr>
</table>
<!-- Summary Box --> <!-- Summary Box -->
<table style="width:100%; border-collapse:collapse; margin-bottom:24px; background:#f8f9fa; border-radius:8px;"> <table style="width:100%; border-collapse:collapse; margin-bottom:24px; background:#f8f9fa; border-radius:8px;">
<tr> <tr>
@@ -80,6 +92,13 @@
</div> </div>
<div style="font-size:11px; color:#6b7280;">Penalties</div> <div style="font-size:11px; color:#6b7280;">Penalties</div>
</td> </td>
<td style="padding:12px; text-align:center; border:1px solid #e0e0e0;">
<div style="font-size:20px; font-weight:bold;"
t-attf-style="color: {{ '#3b82f6' if doc.leave_days > 0 else '#1a1d23' }};">
<t t-esc="doc.leave_days"/>
</div>
<div style="font-size:11px; color:#6b7280;">Leave Days</div>
</td>
</tr> </tr>
</table> </table>
@@ -129,6 +148,40 @@
</tbody> </tbody>
</table> </table>
<!-- Leave Days -->
<t t-if="doc.leave_request_ids">
<h4 style="color:#1a1d23; margin:24px 0 8px;">Leave Days</h4>
<table style="width:100%; border-collapse:collapse; font-size:11px;">
<thead>
<tr style="background:#3b82f6; color:white;">
<th style="padding:8px; text-align:left;">Date</th>
<th style="padding:8px; text-align:left;">Reason</th>
<th style="padding:8px; text-align:center;">Status</th>
</tr>
</thead>
<tbody>
<t t-foreach="doc.leave_request_ids.sorted(key=lambda l: l.leave_date)" t-as="lv">
<tr t-attf-style="background: {{ '#f0f7ff' if lv_index % 2 == 0 else '#ffffff' }};">
<td style="padding:6px 8px; border-bottom:1px solid #e0e0e0;">
<t t-esc="lv.leave_date" t-options="{'widget': 'date'}"/>
</td>
<td style="padding:6px 8px; border-bottom:1px solid #e0e0e0;">
<t t-esc="lv.reason"/>
</td>
<td style="padding:6px 8px; border-bottom:1px solid #e0e0e0; text-align:center;">
<t t-if="lv.state == 'reviewed'">
<span style="color:#10B981; font-weight:bold;">Reviewed</span>
</t>
<t t-else="">
<span style="color:#3b82f6;">Auto-Approved</span>
</t>
</td>
</tr>
</t>
</tbody>
</table>
</t>
<!-- Footer --> <!-- Footer -->
<div style="margin-top:24px; padding-top:12px; border-top:1px solid #e0e0e0; text-align:center;"> <div style="margin-top:24px; padding-top:12px; border-top:1px solid #e0e0e0; text-align:center;">
<p style="color:#9ca3af; font-size:10px;"> <p style="color:#9ca3af; font-size:10px;">

View File

@@ -37,6 +37,7 @@
<th style="padding:8px; text-align:center;">Breaks (min)</th> <th style="padding:8px; text-align:center;">Breaks (min)</th>
<th style="padding:8px; text-align:center;">Late In</th> <th style="padding:8px; text-align:center;">Late In</th>
<th style="padding:8px; text-align:center;">Early Out</th> <th style="padding:8px; text-align:center;">Early Out</th>
<th style="padding:8px; text-align:center;">Leave</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -45,6 +46,7 @@
<t t-set="emp_dates" t-value="set(a.check_in.date() for a in emp_atts if a.check_in)"/> <t t-set="emp_dates" t-value="set(a.check_in.date() for a in emp_atts if a.check_in)"/>
<t t-set="late_count" t-value="len(doc.env['fusion.clock.penalty'].search([('employee_id', '=', emp.id), ('date', '>=', doc.date_start), ('date', '&lt;=', doc.date_end), ('penalty_type', '=', 'late_in')]))"/> <t t-set="late_count" t-value="len(doc.env['fusion.clock.penalty'].search([('employee_id', '=', emp.id), ('date', '>=', doc.date_start), ('date', '&lt;=', doc.date_end), ('penalty_type', '=', 'late_in')]))"/>
<t t-set="early_count" t-value="len(doc.env['fusion.clock.penalty'].search([('employee_id', '=', emp.id), ('date', '>=', doc.date_start), ('date', '&lt;=', doc.date_end), ('penalty_type', '=', 'early_out')]))"/> <t t-set="early_count" t-value="len(doc.env['fusion.clock.penalty'].search([('employee_id', '=', emp.id), ('date', '>=', doc.date_start), ('date', '&lt;=', doc.date_end), ('penalty_type', '=', 'early_out')]))"/>
<t t-set="emp_leaves" t-value="doc.leave_request_ids.filtered(lambda l: l.employee_id == emp)"/>
<tr t-attf-style="background: {{ '#f8f9fa' if emp_index % 2 == 0 else '#ffffff' }};"> <tr t-attf-style="background: {{ '#f8f9fa' if emp_index % 2 == 0 else '#ffffff' }};">
<td style="padding:6px 8px; border-bottom:1px solid #e0e0e0; font-weight:bold;"> <td style="padding:6px 8px; border-bottom:1px solid #e0e0e0; font-weight:bold;">
<t t-esc="emp.name"/> <t t-esc="emp.name"/>
@@ -69,6 +71,10 @@
t-attf-style="color: {{ '#ef4444' if early_count else '#1a1d23' }};"> t-attf-style="color: {{ '#ef4444' if early_count else '#1a1d23' }};">
<t t-esc="early_count"/> <t t-esc="early_count"/>
</td> </td>
<td style="padding:6px 8px; border-bottom:1px solid #e0e0e0; text-align:center;"
t-attf-style="color: {{ '#3b82f6' if emp_leaves else '#1a1d23' }};">
<t t-esc="len(emp_leaves)"/>
</td>
</tr> </tr>
</t> </t>
</tbody> </tbody>

View File

@@ -294,13 +294,10 @@ body:has(.fclk-app) .o_footer {
.fclk-btn-icon { .fclk-btn-icon {
position: relative; position: relative;
z-index: 1; z-index: 1;
display: flex;
align-items: center;
justify-content: center;
} }
.fclk-btn-icon svg[id="fclk-btn-icon-play"] { #fclk-btn-icon-play {
transform: translateX(3px); transform: translateX(4px);
} }
.fclk-btn-ripple { .fclk-btn-ripple {
@@ -605,22 +602,22 @@ body:has(.fclk-app) .o_footer {
/* Standalone fallbacks for wizard modals rendered outside .fclk-app */ /* Standalone fallbacks for wizard modals rendered outside .fclk-app */
.fclk-wizard-overlay { .fclk-wizard-overlay {
--fclk-card: var(--fclk-card, #ffffff); --fclk-wiz-card: #ffffff;
--fclk-card-border: var(--fclk-card-border, #e5e7eb); --fclk-wiz-card-border: #e5e7eb;
--fclk-bg: var(--fclk-bg, #f3f4f6); --fclk-wiz-bg: #f3f4f6;
--fclk-text: var(--fclk-text, #1f2937); --fclk-wiz-text: #1f2937;
--fclk-text-muted: var(--fclk-text-muted, #6b7280); --fclk-wiz-text-muted: #6b7280;
--fclk-text-dim: var(--fclk-text-dim, #9ca3af); --fclk-wiz-text-dim: #9ca3af;
--fclk-green: var(--fclk-green, #10B981); --fclk-wiz-green: #10B981;
--fclk-green-glow: var(--fclk-green-glow, rgba(16, 185, 129, 0.25)); --fclk-wiz-green-glow: rgba(16, 185, 129, 0.25);
--fclk-hover-bg: var(--fclk-hover-bg, #f9fafb); --fclk-wiz-hover-bg: #f9fafb;
position: fixed; position: fixed;
top: 0; top: 0;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
z-index: 300; z-index: 1055;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -629,26 +626,26 @@ body:has(.fclk-app) .o_footer {
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.fclk-wizard-overlay { .fclk-wizard-overlay {
--fclk-card: #1a1d23; --fclk-wiz-card: #1a1d23;
--fclk-card-border: #2a2d35; --fclk-wiz-card-border: #2a2d35;
--fclk-bg: #0f1117; --fclk-wiz-bg: #0f1117;
--fclk-text: #ffffff; --fclk-wiz-text: #ffffff;
--fclk-text-muted: #9ca3af; --fclk-wiz-text-muted: #9ca3af;
--fclk-text-dim: #6b7280; --fclk-wiz-text-dim: #6b7280;
--fclk-green-glow: rgba(16, 185, 129, 0.3); --fclk-wiz-green-glow: rgba(16, 185, 129, 0.3);
--fclk-hover-bg: #1e2128; --fclk-wiz-hover-bg: #1e2128;
} }
} }
html.o_dark .fclk-wizard-overlay { html.o_dark .fclk-wizard-overlay {
--fclk-card: #1a1d23; --fclk-wiz-card: #1a1d23;
--fclk-card-border: #2a2d35; --fclk-wiz-card-border: #2a2d35;
--fclk-bg: #0f1117; --fclk-wiz-bg: #0f1117;
--fclk-text: #ffffff; --fclk-wiz-text: #ffffff;
--fclk-text-muted: #9ca3af; --fclk-wiz-text-muted: #9ca3af;
--fclk-text-dim: #6b7280; --fclk-wiz-text-dim: #6b7280;
--fclk-green-glow: rgba(16, 185, 129, 0.3); --fclk-wiz-green-glow: rgba(16, 185, 129, 0.3);
--fclk-hover-bg: #1e2128; --fclk-wiz-hover-bg: #1e2128;
} }
.fclk-wizard-backdrop { .fclk-wizard-backdrop {
@@ -664,8 +661,8 @@ html.o_dark .fclk-wizard-overlay {
.fclk-wizard-dialog { .fclk-wizard-dialog {
position: relative; position: relative;
background: var(--fclk-card); background: var(--fclk-wiz-card, #ffffff);
border: 1px solid var(--fclk-card-border); border: 1px solid var(--fclk-wiz-card-border, #e5e7eb);
border-radius: 20px; border-radius: 20px;
width: 100%; width: 100%;
max-width: 440px; max-width: 440px;
@@ -693,7 +690,7 @@ html.o_dark .fclk-wizard-overlay {
.fclk-wizard-header { .fclk-wizard-header {
padding: 28px 24px 20px; padding: 28px 24px 20px;
text-align: center; text-align: center;
border-bottom: 1px solid var(--fclk-card-border); border-bottom: 1px solid var(--fclk-wiz-card-border, #e5e7eb);
} }
.fclk-wizard-header-icon { .fclk-wizard-header-icon {
@@ -722,7 +719,7 @@ html.o_dark .fclk-wizard-overlay {
} }
.fclk-wizard-title { .fclk-wizard-title {
color: var(--fclk-text); color: var(--fclk-wiz-text, #1f2937);
font-size: 20px; font-size: 20px;
font-weight: 700; font-weight: 700;
margin: 0 0 6px; margin: 0 0 6px;
@@ -730,7 +727,7 @@ html.o_dark .fclk-wizard-overlay {
} }
.fclk-wizard-subtitle { .fclk-wizard-subtitle {
color: var(--fclk-text-muted); color: var(--fclk-wiz-text-muted, #6b7280);
font-size: 13px; font-size: 13px;
line-height: 1.5; line-height: 1.5;
margin: 0; margin: 0;
@@ -752,14 +749,14 @@ html.o_dark .fclk-wizard-overlay {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
color: var(--fclk-text); color: var(--fclk-wiz-text, #1f2937);
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
margin-bottom: 8px; margin-bottom: 8px;
} }
.fclk-wizard-label svg { .fclk-wizard-label svg {
color: var(--fclk-text-muted); color: var(--fclk-wiz-text-muted, #6b7280);
flex-shrink: 0; flex-shrink: 0;
} }
@@ -770,24 +767,24 @@ html.o_dark .fclk-wizard-overlay {
.fclk-wizard-input { .fclk-wizard-input {
width: 100%; width: 100%;
background: var(--fclk-bg); background: var(--fclk-wiz-bg, #f3f4f6);
border: 1.5px solid var(--fclk-card-border); border: 1.5px solid var(--fclk-wiz-card-border, #e5e7eb);
border-radius: 12px; border-radius: 12px;
padding: 12px 14px; padding: 12px 14px;
font-size: 14px; font-size: 14px;
color: var(--fclk-text); color: var(--fclk-wiz-text, #1f2937);
transition: border-color 0.2s, box-shadow 0.2s; transition: border-color 0.2s, box-shadow 0.2s;
outline: none; outline: none;
font-family: inherit; font-family: inherit;
} }
.fclk-wizard-input:focus { .fclk-wizard-input:focus {
border-color: var(--fclk-green); border-color: var(--fclk-wiz-green, #10B981);
box-shadow: 0 0 0 3px var(--fclk-green-glow); box-shadow: 0 0 0 3px var(--fclk-wiz-green-glow, rgba(16, 185, 129, 0.25));
} }
.fclk-wizard-input::placeholder { .fclk-wizard-input::placeholder {
color: var(--fclk-text-dim); color: var(--fclk-wiz-text-dim, #9ca3af);
} }
.fclk-wizard-textarea { .fclk-wizard-textarea {
@@ -797,7 +794,7 @@ html.o_dark .fclk-wizard-overlay {
.fclk-wizard-hint { .fclk-wizard-hint {
display: block; display: block;
color: var(--fclk-text-dim); color: var(--fclk-wiz-text-dim, #9ca3af);
font-size: 11px; font-size: 11px;
margin-top: 6px; margin-top: 6px;
} }
@@ -807,7 +804,7 @@ html.o_dark .fclk-wizard-overlay {
display: flex; display: flex;
gap: 10px; gap: 10px;
justify-content: flex-end; justify-content: flex-end;
border-top: 1px solid var(--fclk-card-border); border-top: 1px solid var(--fclk-wiz-card-border, #e5e7eb);
} }
.fclk-wizard-btn { .fclk-wizard-btn {
@@ -852,20 +849,20 @@ html.o_dark .fclk-wizard-overlay {
} }
.fclk-wizard-btn--secondary { .fclk-wizard-btn--secondary {
background: var(--fclk-bg); background: var(--fclk-wiz-bg, #f3f4f6);
color: var(--fclk-text-muted); color: var(--fclk-wiz-text-muted, #6b7280);
border: 1px solid var(--fclk-card-border); border: 1px solid var(--fclk-wiz-card-border, #e5e7eb);
} }
.fclk-wizard-btn--secondary:hover:not(:disabled) { .fclk-wizard-btn--secondary:hover:not(:disabled) {
background: var(--fclk-hover-bg); background: var(--fclk-wiz-hover-bg, #f9fafb);
color: var(--fclk-text); color: var(--fclk-wiz-text, #1f2937);
} }
/* Clock-out confirmation summary card */ /* Clock-out confirmation summary card */
.fclk-clockout-summary { .fclk-clockout-summary {
background: var(--fclk-bg); background: var(--fclk-wiz-bg, #f3f4f6);
border: 1px solid var(--fclk-card-border); border: 1px solid var(--fclk-wiz-card-border, #e5e7eb);
border-radius: 12px; border-radius: 12px;
padding: 16px; padding: 16px;
} }
@@ -878,16 +875,16 @@ html.o_dark .fclk-wizard-overlay {
} }
.fclk-clockout-summary-row + .fclk-clockout-summary-row { .fclk-clockout-summary-row + .fclk-clockout-summary-row {
border-top: 1px solid var(--fclk-card-border); border-top: 1px solid var(--fclk-wiz-card-border, #e5e7eb);
} }
.fclk-clockout-summary-label { .fclk-clockout-summary-label {
color: var(--fclk-text-muted); color: var(--fclk-wiz-text-muted, #6b7280);
font-size: 13px; font-size: 13px;
} }
.fclk-clockout-summary-value { .fclk-clockout-summary-value {
color: var(--fclk-text); color: var(--fclk-wiz-text, #1f2937);
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
} }
@@ -917,13 +914,13 @@ html.o_dark .fclk-wizard-overlay {
} }
.fclk-modal-item-name { .fclk-modal-item-name {
color: var(--fclk-text); color: var(--fclk-wiz-text, #1f2937);
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
} }
.fclk-modal-item-addr { .fclk-modal-item-addr {
color: var(--fclk-text-muted); color: var(--fclk-wiz-text-muted, #6b7280);
font-size: 12px; font-size: 12px;
} }

View File

@@ -535,10 +535,12 @@ export class FusionClockPortal extends Interaction {
const reasonEl = document.getElementById("fclk-reason-text"); const reasonEl = document.getElementById("fclk-reason-text");
const timeEl = document.getElementById("fclk-reason-time"); const timeEl = document.getElementById("fclk-reason-time");
const reason = reasonEl ? reasonEl.value.trim() : ""; const reason = reasonEl ? reasonEl.value.trim() : "";
const depTime = timeEl ? timeEl.value.trim() : ""; const rawTime = timeEl ? timeEl.value.trim() : "";
const depTime = rawTime ? new Date(rawTime).toISOString() : "";
if (!reason) { if (!reason) {
this._showToast("Please provide a reason.", "error"); this._showToast("Please provide a reason.", "error");
return; return;
} }

View File

@@ -356,9 +356,10 @@ export class FusionClockPortalFAB extends Interaction {
const submitBtn = document.getElementById("fclk-pfab-reason-submit-btn"); const submitBtn = document.getElementById("fclk-pfab-reason-submit-btn");
if (submitBtn) submitBtn.disabled = true; if (submitBtn) submitBtn.disabled = true;
try { try {
const rawTime = timeEl ? timeEl.value.trim() : "";
await rpc("/fusion_clock/submit_reason", { await rpc("/fusion_clock/submit_reason", {
reason: reason, reason: reason,
departure_time: timeEl ? timeEl.value : "", departure_time: rawTime ? new Date(rawTime).toISOString() : "",
}); });
modal.style.display = "none"; modal.style.display = "none";
if (reasonEl) reasonEl.value = ""; if (reasonEl) reasonEl.value = "";

View File

@@ -1,21 +1,25 @@
/** @odoo-module **/ /** @odoo-module **/
import { Component, useState, onWillStart, onMounted, onWillUnmount } from "@odoo/owl"; import { Component, useState, onWillStart, onMounted, onWillUnmount } from "@odoo/owl";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { useDropdownState } from "@web/core/dropdown/dropdown_hooks";
import { rpc } from "@web/core/network/rpc"; import { rpc } from "@web/core/network/rpc";
import { registry } from "@web/core/registry"; import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks"; import { useService } from "@web/core/utils/hooks";
export class FusionClockFAB extends Component { export class FusionClockFAB extends Component {
static props = {}; static props = {};
static template = "fusion_clock.ClockFAB"; static template = "fusion_clock.ClockSystray";
static components = { Dropdown, DropdownItem };
setup() { setup() {
this.notification = useService("notification"); this.notification = useService("notification");
this.dropdown = useDropdownState();
this.state = useState({ this.state = useState({
isCheckedIn: false, isCheckedIn: false,
isDisplayed: false, isDisplayed: false,
expanded: false,
checkInTime: null, checkInTime: null,
locationName: "", locationName: "",
timerDisplay: "00:00:00", timerDisplay: "00:00:00",
@@ -40,38 +44,22 @@ export class FusionClockFAB extends Component {
if (this.state.isCheckedIn) { if (this.state.isCheckedIn) {
this._startTimer(); this._startTimer();
} }
// Poll every 15s to stay in sync with portal clock-outs
this._pollInterval = setInterval(() => this._fetchStatus(), 15000); this._pollInterval = setInterval(() => this._fetchStatus(), 15000);
// Re-sync immediately when browser tab regains focus
this._onFocus = () => this._fetchStatus(); this._onFocus = () => this._fetchStatus();
window.addEventListener("focus", this._onFocus); window.addEventListener("focus", this._onFocus);
// Close panel when clicking outside
this._onDocClick = (ev) => {
if (!this.state.expanded) return;
const el = ev.target.closest(".fclk-fab-wrapper");
if (!el) this.state.expanded = false;
};
document.addEventListener("click", this._onDocClick, true);
}); });
onWillUnmount(() => { onWillUnmount(() => {
this._stopTimer(); this._stopTimer();
if (this._pollInterval) clearInterval(this._pollInterval); if (this._pollInterval) clearInterval(this._pollInterval);
if (this._onDocClick) document.removeEventListener("click", this._onDocClick, true);
if (this._onFocus) window.removeEventListener("focus", this._onFocus); if (this._onFocus) window.removeEventListener("focus", this._onFocus);
}); });
} }
togglePanel() { // =================================================================
this.state.expanded = !this.state.expanded; // Server sync
this.state.error = ""; // =================================================================
// Always re-fetch when opening the panel
if (this.state.expanded) {
this._fetchStatus();
}
}
async _fetchStatus() { async _fetchStatus() {
try { try {
@@ -83,6 +71,10 @@ export class FusionClockFAB extends Component {
this.state.todayHours = (result.today_hours || 0).toFixed(1); this.state.todayHours = (result.today_hours || 0).toFixed(1);
this.state.weekHours = (result.week_hours || 0).toFixed(1); this.state.weekHours = (result.week_hours || 0).toFixed(1);
if (result.pending_reason) {
this.state.showReasonDialog = true;
}
if (result.is_checked_in && result.check_in) { if (result.is_checked_in && result.check_in) {
const serverTime = new Date(result.check_in + "Z"); const serverTime = new Date(result.check_in + "Z");
const wasRunning = this.state.isCheckedIn; const wasRunning = this.state.isCheckedIn;
@@ -117,8 +109,13 @@ export class FusionClockFAB extends Component {
} }
} }
// =================================================================
// Clock actions
// =================================================================
async onClockAction() { async onClockAction() {
if (this.state.isCheckedIn) { if (this.state.isCheckedIn) {
this.dropdown.close();
this.state.showClockoutConfirm = true; this.state.showClockoutConfirm = true;
return; return;
} }
@@ -181,6 +178,7 @@ export class FusionClockFAB extends Component {
if (result.requires_reason) { if (result.requires_reason) {
this.state.loading = false; this.state.loading = false;
this.dropdown.close();
this.state.showReasonDialog = true; this.state.showReasonDialog = true;
this.state.reasonText = ""; this.state.reasonText = "";
this.state.reasonTime = ""; this.state.reasonTime = "";
@@ -214,6 +212,10 @@ export class FusionClockFAB extends Component {
this.state.loading = false; this.state.loading = false;
} }
// =================================================================
// Reason dialog
// =================================================================
onReasonTextInput(ev) { onReasonTextInput(ev) {
this.state.reasonText = ev.target.value; this.state.reasonText = ev.target.value;
} }
@@ -243,13 +245,17 @@ export class FusionClockFAB extends Component {
this.state.reasonText = ""; this.state.reasonText = "";
this.state.reasonTime = ""; this.state.reasonTime = "";
this.state.reasonSubmitting = false; this.state.reasonSubmitting = false;
await this._executeClockAction(); this.notification.add("Reason submitted. You can now clock in.", { type: "success" });
} catch (e) { } catch (e) {
this.state.error = "Failed to submit reason."; this.state.error = "Failed to submit reason.";
this.state.reasonSubmitting = false; this.state.reasonSubmitting = false;
} }
} }
// =================================================================
// Timer
// =================================================================
get confirmCheckinDisplay() { get confirmCheckinDisplay() {
if (!this.state.checkInTime) return "--"; if (!this.state.checkInTime) return "--";
const d = this.state.checkInTime; const d = this.state.checkInTime;
@@ -293,6 +299,11 @@ export class FusionClockFAB extends Component {
} }
} }
registry.category("main_components").add("FusionClockFAB", { const systrayRegistry = registry.category("systray");
if (systrayRegistry.contains("hr_attendance.attendance_menu")) {
systrayRegistry.remove("hr_attendance.attendance_menu");
}
registry.category("systray").add("fusion_clock.ClockSystray", {
Component: FusionClockFAB, Component: FusionClockFAB,
}); }, { sequence: 101 });

View File

@@ -1,6 +1,6 @@
/* ============================================================ /* ============================================================
Fusion Clock - Floating Action Button (FAB) Fusion Clock - Systray Icon & Dropdown Panel
Bottom-left corner clock widget with ripple animation Top-right navbar clock widget with pulse animation
Theme-aware: adapts to Odoo light / dark mode Theme-aware: adapts to Odoo light / dark mode
============================================================ */ ============================================================ */
@@ -14,7 +14,6 @@
--fclk-fab-divider: #e5e7eb; --fclk-fab-divider: #e5e7eb;
--fclk-fab-location-bg: rgba(16, 185, 129, 0.08); --fclk-fab-location-bg: rgba(16, 185, 129, 0.08);
--fclk-fab-error-bg: rgba(239, 68, 68, 0.06); --fclk-fab-error-bg: rgba(239, 68, 68, 0.06);
--fclk-fab-arrow-bg: #ffffff;
} }
// ---- Dark-mode tokens ---- // ---- Dark-mode tokens ----
@@ -27,7 +26,6 @@ html.o_dark {
--fclk-fab-divider: #3a3d48; --fclk-fab-divider: #3a3d48;
--fclk-fab-location-bg: rgba(16, 185, 129, 0.1); --fclk-fab-location-bg: rgba(16, 185, 129, 0.1);
--fclk-fab-error-bg: rgba(239, 68, 68, 0.1); --fclk-fab-error-bg: rgba(239, 68, 68, 0.1);
--fclk-fab-arrow-bg: #1e2028;
} }
// Static color palette // Static color palette
@@ -36,349 +34,214 @@ $fclk-blue: #3b82f6;
$fclk-green: #10B981; $fclk-green: #10B981;
$fclk-red: #ef4444; $fclk-red: #ef4444;
// Gradient used on the FAB (teal-to-blue like the portal header)
$fclk-gradient: linear-gradient(135deg, $fclk-teal 0%, #2563eb 100%); $fclk-gradient: linear-gradient(135deg, $fclk-teal 0%, #2563eb 100%);
$fclk-gradient-active: linear-gradient(135deg, $fclk-green 0%, $fclk-teal 100%); $fclk-gradient-active: linear-gradient(135deg, $fclk-green 0%, $fclk-teal 100%);
// =========================================================== // ===========================================================
// Wrapper - anchored bottom-LEFT // Systray Icon & Dropdown
// =========================================================== // ===========================================================
.fclk-fab-wrapper {
position: fixed;
bottom: 24px;
left: 24px;
z-index: 1050;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 12px;
pointer-events: none;
> * { pointer-events: auto; } .fclk-systray-btn {
}
// ===========================================================
// FAB Button
// ===========================================================
.fclk-fab-btn {
position: relative; position: relative;
width: 54px; background: none;
height: 54px;
border-radius: 50%;
border: none; border: none;
background: $fclk-gradient; padding: 0;
color: #fff;
font-size: 21px;
cursor: pointer; cursor: pointer;
box-shadow: 0 4px 20px rgba($fclk-teal, 0.35);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
outline: none; width: 20px;
overflow: visible; height: 20px;
}
&:hover { .fclk-systray-icon {
transform: scale(1.1); position: relative;
box-shadow: 0 6px 28px rgba($fclk-teal, 0.45); display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
.fa-clock-o {
font-size: 16px;
position: relative;
z-index: 1;
transition: color 0.3s;
} }
&:active { &--in {
transform: scale(0.93); .fa-clock-o {
} color: var(--bs-success, var(--o-success, #198754));
}
// Clocked-in: green-teal gradient &::before,
&.fclk-fab-btn--active { &::after {
background: $fclk-gradient-active; content: '';
box-shadow: 0 4px 20px rgba($fclk-green, 0.4); position: absolute;
top: 50%;
left: 50%;
width: 14px;
height: 14px;
border-radius: 50%;
border: 1.5px solid var(--bs-success, var(--o-success, #198754));
transform: translate(-50%, -50%) scale(1);
animation: fclk-wave 2.4s infinite ease-out;
}
&:hover { &::after {
box-shadow: 0 6px 28px rgba($fclk-green, 0.5); animation-delay: 1.2s;
} }
} }
// Panel-open: muted &--out {
&.fclk-fab-btn--open { .fa-clock-o {
background: #374151; color: var(--bs-danger, var(--o-danger, #dc3545));
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25); }
color: #d1d5db;
}
.fclk-fab-icon {
position: relative;
z-index: 2;
transition: transform 0.3s ease;
line-height: 1;
}
&.fclk-fab-btn--open .fclk-fab-icon {
transform: rotate(90deg);
} }
} }
// ---- Ripple rings radiating outward from the FAB ---- @keyframes fclk-wave {
.fclk-fab-ripple-ring {
position: absolute;
top: 50%;
left: 50%;
width: 54px;
height: 54px;
border-radius: 50%;
border: 2px solid rgba($fclk-green, 0.5);
transform: translate(-50%, -50%) scale(1);
pointer-events: none;
z-index: 1;
&.fclk-fab-ripple-ring--1 {
animation: fclk-ripple-out 2.4s ease-out infinite;
}
&.fclk-fab-ripple-ring--2 {
animation: fclk-ripple-out 2.4s ease-out 0.8s infinite;
}
&.fclk-fab-ripple-ring--3 {
animation: fclk-ripple-out 2.4s ease-out 1.6s infinite;
}
}
@keyframes fclk-ripple-out {
0% { 0% {
transform: translate(-50%, -50%) scale(1); transform: translate(-50%, -50%) scale(1);
opacity: 0.55; opacity: 0.7;
} }
100% { 100% {
transform: translate(-50%, -50%) scale(2.6); transform: translate(-50%, -50%) scale(2.8);
opacity: 0; opacity: 0;
} }
} }
// ---- Mini timer badge ---- .fclk-systray-dropdown {
.fclk-fab-badge {
position: absolute;
top: -8px;
left: 50%;
transform: translateX(-50%);
background: #111827;
color: $fclk-green;
font-size: 9px;
font-weight: 700;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
padding: 2px 7px;
border-radius: 10px;
white-space: nowrap;
letter-spacing: 0.5px;
border: 1px solid rgba($fclk-green, 0.35);
pointer-events: none;
z-index: 3;
animation: fclk-badge-in 0.3s ease;
}
@keyframes fclk-badge-in {
from { opacity: 0; transform: translateX(-50%) translateY(4px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
// ===========================================================
// Expanded Panel
// ===========================================================
.fclk-fab-panel {
width: 280px; width: 280px;
background: var(--fclk-fab-panel-bg); border-radius: 12px !important;
border: 1px solid var(--fclk-fab-panel-border); overflow: hidden;
border-radius: 16px; box-shadow: var(--fclk-fab-panel-shadow) !important;
padding: 18px;
box-shadow: var(--fclk-fab-panel-shadow);
animation: fclk-panel-slide-up 0.25s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
} }
@keyframes fclk-panel-slide-up { .fclk-systray-panel {
from { opacity: 0; transform: translateY(12px) scale(0.96); } padding: 16px;
to { opacity: 1; transform: translateY(0) scale(1); }
}
// Arrow pointing down toward the FAB
.fclk-fab-panel-arrow {
position: absolute;
bottom: -6px;
left: 22px;
width: 12px;
height: 12px;
background: var(--fclk-fab-arrow-bg);
border-right: 1px solid var(--fclk-fab-panel-border);
border-bottom: 1px solid var(--fclk-fab-panel-border);
transform: rotate(45deg);
}
// ---- Header row ----
.fclk-fab-panel-header {
display: flex; display: flex;
justify-content: space-between; flex-direction: column;
align-items: center; gap: 12px;
margin-bottom: 14px; background: var(--fclk-fab-panel-bg);
} }
.fclk-fab-panel-title { .fclk-systray-header {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
color: var(--fclk-fab-text);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.3px;
} }
.fclk-fab-status-dot { .fclk-systray-header-dot {
width: 8px; font-size: 8px;
height: 8px;
border-radius: 50%;
background: #9ca3af;
flex-shrink: 0; flex-shrink: 0;
&.active {
background: $fclk-green;
box-shadow: 0 0 6px rgba($fclk-green, 0.5);
}
} }
.fclk-fab-open-link { .fclk-systray-header-text {
color: var(--fclk-fab-muted); font-weight: 600;
font-size: 13px; font-size: 13px;
text-decoration: none;
transition: color 0.2s;
&:hover { color: $fclk-blue; }
}
// ---- Location chip ----
.fclk-fab-location {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: $fclk-green;
background: var(--fclk-fab-location-bg);
border-radius: 8px;
padding: 6px 10px;
margin-bottom: 12px;
.fa { font-size: 12px; }
}
// ---- Timer ----
.fclk-fab-timer {
text-align: center;
color: var(--fclk-fab-text); color: var(--fclk-fab-text);
font-size: 28px;
font-weight: 300;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
letter-spacing: 2px;
font-variant-numeric: tabular-nums;
margin-bottom: 14px;
line-height: 1;
} }
// ---- Stats row ---- .fclk-systray-link {
.fclk-fab-stats { margin-left: auto;
color: var(--fclk-fab-muted);
font-size: 12px;
&:hover { color: var(--fclk-fab-text); }
}
.fclk-systray-location {
font-size: 12px;
color: var(--fclk-fab-muted);
.fa { margin-right: 4px; }
}
.fclk-systray-timer {
font-size: 28px;
font-weight: 700;
text-align: center;
letter-spacing: 2px;
color: var(--fclk-fab-text);
font-variant-numeric: tabular-nums;
padding: 4px 0;
}
.fclk-systray-stats {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 16px; gap: 16px;
margin-bottom: 14px; padding: 8px;
background: var(--fclk-fab-location-bg);
border-radius: 8px;
} }
.fclk-fab-stat { .fclk-systray-stat {
text-align: center; display: flex;
flex-direction: column;
.fclk-fab-stat-val { align-items: center;
display: block; gap: 2px;
color: var(--fclk-fab-text);
font-size: 16px;
font-weight: 700;
}
.fclk-fab-stat-lbl {
display: block;
color: var(--fclk-fab-muted);
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.6px;
margin-top: 2px;
}
} }
.fclk-fab-stat-divider { .fclk-systray-stat-val {
font-size: 16px;
font-weight: 700;
color: var(--fclk-fab-text);
}
.fclk-systray-stat-lbl {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--fclk-fab-muted);
}
.fclk-systray-stat-sep {
width: 1px; width: 1px;
height: 28px; height: 24px;
background: var(--fclk-fab-divider); background: var(--fclk-fab-divider);
} }
// ---- Action button ---- .fclk-systray-action {
.fclk-fab-action { width: 100%;
padding: 10px 16px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 8px; gap: 8px;
width: 100%; transition: opacity 0.15s, transform 0.1s;
border: none; color: #fff;
border-radius: 12px;
padding: 11px 0;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
letter-spacing: 0.3px;
.fa { font-size: 15px; } &--in {
background: var(--bs-success, var(--o-success, #198754));
&.fclk-fab-action--in { &:hover { opacity: 0.9; }
background: $fclk-gradient;
color: #fff;
&:hover:not(:disabled) {
box-shadow: 0 4px 16px rgba($fclk-teal, 0.4);
}
} }
&.fclk-fab-action--out { &--out {
background: $fclk-red; background: var(--bs-danger, var(--o-danger, #dc3545));
color: #fff; &:hover { opacity: 0.9; }
&:hover:not(:disabled) {
box-shadow: 0 4px 16px rgba($fclk-red, 0.35);
}
} }
&:disabled { &:active { transform: scale(0.98); }
opacity: 0.55; &:disabled { opacity: 0.6; cursor: not-allowed; }
cursor: not-allowed;
}
} }
// ---- Error ---- .fclk-systray-error {
.fclk-fab-error { padding: 8px 12px;
display: flex;
align-items: flex-start;
gap: 6px;
color: $fclk-red;
font-size: 11px;
background: var(--fclk-fab-error-bg); background: var(--fclk-fab-error-bg);
border-radius: 8px; border-radius: 6px;
padding: 8px 10px; font-size: 12px;
margin-top: 10px; color: var(--bs-warning, var(--o-warning, #856404));
animation: fclk-shake 0.35s ease; .fa { margin-right: 4px; }
line-height: 1.4;
.fa { font-size: 12px; margin-top: 1px; flex-shrink: 0; }
}
@keyframes fclk-shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-4px); }
75% { transform: translateX(4px); }
} }
// =========================================================== // ===========================================================
// FAB Dialog Overlays (reason, clock-out confirmation) // Dialog Overlays (reason, clock-out confirmation)
// =========================================================== // ===========================================================
.fclk-fab-dialog-overlay { .fclk-fab-dialog-overlay {
position: fixed; position: fixed;
@@ -593,7 +456,6 @@ $fclk-gradient-active: linear-gradient(135deg, $fclk-green 0%, $fclk-teal 100%);
} }
} }
// Summary card (used in clock-out confirmation)
.fclk-fab-dialog-summary { .fclk-fab-dialog-summary {
background: var(--fclk-fab-location-bg, rgba(0, 0, 0, 0.04)); background: var(--fclk-fab-location-bg, rgba(0, 0, 0, 0.04));
border: 1px solid var(--fclk-fab-panel-border); border: 1px solid var(--fclk-fab-panel-border);
@@ -678,7 +540,6 @@ html.o_dark {
.fa { margin-right: 4px; } .fa { margin-right: 4px; }
} }
// Google Places dropdown z-index fix
.pac-container { .pac-container {
z-index: 2100 !important; z-index: 2100 !important;
border-radius: 8px; border-radius: 8px;
@@ -732,7 +593,6 @@ html.o_dark {
letter-spacing: 0.2px; letter-spacing: 0.2px;
} }
// -- Total (blue/slate) --
.fclk-dash-card--total { .fclk-dash-card--total {
background: linear-gradient(135deg, #eff6ff 0%, #e0e7ff 100%); background: linear-gradient(135deg, #eff6ff 0%, #e0e7ff 100%);
border: 1px solid #bfdbfe; border: 1px solid #bfdbfe;
@@ -742,7 +602,6 @@ html.o_dark {
.fclk-dash-card-label { color: #3b82f6; } .fclk-dash-card-label { color: #3b82f6; }
} }
// -- Present (green) --
.fclk-dash-card--present { .fclk-dash-card--present {
background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%); background: linear-gradient(135deg, #ecfdf5 0%, #d1fae5 100%);
border: 1px solid #a7f3d0; border: 1px solid #a7f3d0;
@@ -752,7 +611,6 @@ html.o_dark {
.fclk-dash-card-label { color: #10b981; } .fclk-dash-card-label { color: #10b981; }
} }
// -- Absent (red) --
.fclk-dash-card--absent { .fclk-dash-card--absent {
background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%); background: linear-gradient(135deg, #fef2f2 0%, #fee2e2 100%);
border: 1px solid #fecaca; border: 1px solid #fecaca;
@@ -762,7 +620,6 @@ html.o_dark {
.fclk-dash-card-label { color: #ef4444; } .fclk-dash-card-label { color: #ef4444; }
} }
// -- Late (amber) --
.fclk-dash-card--late { .fclk-dash-card--late {
background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%); background: linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%);
border: 1px solid #fde68a; border: 1px solid #fde68a;
@@ -772,7 +629,6 @@ html.o_dark {
.fclk-dash-card-label { color: #f59e0b; } .fclk-dash-card-label { color: #f59e0b; }
} }
// -- Dark mode overrides --
html.o_dark { html.o_dark {
.fclk-dash-card--total { .fclk-dash-card--total {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.12) 0%, rgba(99, 102, 241, 0.1) 100%); background: linear-gradient(135deg, rgba(59, 130, 246, 0.12) 0%, rgba(99, 102, 241, 0.1) 100%);

View File

@@ -1,120 +1,96 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<templates xml:space="preserve"> <templates xml:space="preserve">
<t t-name="fusion_clock.ClockFAB"> <t t-name="fusion_clock.ClockSystray">
<div t-if="state.isDisplayed" class="fclk-fab-wrapper"> <t t-if="state.isDisplayed">
<!-- Systray Dropdown -->
<!-- Expanded Panel (above the button) --> <Dropdown position="'bottom-end'" state="dropdown"
<div t-if="state.expanded" class="fclk-fab-panel"> beforeOpen.bind="_fetchStatus"
<!-- Header --> menuClass="'fclk-systray-dropdown p-0'">
<div class="fclk-fab-panel-header"> <button class="fclk-systray-btn">
<div class="fclk-fab-panel-title"> <span t-attf-class="fclk-systray-icon {{ state.isCheckedIn ? 'fclk-systray-icon--in' : 'fclk-systray-icon--out' }}">
<span t-attf-class="fclk-fab-status-dot {{ state.isCheckedIn ? 'active' : '' }}"/> <i class="fa fa-clock-o"/>
<span t-if="state.isCheckedIn">Clocked In</span> </span>
<span t-else="">Ready</span>
</div>
<a href="/my/clock" class="fclk-fab-open-link" target="_blank" title="Open Full Clock">
<i class="fa fa-external-link"/>
</a>
</div>
<!-- Location -->
<div t-if="state.isCheckedIn and state.locationName" class="fclk-fab-location">
<i class="fa fa-map-marker"/>
<span t-esc="state.locationName"/>
</div>
<!-- Timer -->
<div class="fclk-fab-timer" t-esc="state.timerDisplay"/>
<!-- Stats Row -->
<div class="fclk-fab-stats">
<div class="fclk-fab-stat">
<span class="fclk-fab-stat-val"><t t-esc="state.todayHours"/>h</span>
<span class="fclk-fab-stat-lbl">Today</span>
</div>
<div class="fclk-fab-stat-divider"/>
<div class="fclk-fab-stat">
<span class="fclk-fab-stat-val"><t t-esc="state.weekHours"/>h</span>
<span class="fclk-fab-stat-lbl">Week</span>
</div>
</div>
<!-- Clock Action Button -->
<button t-attf-class="fclk-fab-action {{ state.isCheckedIn ? 'fclk-fab-action--out' : 'fclk-fab-action--in' }}"
t-on-click="onClockAction"
t-att-disabled="state.loading">
<t t-if="state.loading">
<i class="fa fa-circle-o-notch fa-spin"/> Working...
</t>
<t t-elif="state.isCheckedIn">
<i class="fa fa-stop-circle-o"/> Clock Out
</t>
<t t-else="">
<i class="fa fa-play-circle-o"/> Clock In
</t>
</button> </button>
<t t-set-slot="content">
<div class="fclk-systray-panel">
<!-- Header -->
<div class="fclk-systray-header">
<i t-attf-class="fa fa-circle fclk-systray-header-dot {{ state.isCheckedIn ? 'text-success' : 'text-danger' }}"/>
<span t-if="state.isCheckedIn" class="fclk-systray-header-text">Clocked In</span>
<span t-else="" class="fclk-systray-header-text">Ready to Clock In</span>
<a href="/my/clock" class="fclk-systray-link" target="_blank" title="Open Full Clock">
<i class="fa fa-external-link"/>
</a>
</div>
<!-- Error --> <!-- Location -->
<div t-if="state.error" class="fclk-fab-error"> <div t-if="state.isCheckedIn and state.locationName" class="fclk-systray-location">
<i class="fa fa-exclamation-triangle"/> <i class="fa fa-map-marker"/> <t t-esc="state.locationName"/>
<t t-esc="state.error"/> </div>
</div>
<!-- Arrow pointing to button --> <!-- Timer -->
<div class="fclk-fab-panel-arrow"/> <div class="fclk-systray-timer" t-esc="state.timerDisplay"/>
</div>
<!-- Floating Action Button --> <!-- Stats -->
<button t-attf-class="fclk-fab-btn {{ state.isCheckedIn ? 'fclk-fab-btn--active' : '' }} {{ state.expanded ? 'fclk-fab-btn--open' : '' }}" <div class="fclk-systray-stats">
t-on-click="togglePanel"> <div class="fclk-systray-stat">
<span t-if="state.isCheckedIn" class="fclk-fab-ripple-ring fclk-fab-ripple-ring--1"/> <span class="fclk-systray-stat-val"><t t-esc="state.todayHours"/>h</span>
<span t-if="state.isCheckedIn" class="fclk-fab-ripple-ring fclk-fab-ripple-ring--2"/> <span class="fclk-systray-stat-lbl">Today</span>
<span t-if="state.isCheckedIn" class="fclk-fab-ripple-ring fclk-fab-ripple-ring--3"/> </div>
<span class="fclk-fab-icon"> <div class="fclk-systray-stat-sep"/>
<i t-if="!state.expanded" class="fa fa-clock-o"/> <div class="fclk-systray-stat">
<i t-else="" class="fa fa-times"/> <span class="fclk-systray-stat-val"><t t-esc="state.weekHours"/>h</span>
</span> <span class="fclk-systray-stat-lbl">This Week</span>
<span t-if="state.isCheckedIn and !state.expanded" class="fclk-fab-badge"> </div>
<t t-esc="state.timerDisplay"/> </div>
</span>
</button>
<!-- Missed Clock-Out Reason Dialog --> <!-- Clock Action -->
<button t-attf-class="fclk-systray-action {{ state.isCheckedIn ? 'fclk-systray-action--out' : 'fclk-systray-action--in' }}"
t-on-click="onClockAction"
t-att-disabled="state.loading">
<t t-if="state.loading">
<i class="fa fa-circle-o-notch fa-spin"/> Working...
</t>
<t t-elif="state.isCheckedIn">
<i class="fa fa-stop-circle-o"/> Clock Out
</t>
<t t-else="">
<i class="fa fa-play-circle-o"/> Clock In
</t>
</button>
<!-- Error -->
<div t-if="state.error" class="fclk-systray-error">
<i class="fa fa-exclamation-triangle"/> <t t-esc="state.error"/>
</div>
</div>
</t>
</Dropdown>
<!-- Missed Clock-Out Reason Dialog (outside dropdown) -->
<div t-if="state.showReasonDialog" class="fclk-fab-dialog-overlay"> <div t-if="state.showReasonDialog" class="fclk-fab-dialog-overlay">
<div class="fclk-fab-dialog-backdrop" t-on-click="cancelReason"/> <div class="fclk-fab-dialog-backdrop" t-on-click="cancelReason"/>
<div class="fclk-fab-dialog"> <div class="fclk-fab-dialog">
<div class="fclk-fab-dialog-header fclk-fab-dialog-header--warning"> <div class="fclk-fab-dialog-header fclk-fab-dialog-header--warning">
<div class="fclk-fab-dialog-icon"> <div class="fclk-fab-dialog-icon"><i class="fa fa-exclamation-triangle"/></div>
<i class="fa fa-exclamation-triangle"/>
</div>
<h4 class="fclk-fab-dialog-title">Missed Clock-Out</h4> <h4 class="fclk-fab-dialog-title">Missed Clock-Out</h4>
<p class="fclk-fab-dialog-subtitle">You didn't clock out on your last shift. Please provide details before continuing.</p> <p class="fclk-fab-dialog-subtitle">You didn't clock out on your last shift. Please provide details before continuing.</p>
</div> </div>
<div class="fclk-fab-dialog-body"> <div class="fclk-fab-dialog-body">
<div class="fclk-fab-dialog-field"> <div class="fclk-fab-dialog-field">
<label class="fclk-fab-dialog-label"> <label class="fclk-fab-dialog-label"><i class="fa fa-comment-o"/> Reason <span class="fclk-fab-dialog-required">*</span></label>
<i class="fa fa-comment-o"/> Reason <span class="fclk-fab-dialog-required">*</span> <textarea class="fclk-fab-dialog-input" rows="3" placeholder="Please explain why you didn't clock out..." t-on-input="onReasonTextInput" t-att-value="state.reasonText"/>
</label>
<textarea class="fclk-fab-dialog-input" rows="3"
placeholder="Please explain why you didn't clock out..."
t-on-input="onReasonTextInput"
t-att-value="state.reasonText"/>
</div> </div>
<div class="fclk-fab-dialog-field"> <div class="fclk-fab-dialog-field">
<label class="fclk-fab-dialog-label"> <label class="fclk-fab-dialog-label"><i class="fa fa-clock-o"/> Departure Time</label>
<i class="fa fa-clock-o"/> Departure Time <input type="datetime-local" class="fclk-fab-dialog-input" t-on-input="onReasonTimeInput" t-att-value="state.reasonTime"/>
</label>
<input type="datetime-local" class="fclk-fab-dialog-input"
t-on-input="onReasonTimeInput"
t-att-value="state.reasonTime"/>
<span class="fclk-fab-dialog-hint">When did you actually leave? (optional)</span> <span class="fclk-fab-dialog-hint">When did you actually leave? (optional)</span>
</div> </div>
</div> </div>
<div class="fclk-fab-dialog-footer"> <div class="fclk-fab-dialog-footer">
<button class="fclk-fab-dialog-btn fclk-fab-dialog-btn--cancel" t-on-click="cancelReason">Cancel</button> <button class="fclk-fab-dialog-btn fclk-fab-dialog-btn--cancel" t-on-click="cancelReason">Cancel</button>
<button class="fclk-fab-dialog-btn fclk-fab-dialog-btn--submit" t-on-click="submitReason" <button class="fclk-fab-dialog-btn fclk-fab-dialog-btn--submit" t-on-click="submitReason" t-att-disabled="state.reasonSubmitting">
t-att-disabled="state.reasonSubmitting">
<t t-if="state.reasonSubmitting"><i class="fa fa-circle-o-notch fa-spin"/> Submitting...</t> <t t-if="state.reasonSubmitting"><i class="fa fa-circle-o-notch fa-spin"/> Submitting...</t>
<t t-else=""><i class="fa fa-check"/> Submit Reason</t> <t t-else=""><i class="fa fa-check"/> Submit Reason</t>
</button> </button>
@@ -122,14 +98,12 @@
</div> </div>
</div> </div>
<!-- Clock-Out Confirmation Dialog --> <!-- Clock-Out Confirmation Dialog (outside dropdown) -->
<div t-if="state.showClockoutConfirm" class="fclk-fab-dialog-overlay"> <div t-if="state.showClockoutConfirm" class="fclk-fab-dialog-overlay">
<div class="fclk-fab-dialog-backdrop" t-on-click="cancelClockOut"/> <div class="fclk-fab-dialog-backdrop" t-on-click="cancelClockOut"/>
<div class="fclk-fab-dialog fclk-fab-dialog--compact"> <div class="fclk-fab-dialog fclk-fab-dialog--compact">
<div class="fclk-fab-dialog-header fclk-fab-dialog-header--danger"> <div class="fclk-fab-dialog-header fclk-fab-dialog-header--danger">
<div class="fclk-fab-dialog-icon"> <div class="fclk-fab-dialog-icon"><i class="fa fa-stop-circle"/></div>
<i class="fa fa-stop-circle"/>
</div>
<h4 class="fclk-fab-dialog-title">Clock Out?</h4> <h4 class="fclk-fab-dialog-title">Clock Out?</h4>
<p class="fclk-fab-dialog-subtitle">Are you sure you want to end your current shift?</p> <p class="fclk-fab-dialog-subtitle">Are you sure you want to end your current shift?</p>
</div> </div>
@@ -153,7 +127,7 @@
</div> </div>
</div> </div>
</div> </div>
</div> </t>
</t> </t>
</templates> </templates>

View File

@@ -69,13 +69,34 @@
groups="group_fusion_clock_manager"/> groups="group_fusion_clock_manager"/>
<!-- Reports Sub-Menu --> <!-- Reports Sub-Menu -->
<menuitem id="menu_fusion_clock_reports" <menuitem id="menu_fusion_clock_reports_parent"
name="Reports" name="Reports"
parent="menu_fusion_clock_root" parent="menu_fusion_clock_root"
action="action_fusion_clock_report"
sequence="40" sequence="40"
groups="group_fusion_clock_manager"/> groups="group_fusion_clock_manager"/>
<menuitem id="menu_fusion_clock_reports"
name="All Reports"
parent="menu_fusion_clock_reports_parent"
action="action_fusion_clock_report"
sequence="10"
groups="group_fusion_clock_manager"/>
<menuitem id="menu_fusion_clock_generate_historical"
name="Generate Historical Reports"
parent="menu_fusion_clock_reports_parent"
action="action_server_generate_historical"
sequence="20"
groups="group_fusion_clock_manager"/>
<!-- Employees -->
<menuitem id="menu_fusion_clock_employees"
name="Employees"
parent="menu_fusion_clock_root"
action="hr.open_view_employee_list_my"
sequence="50"
groups="group_fusion_clock_manager,group_fusion_clock_team_lead"/>
<!-- Configuration Sub-Menu --> <!-- Configuration Sub-Menu -->
<menuitem id="menu_fusion_clock_config" <menuitem id="menu_fusion_clock_config"
name="Configuration" name="Configuration"

View File

@@ -1,12 +1,27 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<odoo> <odoo>
<!-- Server Action: Generate Historical Reports -->
<record id="action_server_generate_historical" model="ir.actions.server">
<field name="name">Generate Historical Reports</field>
<field name="model_id" ref="model_fusion_clock_report"/>
<field name="state">code</field>
<field name="code">action = model.action_generate_historical_reports()</field>
</record>
<!-- Report List View --> <!-- Report List View -->
<record id="view_fusion_clock_report_list" model="ir.ui.view"> <record id="view_fusion_clock_report_list" model="ir.ui.view">
<field name="name">fusion.clock.report.list</field> <field name="name">fusion.clock.report.list</field>
<field name="model">fusion.clock.report</field> <field name="model">fusion.clock.report</field>
<field name="arch" type="xml"> <field name="arch" type="xml">
<list string="Clock Reports" default_order="date_end desc"> <list string="Clock Reports" default_order="date_end desc">
<header>
<button name="%(action_server_generate_historical)d"
type="action"
string="Generate Historical Reports"
class="btn-secondary"
icon="fa-history"/>
</header>
<field name="name"/> <field name="name"/>
<field name="date_start"/> <field name="date_start"/>
<field name="date_end"/> <field name="date_end"/>
@@ -44,6 +59,11 @@
string="Export CSV" class="btn-secondary" string="Export CSV" class="btn-secondary"
invisible="state == 'draft'" invisible="state == 'draft'"
icon="fa-download"/> icon="fa-download"/>
<button name="action_reset_draft" type="object"
string="Reset to Draft" class="btn-secondary"
invisible="state == 'draft'"
icon="fa-undo"
confirm="This will reset the report to draft. Continue?"/>
<field name="state" widget="statusbar" statusbar_visible="draft,generated,sent"/> <field name="state" widget="statusbar" statusbar_visible="draft,generated,sent"/>
</header> </header>
<sheet> <sheet>

View File

@@ -7,6 +7,9 @@
<field name="model">hr.attendance</field> <field name="model">hr.attendance</field>
<field name="inherit_id" ref="hr_attendance.view_attendance_tree"/> <field name="inherit_id" ref="hr_attendance.view_attendance_tree"/>
<field name="arch" type="xml"> <field name="arch" type="xml">
<xpath expr="//list" position="attributes">
<attribute name="default_order">check_in desc</attribute>
</xpath>
<xpath expr="//field[@name='worked_hours']" position="after"> <xpath expr="//field[@name='worked_hours']" position="after">
<field name="x_fclk_net_hours" string="Net Hours" widget="float_time" optional="show"/> <field name="x_fclk_net_hours" string="Net Hours" widget="float_time" optional="show"/>
<field name="x_fclk_break_minutes" string="Break (min)" optional="show"/> <field name="x_fclk_break_minutes" string="Break (min)" optional="show"/>

View File

@@ -11,7 +11,7 @@
<page string="Fusion Clock" name="fusion_clock_tab" <page string="Fusion Clock" name="fusion_clock_tab"
groups="fusion_clock.group_fusion_clock_manager,fusion_clock.group_fusion_clock_team_lead"> groups="fusion_clock.group_fusion_clock_manager,fusion_clock.group_fusion_clock_team_lead">
<!-- Summary Stats --> <!-- Configuration & Status -->
<group> <group>
<group string="Configuration"> <group string="Configuration">
<field name="x_fclk_enable_clock"/> <field name="x_fclk_enable_clock"/>
@@ -31,133 +31,124 @@
</group> </group>
</group> </group>
<separator string="Activity Logs"/> <!-- Activity Log Sub-Tabs -->
<notebook>
<page string="Clock Events" name="fclk_sub_clock_events">
<field name="x_fclk_activity_log_ids" nolabel="1" colspan="2"
domain="[('log_type', 'in', ['clock_in', 'clock_out'])]">
<list create="false" delete="false" limit="20" default_order="log_date desc">
<field name="log_date"/>
<field name="log_type"/>
<field name="description"/>
<field name="location_id"/>
<field name="source"/>
</list>
</field>
</page>
<!-- Clock Events --> <page string="Penalties" name="fclk_sub_penalties">
<group string="Clock Events" name="fclk_clock_events"> <field name="x_fclk_activity_log_ids" nolabel="1" colspan="2"
<field name="x_fclk_activity_log_ids" nolabel="1" colspan="2" domain="[('log_type', 'in', ['late_clock_in', 'early_clock_out'])]">
domain="[('log_type', 'in', ['clock_in', 'clock_out'])]"> <list create="false" delete="false" limit="20" default_order="log_date desc">
<list create="false" delete="false" limit="20"> <field name="log_date"/>
<field name="log_date"/> <field name="log_type"/>
<field name="log_type"/> <field name="description"/>
<field name="description"/> </list>
<field name="location_id"/> </field>
<field name="source"/> </page>
</list>
</field>
</group>
<!-- Penalties --> <page string="Geofence" name="fclk_sub_geofence">
<group string="Penalties" name="fclk_penalties"> <field name="x_fclk_activity_log_ids" nolabel="1" colspan="2"
<field name="x_fclk_activity_log_ids" nolabel="1" colspan="2" domain="[('log_type', 'in', ['outside_geofence', 'ip_fallback'])]">
domain="[('log_type', 'in', ['late_clock_in', 'early_clock_out'])]"> <list create="false" delete="false" limit="20" default_order="log_date desc">
<list create="false" delete="false" limit="20"> <field name="log_date"/>
<field name="log_date"/> <field name="log_type"/>
<field name="log_type"/> <field name="description"/>
<field name="description"/> <field name="latitude"/>
</list> <field name="longitude"/>
</field> <field name="distance"/>
</group> </list>
</field>
</page>
<!-- Geofence Violations --> <page string="System" name="fclk_sub_system">
<group string="Geofence Violations" name="fclk_geofence"> <field name="x_fclk_activity_log_ids" nolabel="1" colspan="2"
<field name="x_fclk_activity_log_ids" nolabel="1" colspan="2" domain="[('log_type', 'in', ['auto_clock_out', 'missed_clock_out'])]">
domain="[('log_type', 'in', ['outside_geofence', 'ip_fallback'])]"> <list create="false" delete="false" limit="20" default_order="log_date desc">
<list create="false" delete="false" limit="20"> <field name="log_date"/>
<field name="log_date"/> <field name="log_type"/>
<field name="log_type"/> <field name="description"/>
<field name="description"/> <field name="attendance_id"/>
<field name="latitude"/> </list>
<field name="longitude"/> </field>
<field name="distance"/> </page>
</list>
</field>
</group>
<!-- System Actions --> <page string="Absences" name="fclk_sub_absences">
<group string="System Actions" name="fclk_system_actions"> <field name="x_fclk_activity_log_ids" nolabel="1" colspan="2"
<field name="x_fclk_activity_log_ids" nolabel="1" colspan="2" domain="[('log_type', '=', 'absent')]">
domain="[('log_type', 'in', ['auto_clock_out', 'missed_clock_out'])]"> <list create="false" delete="false" limit="20" default_order="log_date desc">
<list create="false" delete="false" limit="20"> <field name="log_date"/>
<field name="log_date"/> <field name="description"/>
<field name="log_type"/> </list>
<field name="description"/> </field>
<field name="attendance_id"/> </page>
</list>
</field>
</group>
<!-- Absences --> <page string="Leave Requests" name="fclk_sub_leaves">
<group string="Absences" name="fclk_absences"> <field name="x_fclk_leave_request_ids" nolabel="1" colspan="2">
<field name="x_fclk_activity_log_ids" nolabel="1" colspan="2" <list create="false" delete="false" limit="20" default_order="leave_date desc">
domain="[('log_type', '=', 'absent')]"> <field name="leave_date"/>
<list create="false" delete="false" limit="20"> <field name="reason"/>
<field name="log_date"/> <field name="state"/>
<field name="description"/> <field name="created_from"/>
</list> </list>
</field> </field>
</group> </page>
<!-- Leave Requests --> <page string="Reasons" name="fclk_sub_reasons">
<group string="Leave Requests" name="fclk_leaves"> <field name="x_fclk_activity_log_ids" nolabel="1" colspan="2"
<field name="x_fclk_leave_request_ids" nolabel="1" colspan="2"> domain="[('log_type', '=', 'reason_provided')]">
<list create="false" delete="false" limit="20"> <list create="false" delete="false" limit="20" default_order="log_date desc">
<field name="leave_date"/> <field name="log_date"/>
<field name="reason"/> <field name="description"/>
<field name="state"/> </list>
<field name="created_from"/> </field>
</list> </page>
</field>
</group>
<!-- Reason Submissions --> <page string="Overtime" name="fclk_sub_overtime">
<group string="Reason Submissions" name="fclk_reasons"> <field name="x_fclk_activity_log_ids" nolabel="1" colspan="2"
<field name="x_fclk_activity_log_ids" nolabel="1" colspan="2" domain="[('log_type', '=', 'overtime')]">
domain="[('log_type', '=', 'reason_provided')]"> <list create="false" delete="false" limit="20" default_order="log_date desc">
<list create="false" delete="false" limit="20"> <field name="log_date"/>
<field name="log_date"/> <field name="description"/>
<field name="description"/> <field name="attendance_id"/>
</list> </list>
</field> </field>
</group> </page>
<!-- Overtime --> <page string="Corrections" name="fclk_sub_corrections">
<group string="Overtime" name="fclk_overtime"> <field name="x_fclk_correction_ids" nolabel="1" colspan="2">
<field name="x_fclk_activity_log_ids" nolabel="1" colspan="2" <list create="false" delete="false" limit="20">
domain="[('log_type', '=', 'overtime')]"> <field name="attendance_id"/>
<list create="false" delete="false" limit="20"> <field name="requested_check_in"/>
<field name="log_date"/> <field name="requested_check_out"/>
<field name="description"/> <field name="reason"/>
<field name="attendance_id"/> <field name="state" decoration-success="state == 'approved'"
</list> decoration-danger="state == 'rejected'"
</field> decoration-warning="state == 'pending'"/>
</group> </list>
</field>
</page>
<!-- Correction Requests --> <page string="Streaks" name="fclk_sub_streaks">
<group string="Correction Requests" name="fclk_corrections"> <field name="x_fclk_activity_log_ids" nolabel="1" colspan="2"
<field name="x_fclk_correction_ids" nolabel="1" colspan="2"> domain="[('log_type', '=', 'streak_milestone')]">
<list create="false" delete="false" limit="20"> <list create="false" delete="false" limit="20" default_order="log_date desc">
<field name="attendance_id"/> <field name="log_date"/>
<field name="requested_check_in"/> <field name="description"/>
<field name="requested_check_out"/> </list>
<field name="reason"/> </field>
<field name="state" decoration-success="state == 'approved'" </page>
decoration-danger="state == 'rejected'" </notebook>
decoration-warning="state == 'pending'"/>
</list>
</field>
</group>
<!-- Streak Milestones -->
<group string="Streak Milestones" name="fclk_streaks">
<field name="x_fclk_activity_log_ids" nolabel="1" colspan="2"
domain="[('log_type', '=', 'streak_milestone')]">
<list create="false" delete="false" limit="20">
<field name="log_date"/>
<field name="description"/>
</list>
</field>
</group>
</page> </page>
</xpath> </xpath>

View File

@@ -226,10 +226,10 @@
<div class="fclk-recent-item"> <div class="fclk-recent-item">
<div class="fclk-recent-date"> <div class="fclk-recent-date">
<div class="fclk-recent-day-name"> <div class="fclk-recent-day-name">
<t t-esc="att.check_in.strftime('%a')"/> <t t-esc="context_timestamp(att.check_in).strftime('%a')"/>
</div> </div>
<div class="fclk-recent-day-num"> <div class="fclk-recent-day-num">
<t t-esc="att.check_in.strftime('%d')"/> <t t-esc="context_timestamp(att.check_in).strftime('%d')"/>
</div> </div>
</div> </div>
<div class="fclk-recent-info"> <div class="fclk-recent-info">
@@ -237,8 +237,8 @@
<t t-esc="att.x_fclk_location_id.name or 'Unknown'"/> <t t-esc="att.x_fclk_location_id.name or 'Unknown'"/>
</div> </div>
<div class="fclk-recent-times"> <div class="fclk-recent-times">
<t t-esc="att.check_in.strftime('%I:%M %p')"/> <t t-esc="context_timestamp(att.check_in).strftime('%I:%M %p')"/>
- <t t-esc="att.check_out.strftime('%I:%M %p') if att.check_out else '--'"/> - <t t-esc="context_timestamp(att.check_out).strftime('%I:%M %p') if att.check_out else '--'"/>
</div> </div>
</div> </div>
<div class="fclk-recent-hours"> <div class="fclk-recent-hours">

View File

@@ -69,15 +69,15 @@
<t t-foreach="attendances" t-as="att"> <t t-foreach="attendances" t-as="att">
<tr> <tr>
<td> <td>
<strong><t t-esc="att.check_in.strftime('%a')"/></strong> <strong><t t-esc="context_timestamp(att.check_in).strftime('%a')"/></strong>
<span style="color:#9ca3af; margin-left:4px;"> <span style="color:#9ca3af; margin-left:4px;">
<t t-esc="att.check_in.strftime('%b %d')"/> <t t-esc="context_timestamp(att.check_in).strftime('%b %d')"/>
</span> </span>
</td> </td>
<td><t t-esc="att.check_in.strftime('%I:%M %p')"/></td> <td><t t-esc="context_timestamp(att.check_in).strftime('%I:%M %p')"/></td>
<td> <td>
<t t-if="att.check_out"> <t t-if="att.check_out">
<t t-esc="att.check_out.strftime('%I:%M %p')"/> <t t-esc="context_timestamp(att.check_out).strftime('%I:%M %p')"/>
<t t-if="att.x_fclk_auto_clocked_out"> <t t-if="att.x_fclk_auto_clocked_out">
<span class="fclk-ts-badge-auto">AUTO</span> <span class="fclk-ts-badge-auto">AUTO</span>
</t> </t>

View File

@@ -10,155 +10,166 @@
<xpath expr="//form" position="inside"> <xpath expr="//form" position="inside">
<app data-string="Fusion Clock" string="Fusion Clock" name="fusion_clock" groups="fusion_clock.group_fusion_clock_manager"> <app data-string="Fusion Clock" string="Fusion Clock" name="fusion_clock" groups="fusion_clock.group_fusion_clock_manager">
<!-- ============================================================ -->
<!-- Work Schedule --> <!-- Work Schedule -->
<!-- ============================================================ -->
<block title="Work Schedule" name="fclk_work_schedule"> <block title="Work Schedule" name="fclk_work_schedule">
<setting string="Default Clock-In Time" help="The scheduled start time for employees (used when no shift is assigned)."> <setting id="fclk_default_schedule" string="Default Schedule"
help="Scheduled start and end times used when no shift is assigned to an employee.">
<div class="content-group"> <div class="content-group">
<div class="row mt16"> <div class="row mt16">
<label for="fclk_default_clock_in_time" class="col-lg-3"/> <label for="fclk_default_clock_in_time" string="Clock-In" class="col-lg-5 o_light_label"/>
<field name="fclk_default_clock_in_time" widget="float_time"/> <field name="fclk_default_clock_in_time" widget="float_time"/>
</div> </div>
</div> <div class="row mt8">
</setting> <label for="fclk_default_clock_out_time" string="Clock-Out" class="col-lg-5 o_light_label"/>
<setting string="Default Clock-Out Time" help="The scheduled end time for employees.">
<div class="content-group">
<div class="row mt16">
<label for="fclk_default_clock_out_time" class="col-lg-3"/>
<field name="fclk_default_clock_out_time" widget="float_time"/> <field name="fclk_default_clock_out_time" widget="float_time"/>
</div> </div>
</div> </div>
</setting> </setting>
</block> <setting id="fclk_auto_break" string="Auto-Deduct Break"
help="Automatically deduct unpaid break from worked hours on clock-out.">
<!-- Break Settings -->
<block title="Break Settings" name="fclk_break_settings">
<setting string="Auto-Deduct Break" help="Automatically deduct unpaid break from worked hours.">
<field name="fclk_auto_deduct_break"/> <field name="fclk_auto_deduct_break"/>
<div class="content-group" invisible="not fclk_auto_deduct_break"> <div class="content-group" invisible="not fclk_auto_deduct_break">
<div class="row mt16"> <div class="row mt16">
<label for="fclk_default_break_minutes" class="col-lg-3"/> <label for="fclk_default_break_minutes" string="Duration (min)" class="col-lg-5 o_light_label"/>
<field name="fclk_default_break_minutes"/> <field name="fclk_default_break_minutes"/>
<span class="ms-1">minutes</span>
</div> </div>
<div class="row mt8"> <div class="row mt8">
<label for="fclk_break_threshold_hours" class="col-lg-3"/> <label for="fclk_break_threshold_hours" string="Min. Shift" class="col-lg-5 o_light_label"/>
<field name="fclk_break_threshold_hours" widget="float_time"/> <field name="fclk_break_threshold_hours" widget="float_time"/>
<span class="ms-1">(only deduct if shift exceeds this)</span>
</div> </div>
</div> </div>
</setting> </setting>
</block> </block>
<!-- Auto Clock-Out & Grace Period --> <!-- ============================================================ -->
<block title="Auto Clock-Out" name="fclk_auto_clockout"> <!-- Attendance Rules -->
<setting string="Enable Auto Clock-Out" help="Automatically clock out employees after shift + grace period."> <!-- ============================================================ -->
<block title="Attendance Rules" name="fclk_attendance_rules">
<setting id="fclk_auto_clockout" string="Auto Clock-Out"
help="Automatically clock out employees after their shift end time plus a grace period.">
<field name="fclk_enable_auto_clockout"/> <field name="fclk_enable_auto_clockout"/>
<div class="content-group" invisible="not fclk_enable_auto_clockout"> <div class="content-group" invisible="not fclk_enable_auto_clockout">
<div class="row mt16"> <div class="row mt16">
<label for="fclk_grace_period_minutes" class="col-lg-3"/> <label for="fclk_grace_period_minutes" string="Grace (min)" class="col-lg-5 o_light_label"/>
<field name="fclk_grace_period_minutes"/> <field name="fclk_grace_period_minutes"/>
<span class="ms-1">minutes grace after scheduled end</span>
</div> </div>
<div class="row mt8"> <div class="row mt8">
<label for="fclk_max_shift_hours" class="col-lg-3"/> <label for="fclk_max_shift_hours" string="Max Shift" class="col-lg-5 o_light_label"/>
<field name="fclk_max_shift_hours" widget="float_time"/> <field name="fclk_max_shift_hours" widget="float_time"/>
<span class="ms-1">max shift safety net</span>
</div> </div>
</div> </div>
</setting> </setting>
</block> <setting id="fclk_penalties" string="Penalty Tracking"
help="Deduct minutes from worked hours when employees clock in late or clock out early.">
<!-- Penalties -->
<block title="Penalty Tracking" name="fclk_penalties">
<setting string="Enable Penalties" help="Track late clock-in and early clock-out with automatic deductions.">
<field name="fclk_enable_penalties"/> <field name="fclk_enable_penalties"/>
<div class="content-group" invisible="not fclk_enable_penalties"> <div class="content-group" invisible="not fclk_enable_penalties">
<div class="row mt16"> <div class="row mt16">
<label for="fclk_penalty_grace_minutes" class="col-lg-3"/> <label for="fclk_penalty_grace_minutes" string="Grace (min)" class="col-lg-5 o_light_label"/>
<field name="fclk_penalty_grace_minutes"/> <field name="fclk_penalty_grace_minutes"/>
<span class="ms-1">minutes grace before penalty</span>
</div> </div>
<div class="row mt8"> <div class="row mt8">
<label for="fclk_penalty_deduction_minutes" class="col-lg-3"/> <label for="fclk_penalty_deduction_minutes" string="Deduction (min)" class="col-lg-5 o_light_label"/>
<field name="fclk_penalty_deduction_minutes"/> <field name="fclk_penalty_deduction_minutes"/>
<span class="ms-1">minutes deducted per penalty</span> </div>
</div>
</setting>
<setting id="fclk_overtime" string="Overtime Tracking"
help="Calculate and track overtime when net hours exceed the daily or weekly threshold.">
<field name="fclk_enable_overtime"/>
<div class="content-group" invisible="not fclk_enable_overtime">
<div class="row mt16">
<label for="fclk_daily_overtime_threshold" string="Daily Limit" class="col-lg-5 o_light_label"/>
<field name="fclk_daily_overtime_threshold" widget="float_time"/>
</div>
<div class="row mt8">
<label for="fclk_weekly_overtime_threshold" string="Weekly Limit" class="col-lg-5 o_light_label"/>
<field name="fclk_weekly_overtime_threshold" widget="float_time"/>
</div> </div>
</div> </div>
</setting> </setting>
</block> </block>
<!-- Office User & Notifications --> <!-- ============================================================ -->
<block title="Office User &amp; Notifications" name="fclk_notifications"> <!-- Notifications -->
<setting string="Office User" help="User who receives all attendance-related activity notifications."> <!-- ============================================================ -->
<block title="Notifications" name="fclk_notifications">
<setting id="fclk_office_user" string="Office User"
help="User who receives activity notifications for attendance issues (late arrivals, excessive absences).">
<div class="content-group"> <div class="content-group">
<div class="row mt16"> <div class="row mt16">
<label for="fclk_office_user_id" class="col-lg-3"/> <label for="fclk_office_user_id" string="User" class="col-lg-5 o_light_label"/>
<field name="fclk_office_user_id"/> <field name="fclk_office_user_id"/>
</div> </div>
<div class="row mt8"> <div class="row mt8">
<label for="fclk_very_late_threshold_minutes" class="col-lg-3"/> <label for="fclk_very_late_threshold_minutes" string="Late Alert (min)" class="col-lg-5 o_light_label"/>
<field name="fclk_very_late_threshold_minutes"/> <field name="fclk_very_late_threshold_minutes"/>
<span class="ms-1">minutes late before office user is notified</span>
</div> </div>
<div class="row mt8"> <div class="row mt8">
<label for="fclk_max_monthly_absences" class="col-lg-3"/> <label for="fclk_max_monthly_absences" string="Max Absences" class="col-lg-5 o_light_label"/>
<field name="fclk_max_monthly_absences"/> <field name="fclk_max_monthly_absences"/>
<span class="ms-1">absences before office user is alerted</span>
</div> </div>
</div> </div>
</setting> </setting>
<setting string="Employee Notifications" help="Send clock-in/out reminders to employees."> <setting id="fclk_employee_notifications" string="Employee Reminders"
help="Send clock-in and clock-out reminders to employees based on their shift schedule.">
<field name="fclk_enable_employee_notifications"/> <field name="fclk_enable_employee_notifications"/>
<div class="content-group" invisible="not fclk_enable_employee_notifications"> <div class="content-group" invisible="not fclk_enable_employee_notifications">
<div class="row mt16"> <div class="row mt16">
<label for="fclk_reminder_before_shift_minutes" class="col-lg-3"/> <label for="fclk_reminder_before_shift_minutes" string="Late Reminder (min)" class="col-lg-5 o_light_label"/>
<field name="fclk_reminder_before_shift_minutes"/> <field name="fclk_reminder_before_shift_minutes"/>
<span class="ms-1">minutes after shift start to remind</span>
</div> </div>
<div class="row mt8"> <div class="row mt8">
<label for="fclk_reminder_before_end_minutes" class="col-lg-3"/> <label for="fclk_reminder_before_end_minutes" string="End Reminder (min)" class="col-lg-5 o_light_label"/>
<field name="fclk_reminder_before_end_minutes"/> <field name="fclk_reminder_before_end_minutes"/>
<span class="ms-1">minutes before shift end to remind</span>
</div> </div>
</div> </div>
</setting> </setting>
<setting string="Weekly Summary" help="Send weekly attendance summary to employees on Monday."> <setting id="fclk_weekly_summary" string="Weekly Summary"
help="Send a weekly attendance summary email to each employee on Monday.">
<field name="fclk_send_weekly_summary"/> <field name="fclk_send_weekly_summary"/>
</setting> </setting>
</block> </block>
<!-- Overtime --> <!-- ============================================================ -->
<block title="Overtime" name="fclk_overtime"> <!-- Location & Verification -->
<setting string="Enable Overtime Tracking" help="Track hours beyond scheduled shift."> <!-- ============================================================ -->
<field name="fclk_enable_overtime"/> <block title="Location &amp; Verification" name="fclk_location_settings">
<div class="content-group" invisible="not fclk_enable_overtime"> <setting id="fclk_ip_fallback" string="IP Fallback"
help="Allow IP-based location verification when GPS is unavailable.">
<field name="fclk_enable_ip_fallback"/>
</setting>
<setting id="fclk_photo_verification" string="Photo Verification"
help="Require selfie on clock-in. Per-location control is available in each location's settings.">
<field name="fclk_enable_photo_verification"/>
</setting>
<setting id="fclk_google_maps" string="Google Maps API Key"
help="Required for location geocoding and map previews in clock location setup.">
<div class="content-group">
<div class="row mt16"> <div class="row mt16">
<label for="fclk_daily_overtime_threshold" class="col-lg-3"/> <label for="fclk_google_maps_api_key" string="API Key" class="col-lg-5 o_light_label"/>
<field name="fclk_daily_overtime_threshold" widget="float_time"/> <field name="fclk_google_maps_api_key" class="o_input" placeholder="AIza..." password="True"/>
<span class="ms-1">daily net hours threshold</span>
</div> </div>
<div class="row mt8"> </div>
<label for="fclk_weekly_overtime_threshold" class="col-lg-3"/> </setting>
<field name="fclk_weekly_overtime_threshold" widget="float_time"/> <setting id="fclk_manage_locations" string="Clock Locations"
<span class="ms-1">weekly net hours threshold</span> help="Configure geofenced clock-in/out locations with GPS radius, IP ranges, and verification rules.">
<div class="content-group">
<div class="mt16">
<button name="%(fusion_clock.action_fusion_clock_location)d" type="action"
string="Manage Locations" icon="oi-arrow-right" class="btn-link"/>
</div> </div>
</div> </div>
</setting> </setting>
</block> </block>
<!-- Location & Verification --> <!-- ============================================================ -->
<block title="Location &amp; Verification" name="fclk_location_verification"> <!-- Kiosk & Portal -->
<setting string="IP Fallback" help="Allow IP-based verification when GPS is unavailable."> <!-- ============================================================ -->
<field name="fclk_enable_ip_fallback"/> <block title="Kiosk &amp; Portal" name="fclk_kiosk_portal">
</setting> <setting id="fclk_kiosk" string="Kiosk Mode"
<setting string="Photo Verification" help="Require selfie on clock-in (controlled per location)."> help="Allow employees to clock in/out from a shared device (tablet or computer).">
<field name="fclk_enable_photo_verification"/>
</setting>
</block>
<!-- Kiosk -->
<block title="Kiosk Mode" name="fclk_kiosk">
<setting string="Enable Kiosk" help="Allow shared-device clock-in/out.">
<field name="fclk_enable_kiosk"/> <field name="fclk_enable_kiosk"/>
<div class="content-group" invisible="not fclk_enable_kiosk"> <div class="content-group" invisible="not fclk_enable_kiosk">
<div class="row mt16"> <div class="row mt16">
@@ -167,88 +178,70 @@
</div> </div>
</div> </div>
</setting> </setting>
</block> <setting id="fclk_corrections" string="Correction Requests"
help="Allow employees to request timesheet corrections from the portal.">
<!-- Corrections -->
<block title="Corrections" name="fclk_corrections">
<setting string="Enable Correction Requests" help="Allow employees to request timesheet corrections.">
<field name="fclk_enable_correction_requests"/> <field name="fclk_enable_correction_requests"/>
</setting> </setting>
<setting id="fclk_sounds" string="Clock Sounds"
help="Play audio confirmation sounds when employees clock in or out.">
<field name="fclk_enable_sounds"/>
</setting>
</block> </block>
<!-- Pay Period --> <!-- ============================================================ -->
<block title="Pay Period" name="fclk_pay_period"> <!-- Pay Period & Reports -->
<setting string="Pay Period Schedule" help="Defines how often reports are generated."> <!-- ============================================================ -->
<block title="Pay Period &amp; Reports" name="fclk_pay_period_reports">
<setting id="fclk_pay_period" string="Pay Period Schedule"
help="Defines how often attendance reports are generated and the start/end dates of each reporting period.">
<div class="content-group"> <div class="content-group">
<div class="row mt16"> <div class="row mt16">
<label for="fclk_pay_period_type" class="col-lg-3"/> <label for="fclk_pay_period_type" string="Frequency" class="col-lg-5 o_light_label"/>
<field name="fclk_pay_period_type"/> <field name="fclk_pay_period_type"/>
</div> </div>
<div class="row mt8"> <div class="row mt8">
<label for="fclk_pay_period_start" class="col-lg-3"/> <label for="fclk_pay_period_start" string="Anchor Date" class="col-lg-5 o_light_label"/>
<field name="fclk_pay_period_start"/> <field name="fclk_pay_period_start"/>
</div> </div>
</div> </div>
</setting> </setting>
</block> <setting id="fclk_auto_reports" string="Auto-Generate Reports"
help="Automatically create attendance reports at the end of each pay period.">
<!-- Reports -->
<block title="Reports &amp; Email" name="fclk_reports">
<setting string="Auto-Generate Reports" help="Automatically generate reports at the end of each pay period.">
<field name="fclk_auto_generate_reports"/> <field name="fclk_auto_generate_reports"/>
</setting> </setting>
<setting string="Send Employee Copies" help="Email individual reports to each employee."> <setting id="fclk_employee_copies" string="Send Employee Copies"
help="Email individual attendance reports to each employee at the end of each pay period.">
<field name="fclk_send_employee_reports"/> <field name="fclk_send_employee_reports"/>
</setting> </setting>
<setting string="Manager Report Recipients" help="Comma-separated emails for batch report delivery."> <setting id="fclk_internal_recipients" string="Internal Recipients"
help="Select internal users who should receive batch attendance reports.">
<div class="content-group"> <div class="content-group">
<div class="row mt16"> <div class="row mt16">
<label for="fclk_report_recipient_emails" class="col-lg-3"/> <label for="fclk_report_recipient_user_ids" string="Users" class="col-lg-5 o_light_label"/>
<field name="fclk_report_recipient_emails" class="o_input" placeholder="manager@company.com"/> <field name="fclk_report_recipient_user_ids" widget="many2many_tags"/>
</div> </div>
</div> </div>
</setting> </setting>
<setting string="CSV Column Mapping" help="Custom column names for CSV export (JSON format)."> <setting id="fclk_external_recipients" string="External Recipients"
help="Additional email addresses for batch report delivery (e.g., external payroll agency).">
<div class="content-group"> <div class="content-group">
<div class="row mt16"> <div class="row mt16">
<label for="fclk_csv_column_mapping" class="col-lg-3"/> <label for="fclk_report_recipient_emails" string="Emails" class="col-lg-5 o_light_label"/>
<field name="fclk_report_recipient_emails" class="o_input" placeholder="payroll@agency.com, manager@company.com"/>
</div>
</div>
</setting>
<setting id="fclk_csv_mapping" string="CSV Column Mapping"
help="Custom column names for CSV export (JSON format). Leave blank for defaults.">
<div class="content-group">
<div class="row mt16">
<label for="fclk_csv_column_mapping" string="Mapping" class="col-lg-5 o_light_label"/>
<field name="fclk_csv_column_mapping" class="o_input"/> <field name="fclk_csv_column_mapping" class="o_input"/>
</div> </div>
</div> </div>
</setting> </setting>
</block> </block>
<!-- Clock Locations -->
<block title="Clock Locations" name="fclk_locations">
<setting string="Manage Locations" help="Configure geofenced clock-in/out locations.">
<div class="content-group">
<div class="mt16">
<button name="%(fusion_clock.action_fusion_clock_location)d" type="action"
string="Manage Locations" class="btn btn-primary" icon="fa-map-marker"/>
</div>
</div>
</setting>
</block>
<!-- Google Maps -->
<block title="Google Maps" name="fclk_google_maps">
<setting string="Google Maps API Key" help="Required for location geocoding and map previews.">
<div class="content-group">
<div class="row mt16">
<label for="fclk_google_maps_api_key" class="col-lg-3"/>
<field name="fclk_google_maps_api_key" class="o_input" placeholder="AIza..." password="True"/>
</div>
</div>
</setting>
</block>
<!-- Sounds -->
<block title="Sounds" name="fclk_sounds">
<setting string="Clock Sounds" help="Play audio feedback on clock-in and clock-out.">
<field name="fclk_enable_sounds"/>
</setting>
</block>
</app> </app>
</xpath> </xpath>
</field> </field>

View File

@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import models
from . import controllers

View File

@@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
{
'name': 'Fusion Clock AI',
'version': '19.0.1.0.0',
'category': 'Human Resources/Attendances',
'summary': 'AI-Powered Intelligence for Fusion Clock - GPT Reports, Anomaly Detection, Natural Language Queries & Employee Assistant',
'description': """
Fusion Clock AI - Intelligent Attendance Analytics
====================================================
AI enhancement module for Fusion Clock. Requires OpenAI API key.
* **Natural Language Dashboard** - Ask questions about attendance in plain English
* **AI Narrative Reports** - Human-readable weekly/monthly summaries
* **Payroll Anomaly Detection** - GPT flags suspicious patterns before payroll
* **Employee AI Assistant** - Portal chatbot for hours, leave requests, schedule queries
* **Attendance Coach** - Personalized weekly tips per employee
* **Correction Review Advisor** - AI context for approval/rejection decisions
* **Predictive Understaffing** - Forecast absence likelihood by day
* **Shift Optimization** - Data-driven shift reassignment suggestions
* **Compliance Checks** - Labor law violation alerts
* **Smart Configuration** - Describe policies in English, AI maps to settings
* **Geofence Tuning** - AI suggests radius adjustments from clock-in data
* **Intelligent Incident Logs** - Auto-generated explanations for every activity log
Requires: fusion_clock module and an OpenAI API key.
""",
'author': 'Nexa Systems Inc.',
'website': 'https://nexasystems.io',
'license': 'OPL-1',
'depends': [
'fusion_clock',
'mail',
],
'external_dependencies': {
'python': ['openai'],
},
'data': [
'security/security.xml',
'security/ir.model.access.csv',
'data/ir_config_parameter_data.xml',
'data/ir_cron_data.xml',
'data/ai_prompt_data.xml',
'views/res_config_settings_views.xml',
'views/ai_conversation_views.xml',
'views/ai_usage_views.xml',
'views/ai_prompt_views.xml',
'views/hr_employee_views.xml',
'views/clock_report_views.xml',
'views/clock_correction_views.xml',
'views/ai_menus.xml',
'views/portal_ai_templates.xml',
],
'assets': {
'web.assets_frontend': [
'fusion_clock_ai/static/src/css/portal_ai.css',
'fusion_clock_ai/static/src/js/ai_chat_portal.js',
],
'web.assets_backend': [
'fusion_clock_ai/static/src/scss/fusion_clock_ai.scss',
'fusion_clock_ai/static/src/js/ai_chat_backend.js',
'fusion_clock_ai/static/src/xml/ai_chat_backend.xml',
],
},
'installable': True,
'auto_install': False,
'application': False,
}

Binary file not shown.

View File

@@ -0,0 +1,6 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from . import ai_api
from . import portal_ai

Some files were not shown because too many files have changed in this diff Show More