diff --git a/activeblue_familylaw/__manifest__.py b/activeblue_familylaw/__manifest__.py
index 9a24f90..097d930 100644
--- a/activeblue_familylaw/__manifest__.py
+++ b/activeblue_familylaw/__manifest__.py
@@ -23,6 +23,7 @@
'crm',
'account',
'hr_expense',
+ 'analytic',
# 'sign', # Odoo Sign — enable when confirmed installed
# 'queue_job', # OCA queue_job — install from https://github.com/OCA/queue
],
@@ -56,6 +57,7 @@
'views/fl_wizard_views.xml',
'views/fl_discovery_suggest_views.xml',
'views/fl_conflict_check_views.xml',
+ 'views/fl_timesheet_views.xml',
'views/menu_views.xml',
# Phase 4 — QWeb PDF Reports
'report/report_financial_affidavit_short.xml',
diff --git a/activeblue_familylaw/models/__init__.py b/activeblue_familylaw/models/__init__.py
index 6c20caa..6b60232 100644
--- a/activeblue_familylaw/models/__init__.py
+++ b/activeblue_familylaw/models/__init__.py
@@ -18,3 +18,4 @@ from . import fl_case
from . import fl_conflict_check
from . import fl_paralegal_agent
from . import fl_attorney_agent
+from . import fl_timesheet
diff --git a/activeblue_familylaw/models/fl_attorney_agent.py b/activeblue_familylaw/models/fl_attorney_agent.py
index a07e911..c3b40e7 100644
--- a/activeblue_familylaw/models/fl_attorney_agent.py
+++ b/activeblue_familylaw/models/fl_attorney_agent.py
@@ -58,6 +58,7 @@ class FlAttorneyAgent(models.AbstractModel):
statutes = self._candidate_statutes(case)
caselaw = self._candidate_caselaw(case)
+ ai_used = False
try:
context = self._build_context(case, statutes, caselaw)
result = self.env['fl.ai.engine'].call_claude_json(
@@ -69,13 +70,31 @@ class FlAttorneyAgent(models.AbstractModel):
max_tokens=3000,
)
self._store_memo(case, analysis, result, statutes, caselaw)
+ ai_used = True
except Exception as exc:
_logger.error("Attorney agent failed for case %s: %s",
case.id, exc, exc_info=True)
self._store_fallback(case, analysis, statutes, caselaw, str(exc))
+ self._log_ai_time(case, 'Attorney strategy memo', ai_used)
return analysis
+ def _log_ai_time(self, case, note, ai_used):
+ """Log a non-billable AI audit entry. No-op until fl.timesheet exists."""
+ if 'fl.timesheet' not in self.env:
+ return
+ try:
+ self.env['fl.timesheet'].sudo().create({
+ 'case_id': case.id,
+ 'name': note,
+ 'is_billable': False,
+ 'ai_agent': 'attorney',
+ 'duration_hours': 0.1 if ai_used else 0.02,
+ })
+ except Exception as exc: # never block on audit logging
+ _logger.warning("Attorney AI-time logging skipped for case %s: %s",
+ case.id, exc)
+
# ──────────────────────────────────────────────────────────────────────
# Candidate library (grounds the model in real records)
# ──────────────────────────────────────────────────────────────────────
diff --git a/activeblue_familylaw/models/fl_case.py b/activeblue_familylaw/models/fl_case.py
index 2f2ab4c..f5fdf07 100644
--- a/activeblue_familylaw/models/fl_case.py
+++ b/activeblue_familylaw/models/fl_case.py
@@ -472,6 +472,28 @@ class FlCase(models.Model):
compute='_compute_total_expenses', store=True
)
+ # ══════════════════════════════════════════════════════════════════════
+ # TIME & BILLING
+ # ══════════════════════════════════════════════════════════════════════
+
+ currency_id = fields.Many2one(
+ 'res.currency', string='Currency',
+ default=lambda self: self.env.company.currency_id
+ )
+ timesheet_ids = fields.One2many(
+ 'fl.timesheet', 'case_id', string='Timesheet Entries'
+ )
+ total_billable_hours = fields.Float(
+ string='Billable Hours', compute='_compute_timesheet_totals'
+ )
+ total_billable_amount = fields.Monetary(
+ string='Billable Amount', compute='_compute_timesheet_totals',
+ currency_field='currency_id'
+ )
+ total_ai_audit_hours = fields.Float(
+ string='AI Audit Hours', compute='_compute_timesheet_totals'
+ )
+
# ══════════════════════════════════════════════════════════════════════
# POST-ORDER
# ══════════════════════════════════════════════════════════════════════
@@ -804,6 +826,22 @@ class FlCase(models.Model):
for rec in self:
rec.total_expenses = sum(rec.expense_ids.mapped('total_amount'))
+ @api.depends(
+ 'timesheet_ids.duration_hours',
+ 'timesheet_ids.is_billable',
+ 'timesheet_ids.billable_amount',
+ 'timesheet_ids.ai_agent',
+ )
+ def _compute_timesheet_totals(self):
+ for rec in self:
+ billable = rec.timesheet_ids.filtered(lambda t: t.is_billable)
+ rec.total_billable_hours = sum(billable.mapped('duration_hours'))
+ rec.total_billable_amount = sum(billable.mapped('billable_amount'))
+ rec.total_ai_audit_hours = sum(
+ rec.timesheet_ids.filtered(lambda t: t.ai_agent)
+ .mapped('duration_hours')
+ )
+
@api.depends('filing_date')
def _compute_retroactivity_date(self):
"""
diff --git a/activeblue_familylaw/models/fl_paralegal_agent.py b/activeblue_familylaw/models/fl_paralegal_agent.py
index f6a8963..a3954a2 100644
--- a/activeblue_familylaw/models/fl_paralegal_agent.py
+++ b/activeblue_familylaw/models/fl_paralegal_agent.py
@@ -311,7 +311,7 @@ class FlParalegalAgent(models.AbstractModel):
if 'fl.timesheet' not in self.env:
return
try:
- self.env['fl.timesheet'].create({
+ self.env['fl.timesheet'].sudo().create({
'case_id': case.id,
'name': note,
'is_billable': False,
diff --git a/activeblue_familylaw/models/fl_timesheet.py b/activeblue_familylaw/models/fl_timesheet.py
new file mode 100644
index 0000000..eecaec8
--- /dev/null
+++ b/activeblue_familylaw/models/fl_timesheet.py
@@ -0,0 +1,129 @@
+from odoo import api, fields, models
+
+
+class HrEmployee(models.Model):
+ _inherit = 'hr.employee'
+
+ fl_hourly_rate = fields.Float(
+ string='Family Law Billing Rate ($/hr)',
+ help='Hourly billing rate used for family-law case timesheets. '
+ 'Falls back to the firm default (ir.config_parameter '
+ 'fl_timesheet.default_hourly_rate) when unset.'
+ )
+
+
+class FlTimesheet(models.Model):
+ """
+ Family-law timesheet / AI audit entry.
+
+ Wraps account.analytic.line via delegation inheritance: each fl.timesheet
+ owns one analytic line, so billable hours flow through standard Odoo
+ Accounting. duration_hours maps to the analytic line's unit_amount.
+ AI agents create non-billable entries (is_billable=False, ai_agent set) for
+ an audit trail; people create billable entries from the case Timesheet tab.
+ """
+ _name = 'fl.timesheet'
+ _description = 'Family Law Timesheet / AI Audit Entry'
+ _inherits = {'account.analytic.line': 'analytic_line_id'}
+ _order = 'date desc, id desc'
+
+ analytic_line_id = fields.Many2one(
+ 'account.analytic.line', string='Analytic Line',
+ required=True, ondelete='cascade', auto_join=True, index=True
+ )
+ case_id = fields.Many2one(
+ 'fl.case', string='Case', required=True, ondelete='cascade', index=True
+ )
+ employee_id = fields.Many2one('hr.employee', string='Employee')
+ is_billable = fields.Boolean(
+ string='Billable', default=True,
+ help='Uncheck for non-billable entries (e.g. AI audit time).'
+ )
+ ai_agent = fields.Selection([
+ ('paralegal', 'Paralegal Agent'),
+ ('attorney', 'Attorney Agent'),
+ ], string='AI Agent',
+ help='Set when an AI agent auto-logged this entry for the audit trail.')
+ duration_hours = fields.Float(string='Duration (Hours)', default=0.0)
+
+ hourly_rate = fields.Float(
+ string='Rate ($/hr)', compute='_compute_billable_amount'
+ )
+ billable_amount = fields.Monetary(
+ string='Amount', compute='_compute_billable_amount',
+ currency_field='currency_id'
+ )
+
+ @api.depends('duration_hours', 'is_billable', 'employee_id')
+ def _compute_billable_amount(self):
+ default_rate = float(self.env['ir.config_parameter'].sudo().get_param(
+ 'fl_timesheet.default_hourly_rate', 0.0) or 0.0)
+ for rec in self:
+ rate = rec.employee_id.fl_hourly_rate or default_rate
+ rec.hourly_rate = rate
+ rec.billable_amount = (
+ rec.duration_hours * rate if rec.is_billable else 0.0
+ )
+
+ # ──────────────────────────────────────────────────────────────────────
+ # CRUD — keep the wrapped analytic line in sync
+ # ──────────────────────────────────────────────────────────────────────
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ for vals in vals_list:
+ self._prepare_line_vals(vals)
+ return super().create(vals_list)
+
+ def write(self, vals):
+ if 'duration_hours' in vals:
+ vals['unit_amount'] = vals['duration_hours']
+ return super().write(vals)
+
+ def _prepare_line_vals(self, vals):
+ """Fill the analytic line's required fields before delegation create."""
+ case = self.env['fl.case'].browse(vals['case_id']) if vals.get('case_id') \
+ else self.env['fl.case']
+ if not vals.get('account_id'):
+ vals['account_id'] = self._resolve_analytic_account(case).id
+ if not vals.get('name'):
+ if vals.get('ai_agent'):
+ label = dict(self._fields['ai_agent'].selection).get(
+ vals['ai_agent'], vals['ai_agent'])
+ vals['name'] = f'AI audit: {label}'
+ else:
+ vals['name'] = case.name or 'Time entry'
+ if 'duration_hours' in vals:
+ vals['unit_amount'] = vals['duration_hours']
+
+ def _resolve_analytic_account(self, case):
+ """
+ Resolve the analytic account for the wrapped line. Prefers the case
+ project's analytic account (field name varies by version), falling back
+ to a cached firm-wide account created under any available analytic plan.
+ """
+ Account = self.env['account.analytic.account'].sudo()
+
+ project = case.project_id if case else self.env['project.project']
+ if project:
+ for fname in ('account_id', 'analytic_account_id'):
+ acc = getattr(project, fname, False)
+ if acc:
+ return acc
+
+ param = self.env['ir.config_parameter'].sudo()
+ acc_id = param.get_param('fl_timesheet.analytic_account_id')
+ if acc_id:
+ acc = Account.browse(int(acc_id))
+ if acc.exists():
+ return acc
+
+ Plan = self.env['account.analytic.plan'].sudo()
+ plan = Plan.search([], limit=1) or Plan.create({'name': 'Family Law'})
+ acc = Account.create({
+ 'name': 'Family Law Cases',
+ 'plan_id': plan.id,
+ 'company_id': self.env.company.id,
+ })
+ param.set_param('fl_timesheet.analytic_account_id', acc.id)
+ return acc
diff --git a/activeblue_familylaw/security/ir.model.access.csv b/activeblue_familylaw/security/ir.model.access.csv
index 358f772..195438e 100644
--- a/activeblue_familylaw/security/ir.model.access.csv
+++ b/activeblue_familylaw/security/ir.model.access.csv
@@ -81,6 +81,12 @@ access_fl_income_withholding_petitioner,fl.income.withholding petitioner,model_f
# ── fl.conflict.check ────────────────────────────────────────────────────────
access_fl_conflict_check_admin,fl.conflict.check admin,model_fl_conflict_check,group_admin,1,1,1,1
access_fl_conflict_check_paralegal,fl.conflict.check paralegal,model_fl_conflict_check,group_paralegal,1,1,1,0
+# ── fl.timesheet ─────────────────────────────────────────────────────────────
+access_fl_timesheet_admin,fl.timesheet admin,model_fl_timesheet,group_admin,1,1,1,1
+access_fl_timesheet_paralegal,fl.timesheet paralegal,model_fl_timesheet,group_paralegal,1,1,1,0
+# ── account.analytic.line (timesheet wraps it — ensure non-admins can post) ───
+access_account_analytic_line_fl_admin,account.analytic.line fl admin,analytic.model_account_analytic_line,group_admin,1,1,1,1
+access_account_analytic_line_fl_paralegal,account.analytic.line fl paralegal,analytic.model_account_analytic_line,group_paralegal,1,1,1,0
# ── fl.intake.wizard ─────────────────────────────────────────────────────────
access_fl_intake_wizard_admin,fl.intake.wizard admin,model_fl_intake_wizard,group_admin,1,1,1,1
access_fl_intake_wizard_paralegal,fl.intake.wizard paralegal,model_fl_intake_wizard,group_paralegal,1,1,1,1
diff --git a/activeblue_familylaw/views/fl_case_views.xml b/activeblue_familylaw/views/fl_case_views.xml
index 2e97793..268f3a7 100644
--- a/activeblue_familylaw/views/fl_case_views.xml
+++ b/activeblue_familylaw/views/fl_case_views.xml
@@ -310,6 +310,34 @@