diff --git a/fusion_repairs/__manifest__.py b/fusion_repairs/__manifest__.py index c772b91c..f8c2cc6b 100644 --- a/fusion_repairs/__manifest__.py +++ b/fusion_repairs/__manifest__.py @@ -4,7 +4,7 @@ { 'name': 'Fusion Repairs', - 'version': '19.0.2.1.0', + 'version': '19.0.2.2.0', 'category': 'Inventory/Repairs', 'summary': 'Guided medical equipment repair intake, dispatch, maintenance, and self-service portal', 'description': """ @@ -56,6 +56,7 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved. 'website', 'sale_management', 'stock', + 'purchase', 'repair', 'maintenance', 'fusion_tasks', @@ -78,6 +79,8 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved. 'data/emergency_charge_data.xml', 'data/callout_rate_data.xml', 'data/delivery_charge_data.xml', + 'data/symptom_class_data.xml', + 'data/flowchart_stairlift_not_moving_data.xml', # Views 'views/repair_product_category_views.xml', 'views/intake_template_views.xml', @@ -93,6 +96,8 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved. 'views/repair_order_views.xml', 'views/repair_part_order_views.xml', 'views/repair_service_plan_views.xml', + 'views/repair_symptom_class_views.xml', + 'views/repair_flowchart_views.xml', 'views/sale_order_views.xml', 'views/technician_task_views.xml', 'views/res_partner_views.xml', @@ -119,6 +124,15 @@ Copyright (C) 2024-2026 Nexa Systems Inc. All rights reserved. 'fusion_repairs/static/src/scss/dashboard.scss', 'fusion_repairs/static/src/components/dashboard/dashboard.js', 'fusion_repairs/static/src/components/dashboard/dashboard.xml', + # Bundle 11: Drawflow flowchart designer + runner. CSS first. + 'fusion_repairs/static/src/lib/drawflow/drawflow.min.css', + 'fusion_repairs/static/src/lib/drawflow/drawflow.min.js', + 'fusion_repairs/static/src/components/flowchart_designer/flowchart_designer.scss', + 'fusion_repairs/static/src/components/flowchart_designer/flowchart_designer.js', + 'fusion_repairs/static/src/components/flowchart_designer/flowchart_designer.xml', + 'fusion_repairs/static/src/components/flowchart_runner/flowchart_runner.scss', + 'fusion_repairs/static/src/components/flowchart_runner/flowchart_runner.js', + 'fusion_repairs/static/src/components/flowchart_runner/flowchart_runner.xml', ], 'web.assets_frontend': [ 'fusion_repairs/static/src/scss/portal_repair_mobile.scss', diff --git a/fusion_repairs/data/flowchart_stairlift_not_moving_data.xml b/fusion_repairs/data/flowchart_stairlift_not_moving_data.xml new file mode 100644 index 00000000..50963abc --- /dev/null +++ b/fusion_repairs/data/flowchart_stairlift_not_moving_data.xml @@ -0,0 +1,251 @@ + + + + + + + Stairlift - Not Moving + + + + Standard diagnostic walk-through for a stairlift that is not responding + to its controls. Most common root causes (power, seatbelt, swivel lock) + are checked first; if all clear, dispatch a technician.

+ ]]>
+
+ + + + + Is the power on? + question + + 10 + 100 + 100 + Ask the client if the stairlift has power. Check:

+
    +
  • Is the unit plugged into the wall outlet?
  • +
  • Is the master key switch (usually on the carriage) turned ON?
  • +
  • Is the battery indicator LED lit?
  • +
+ ]]>
+
+ + + + No power - check breaker + suggestion + 20 + 450 + 20 + Ask the client to:

+
    +
  1. Test the wall outlet with a phone charger or lamp.
  2. +
  3. Check the home's electrical panel for a tripped breaker.
  4. +
  5. If the breaker is tripped, flip it OFF then ON.
  6. +
+ ]]>
+
+ + + + Is the seatbelt fastened? + question + 30 + 450 + 200 + Most stairlifts will not move unless the seatbelt safety + switch is engaged. Confirm the belt is buckled.

+ ]]>
+
+ + + + Fasten seatbelt and retry + suggestion + 40 + 800 + 120 + Have the client buckle the seatbelt and try the controls again.

+ ]]>
+
+ + + + Is the seat rotated to the forward (travel) position? + question + 50 + 800 + 300 + The swivel lock must be engaged in the forward position. If + the seat is partially turned, the stairlift will refuse to move.

+ ]]>
+
+ + + + Rotate seat to forward and retry + suggestion + 60 + 1150 + 220 + Ask the client to rotate the seat back to the forward + travel position until the swivel lock clicks. Then try again.

+ ]]>
+
+ + + + Working - resolved on call + outcome + resolved + 100 + 1500 + 150 + Great - the stairlift is moving again. Confirm with the client, + close the repair, and offer to book an annual safety inspection + if they have not had one recently.

+ ]]>
+
+ + + + Still not moving - dispatch technician + outcome + escalate + 110 + 1500 + 350 + Common fixes are exhausted. Likely root causes for a tech to investigate:

+
    +
  • Control board fault (most common - $300-$500 part)
  • +
  • Motor brushes worn
  • +
  • Track end-stop safety switch
  • +
+

Escalate and the transcript will be visible to the tech before they arrive.

+ ]]>
+
+ + + + + + + No - no power + 10 + + + + + + Yes - power is on + 20 + + + + + + + Power restored - works now + 10 + + + + + + Still no power + 20 + + + + + + + No + 10 + + + + + + Yes + 20 + + + + + + + Works now + 10 + + + + + + Still not moving - try next check + 20 + + + + + + + No - seat is turned + 10 + + + + + + Yes - seat is forward + 20 + + + + + + + Works now + 10 + + + + + + Still not moving + 20 + + +
+
diff --git a/fusion_repairs/data/mail_template_data.xml b/fusion_repairs/data/mail_template_data.xml index 562fad8c..b6bbe0d1 100644 --- a/fusion_repairs/data/mail_template_data.xml +++ b/fusion_repairs/data/mail_template_data.xml @@ -242,26 +242,48 @@ Repair: Parts Ordered (Client) - Parts ordered for your {{ object.repair_order_id.x_fc_repair_category_id.name or 'equipment' }} - {{ object.repair_order_id.name }} + Parts ordered for your {{ object.repair_order_id.x_fc_repair_category_id.name or 'equipment' }} - expected {{ object.expected_date }} {{ (object.company_id.email_formatted or user.email_formatted) }} {{ object.partner_id.id }}
-

Parts ordered

-

- We've placed an order for the parts your - needs. Expected arrival: . +

Your part has been ordered

+ + +
+
+ Expected arrival +
+
+ +
+
+ +

+ We have placed the order for the part your + + needs to get you up and running again.

+ - - - - + +
Part
Manufacturer
Ref
Repair
Reference
-

We'll email again as soon as the parts arrive at our warehouse.

+ +
+ There is nothing for you to do right now. + Our office will call you as soon as the part arrives at our + warehouse to confirm a return visit time. If you have any + questions about pricing or scheduling, please reach out to + our office directly. +
+ +

+ We will email you again the moment the part arrives. +

diff --git a/fusion_repairs/data/symptom_class_data.xml b/fusion_repairs/data/symptom_class_data.xml new file mode 100644 index 00000000..b4106475 --- /dev/null +++ b/fusion_repairs/data/symptom_class_data.xml @@ -0,0 +1,107 @@ + + + + + + + + Not Moving + not_moving + + 10 + fa-pause-circle + Stairlift unresponsive to controls; will not travel up or down. + + + + Beeps / Alarm + beeps_alarm + + 20 + fa-bell + + + + Stops Midway + stops_midway + + 30 + fa-hand-paper-o + + + + Remote / Call Station Issue + remote_issue + + 40 + fa-mobile + + + + Swivel Won't Rotate + swivel_stuck + + 50 + fa-refresh + + + + Slow / Sluggish + slow_sluggish + + 60 + fa-tachometer + + + + Track Issue (noise / grinding) + track_issue + + 70 + fa-bars + + + + + Not Moving + not_moving + + 10 + fa-pause-circle + + + Remote / Pendant Issue + remote_issue + + 20 + fa-mobile + + + + + Not Moving + not_moving + + 10 + fa-pause-circle + + + + + Will Not Rise / Recline + not_moving + + 10 + fa-pause-circle + + + + diff --git a/fusion_repairs/migrations/19.0.2.2.0/post-migration.py b/fusion_repairs/migrations/19.0.2.2.0/post-migration.py new file mode 100644 index 00000000..e4dd5710 --- /dev/null +++ b/fusion_repairs/migrations/19.0.2.2.0/post-migration.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- +"""Post-migration for 19.0.2.2.0 - Bundle 11. + +Back-fills the new x_fc_symptom_class_id M2O on repair.order from the legacy +x_fc_issue_category Char so that existing repairs can immediately be used +with troubleshooting flowcharts. For each distinct (category, issue_category) +text we encounter, we either match an existing symptom.class by lowercase +name OR create a placeholder symptom row tagged 'auto-imported' so a manager +can clean it up later. +""" + +import logging +import re + +_logger = logging.getLogger(__name__) + + +def _slug(text): + return re.sub(r'[^a-z0-9_]+', '_', (text or '').strip().lower()).strip('_') or 'unknown' + + +def migrate(cr, version): + if not version: + return # fresh install, nothing to back-fill + + # Find every (category_id, issue_category) pair on existing repairs that + # is not yet linked to a symptom class. + cr.execute(""" + SELECT DISTINCT x_fc_repair_category_id, x_fc_issue_category + FROM repair_order + WHERE x_fc_issue_category IS NOT NULL + AND x_fc_issue_category <> '' + AND x_fc_symptom_class_id IS NULL + """) + pairs = cr.fetchall() + if not pairs: + return + + _logger.info( + 'Bundle 11 migration: back-filling symptom_class for %d distinct ' + '(category, issue_category) pairs', len(pairs), + ) + + for cat_id, issue_text in pairs: + if not cat_id: + continue + code = _slug(issue_text) + # Try to reuse an existing symptom class by code-or-name on this category. + cr.execute(""" + SELECT id FROM fusion_repair_symptom_class + WHERE category_id = %s + AND (code = %s OR LOWER(name->>'en_US') = LOWER(%s)) + LIMIT 1 + """, (cat_id, code, issue_text)) + row = cr.fetchone() + if row: + sym_id = row[0] + else: + # Create a placeholder so the M2O isn't lost. Manager renames later. + display = (issue_text or 'Imported').strip()[:80] + cr.execute(""" + INSERT INTO fusion_repair_symptom_class + (name, code, category_id, sequence, active, + description, icon, create_date, write_date, create_uid, + write_uid) + VALUES (%s, %s, %s, %s, %s, %s, %s, + NOW(), NOW(), 1, 1) + RETURNING id + """, ( + {'en_US': display}, + code, + cat_id, + 90, + True, + 'Auto-imported by Bundle 11 migration from legacy ' + 'x_fc_issue_category. Review and rename if needed.', + 'fa-exclamation-circle', + )) + sym_id = cr.fetchone()[0] + # Back-fill the M2O on every repair with this pair. + cr.execute(""" + UPDATE repair_order + SET x_fc_symptom_class_id = %s + WHERE x_fc_repair_category_id = %s + AND x_fc_issue_category = %s + AND x_fc_symptom_class_id IS NULL + """, (sym_id, cat_id, issue_text)) + + _logger.info('Bundle 11 migration: back-fill complete.') diff --git a/fusion_repairs/models/__init__.py b/fusion_repairs/models/__init__.py index 5810aaa9..7ffe12f7 100644 --- a/fusion_repairs/models/__init__.py +++ b/fusion_repairs/models/__init__.py @@ -19,6 +19,9 @@ from . import repair_part_order from . import repair_callout_rate from . import repair_labor_warranty from . import repair_delivery_charge +from . import repair_symptom_class +from . import repair_flowchart +from . import repair_flowchart_run from . import product_template from . import res_partner from . import res_users diff --git a/fusion_repairs/models/intake_service.py b/fusion_repairs/models/intake_service.py index 2d79bf7f..d52baeb2 100644 --- a/fusion_repairs/models/intake_service.py +++ b/fusion_repairs/models/intake_service.py @@ -130,6 +130,7 @@ class FusionRepairIntakeService(models.AbstractModel): 'x_fc_third_party_equipment': bool(item.get('third_party')), 'x_fc_urgency': item.get('urgency') or 'normal', 'x_fc_issue_category': item.get('issue_category') or False, + 'x_fc_symptom_class_id': item.get('symptom_class_id') or False, 'x_fc_is_quote_only': bool(quote_only), 'x_fc_rush_requested': bool(rush_requested), 'x_fc_rush_tier': rush_tier or False, diff --git a/fusion_repairs/models/repair_flowchart.py b/fusion_repairs/models/repair_flowchart.py new file mode 100644 index 00000000..839ffc72 --- /dev/null +++ b/fusion_repairs/models/repair_flowchart.py @@ -0,0 +1,394 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +"""Troubleshooting flowchart designer-time models. + +Three models cooperate: + + fusion.repair.flowchart one chart per (category, symptom) + -> fusion.repair.flowchart.node one row per visual node + -> fusion.repair.flowchart.edge directed edges between nodes + +The full visual layout (positions, drawflow internal state) is also serialised +to flowchart.canvas_layout as JSON so the Drawflow designer round-trips +without needing per-node x/y stored separately. The structured node/edge +records exist for queries (e.g. "show me all flowcharts that mention 'control +board' in any node"), reporting, runtime traversal, and so that the runner can +operate without parsing the canvas JSON. +""" + +import json +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError + +_logger = logging.getLogger(__name__) + + +NODE_TYPES = [ + ('question', 'Question'), + ('suggestion', 'Suggestion'), + ('info', 'Info'), + ('outcome', 'Outcome'), +] + +OUTCOME_KINDS = [ + ('resolved', 'Resolved on Call'), + ('escalate', 'Escalate to Technician'), + ('order_part', 'Order Part'), +] + + +class FusionRepairFlowchart(models.Model): + _name = 'fusion.repair.flowchart' + _inherit = ['mail.thread'] + _description = 'Repair Troubleshooting Flowchart' + _order = 'category_id, symptom_class_id, name' + + name = fields.Char(required=True, translate=True, tracking=True) + category_id = fields.Many2one( + 'fusion.repair.product.category', + string='Equipment Category', + required=True, + index=True, + ondelete='cascade', + tracking=True, + ) + symptom_class_id = fields.Many2one( + 'fusion.repair.symptom.class', + string='Symptom', + required=True, + index=True, + ondelete='cascade', + domain="[('category_id', '=', category_id)]", + tracking=True, + ) + description = fields.Html(translate=True) + active = fields.Boolean(default=True, tracking=True) + published = fields.Boolean( + default=False, + tracking=True, + help='Only published flowcharts are offered to CS during intake. Draft ' + 'charts are visible to admins for editing but never run.', + ) + version = fields.Integer( + default=1, + readonly=True, + copy=False, + help='Bumped on every save in the designer; lets us snapshot which ' + 'version a flowchart.run was based on.', + ) + + canvas_layout = fields.Text( + string='Designer State', + help='Drawflow JSON for the visual designer canvas - positions, zoom, ' + 'connection curves. Round-tripped by the designer UI.', + ) + + node_ids = fields.One2many('fusion.repair.flowchart.node', 'flowchart_id') + edge_ids = fields.One2many('fusion.repair.flowchart.edge', 'flowchart_id') + node_count = fields.Integer(compute='_compute_counts') + edge_count = fields.Integer(compute='_compute_counts') + + start_node_id = fields.Many2one( + 'fusion.repair.flowchart.node', + string='Start Node', + compute='_compute_start_node', + store=True, + help='Auto-computed - whichever node has is_start=True. Validation ' + 'enforces exactly one.', + ) + + company_id = fields.Many2one( + 'res.company', + default=lambda self: self.env.company, + ) + + _active_per_pair_unique = models.Constraint( + # NULLs treated as distinct so multiple draft (active=False) charts + # can coexist; only one active+published chart per pair is the + # business rule we care about - enforced via Python below. + 'unique(category_id, symptom_class_id, company_id, published)', + 'Only one published flowchart per (category, symptom) per company. ' + 'Unpublish or duplicate before publishing another.', + ) + + @api.depends('node_ids', 'edge_ids') + def _compute_counts(self): + for r in self: + r.node_count = len(r.node_ids) + r.edge_count = len(r.edge_ids) + + @api.depends('node_ids.is_start') + def _compute_start_node(self): + for r in self: + r.start_node_id = r.node_ids.filtered(lambda n: n.is_start)[:1] + + # ------------------------------------------------------------------ + # ACTIONS + # ------------------------------------------------------------------ + def action_open_designer(self): + """Open the OWL Drawflow designer as a client action.""" + self.ensure_one() + return { + 'type': 'ir.actions.client', + 'tag': 'fusion_repair_flowchart_designer', + 'name': _('Flowchart Designer - %s') % self.name, + 'params': {'flowchart_id': self.id}, + } + + def action_publish(self): + for r in self: + r._validate_publishable() + r.published = True + r.message_post(body=_('Flowchart published. CS will now see it for ' + 'new intakes on this (category, symptom).')) + + def action_unpublish(self): + for r in self: + r.published = False + + def action_duplicate_as_draft(self): + self.ensure_one() + copy = self.copy({ + 'name': _('%s (copy)') % self.name, + 'published': False, + 'version': 1, + }) + return { + 'type': 'ir.actions.act_window', + 'name': copy.name, + 'res_model': self._name, + 'view_mode': 'form', + 'res_id': copy.id, + } + + # ------------------------------------------------------------------ + # VALIDATION + # ------------------------------------------------------------------ + def _validate_publishable(self): + for r in self: + starts = r.node_ids.filtered(lambda n: n.is_start) + if len(starts) != 1: + raise ValidationError(_( + 'Flowchart "%s" must have exactly one start node ' + '(found %d). Edit in the designer and try again.' + ) % (r.name, len(starts))) + # Every non-outcome node should have at least one outgoing edge. + for n in r.node_ids: + if n.node_type != 'outcome' and not r.edge_ids.filtered( + lambda e: e.source_node_id == n + ): + raise ValidationError(_( + 'Node "%s" is not an outcome but has no outgoing edges. ' + 'Connect it to the next step or change its type to ' + 'Outcome.' + ) % n.name) + + # ------------------------------------------------------------------ + # DESIGNER RPC API (called from the OWL component) + # ------------------------------------------------------------------ + @api.model + def designer_load(self, flowchart_id): + """Return everything the designer needs to render this chart.""" + chart = self.browse(flowchart_id).exists() + if not chart: + raise UserError(_('Flowchart not found.')) + return { + 'id': chart.id, + 'name': chart.name, + 'category_id': chart.category_id.id, + 'category_name': chart.category_id.name, + 'symptom_class_id': chart.symptom_class_id.id, + 'symptom_name': chart.symptom_class_id.name, + 'published': chart.published, + 'version': chart.version, + 'canvas_layout': chart.canvas_layout or '', + 'nodes': [n._designer_dict() for n in chart.node_ids], + 'edges': [e._designer_dict() for e in chart.edge_ids], + } + + def designer_save(self, payload): + """Replace the chart's nodes + edges with the designer's snapshot. + + payload = { + 'canvas_layout': '', + 'nodes': [{'client_id': str, 'name': ..., 'node_type': ..., + 'content_html': ..., 'media_ids': [int], + 'is_start': bool, 'outcome_kind': ..., + 'canvas_x': int, 'canvas_y': int}, ...], + 'edges': [{'source_client_id': str, 'target_client_id': str, + 'label': str, 'sequence': int}, ...], + } + + Returns the saved chart (designer_load shape). + """ + self.ensure_one() + # Atomic replace - simplest and safest. Edges depend on nodes so + # delete edges first. + self.edge_ids.unlink() + self.node_ids.unlink() + + client_to_db = {} + Node = self.env['fusion.repair.flowchart.node'].sudo() + for nd in payload.get('nodes', []): + rec = Node.create({ + 'flowchart_id': self.id, + 'name': nd.get('name') or _('Untitled'), + 'node_type': nd.get('node_type') or 'question', + 'content_html': nd.get('content_html') or False, + 'is_start': bool(nd.get('is_start')), + 'outcome_kind': nd.get('outcome_kind') or False, + 'canvas_x': int(nd.get('canvas_x') or 0), + 'canvas_y': int(nd.get('canvas_y') or 0), + 'media_ids': [(6, 0, nd.get('media_ids') or [])], + }) + client_to_db[nd.get('client_id')] = rec.id + + Edge = self.env['fusion.repair.flowchart.edge'].sudo() + for ed in payload.get('edges', []): + src = client_to_db.get(ed.get('source_client_id')) + tgt = client_to_db.get(ed.get('target_client_id')) + if not src or not tgt: + _logger.warning( + 'Skipping edge with unknown client ids: %s -> %s', + ed.get('source_client_id'), ed.get('target_client_id'), + ) + continue + Edge.create({ + 'flowchart_id': self.id, + 'source_node_id': src, + 'target_node_id': tgt, + 'label': ed.get('label') or '', + 'sequence': int(ed.get('sequence') or 10), + }) + + self.write({ + 'canvas_layout': payload.get('canvas_layout') or '', + 'version': self.version + 1, + }) + # Soft-validate (don't raise) - the designer can publish later. + try: + self._validate_publishable() + except ValidationError as e: + _logger.info('Saved chart %s but it is not yet publishable: %s', + self.name, e) + return self.designer_load(self.id) + + +class FusionRepairFlowchartNode(models.Model): + _name = 'fusion.repair.flowchart.node' + _description = 'Flowchart Node' + _order = 'flowchart_id, sequence, id' + + flowchart_id = fields.Many2one( + 'fusion.repair.flowchart', + required=True, + ondelete='cascade', + index=True, + ) + name = fields.Char(required=True, translate=True) + sequence = fields.Integer(default=10) + node_type = fields.Selection( + NODE_TYPES, default='question', required=True, + ) + content_html = fields.Html( + translate=True, + sanitize=True, + sanitize_overridable=True, + help='Rich text shown to CS when this node is the current step. ' + 'Photos / videos go in media_ids instead.', + ) + media_ids = fields.Many2many( + 'ir.attachment', + 'fusion_repair_flowchart_node_media_rel', + 'node_id', 'attachment_id', + string='Media', + help='Photos and short videos that illustrate this step.', + ) + is_start = fields.Boolean( + string='Start', + help='Exactly one node per flowchart must be the start.', + ) + outcome_kind = fields.Selection( + OUTCOME_KINDS, + string='Outcome Kind', + help='Required when node_type is Outcome - determines what happens ' + 'when the run reaches this node.', + ) + + # Designer-only - persisted for round-trip but not used by the runtime. + canvas_x = fields.Integer(default=0) + canvas_y = fields.Integer(default=0) + + outgoing_edge_ids = fields.One2many( + 'fusion.repair.flowchart.edge', 'source_node_id', + string='Outgoing Edges', + ) + incoming_edge_ids = fields.One2many( + 'fusion.repair.flowchart.edge', 'target_node_id', + string='Incoming Edges', + ) + + def _designer_dict(self): + self.ensure_one() + return { + 'id': self.id, + 'name': self.name, + 'node_type': self.node_type, + 'content_html': self.content_html or '', + 'is_start': self.is_start, + 'outcome_kind': self.outcome_kind or '', + 'canvas_x': self.canvas_x, + 'canvas_y': self.canvas_y, + 'media_ids': self.media_ids.ids, + 'media': [{ + 'id': a.id, + 'name': a.name, + 'mimetype': a.mimetype, + 'url': f'/web/image/{a.id}', + } for a in self.media_ids], + } + + +class FusionRepairFlowchartEdge(models.Model): + _name = 'fusion.repair.flowchart.edge' + _description = 'Flowchart Edge' + _order = 'source_node_id, sequence, id' + + flowchart_id = fields.Many2one( + 'fusion.repair.flowchart', + required=True, + ondelete='cascade', + index=True, + ) + source_node_id = fields.Many2one( + 'fusion.repair.flowchart.node', + required=True, + ondelete='cascade', + index=True, + ) + target_node_id = fields.Many2one( + 'fusion.repair.flowchart.node', + required=True, + ondelete='cascade', + index=True, + ) + label = fields.Char( + translate=True, + help='Shown on the button in the runner ("Yes", "No", ' + '"Wheel turns freely"...).', + ) + sequence = fields.Integer(default=10) + + def _designer_dict(self): + self.ensure_one() + return { + 'id': self.id, + 'source_node_id': self.source_node_id.id, + 'target_node_id': self.target_node_id.id, + 'label': self.label or '', + 'sequence': self.sequence, + } diff --git a/fusion_repairs/models/repair_flowchart_run.py b/fusion_repairs/models/repair_flowchart_run.py new file mode 100644 index 00000000..5ad27d4b --- /dev/null +++ b/fusion_repairs/models/repair_flowchart_run.py @@ -0,0 +1,362 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +"""Flowchart runtime - one fusion.repair.flowchart.run per CS troubleshooting +session, with one fusion.repair.flowchart.run.step per node the rep visited. + +The transcript is the audit + handoff artefact: when CS resolves on-call we +post it to chatter; when they escalate we copy it to internal_notes so the +dispatched tech can see what was tried BEFORE arriving at the client. +""" + +import logging + +from markupsafe import Markup + +from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +RUN_OUTCOMES = [ + ('in_progress', 'In Progress'), + ('resolved', 'Resolved on Call'), + ('escalated', 'Escalated to Technician'), + ('order_part', 'Captured Part Order'), + ('abandoned', 'Abandoned'), +] + + +class FusionRepairFlowchartRun(models.Model): + _name = 'fusion.repair.flowchart.run' + _inherit = ['mail.thread'] + _description = 'Flowchart Troubleshooting Run' + _order = 'started_at desc, id desc' + + name = fields.Char( + compute='_compute_name', + store=True, + ) + repair_order_id = fields.Many2one( + 'repair.order', + required=True, + index=True, + ondelete='cascade', + ) + flowchart_id = fields.Many2one( + 'fusion.repair.flowchart', + required=True, + index=True, + ondelete='restrict', + ) + flowchart_version = fields.Integer( + string='Chart Version Snapshot', + help='Version of the flowchart at the time the run was started; ' + 'so the transcript stays valid even if the chart is later edited.', + ) + partner_id = fields.Many2one( + related='repair_order_id.partner_id', store=True, readonly=True, + ) + started_at = fields.Datetime(default=fields.Datetime.now, required=True) + started_by_id = fields.Many2one( + 'res.users', default=lambda self: self.env.user, required=True, + ) + completed_at = fields.Datetime() + completed_by_id = fields.Many2one('res.users') + outcome = fields.Selection( + RUN_OUTCOMES, + default='in_progress', + required=True, + tracking=True, + ) + step_ids = fields.One2many( + 'fusion.repair.flowchart.run.step', 'run_id', + string='Steps', + ) + step_count = fields.Integer(compute='_compute_step_count') + current_node_id = fields.Many2one( + 'fusion.repair.flowchart.node', + compute='_compute_current_node', + store=True, + help='Where the runner is right now - last step\'s node if no edge ' + 'was chosen yet, otherwise the edge\'s target node.', + ) + transcript_html = fields.Html( + compute='_compute_transcript', + sanitize=False, # we control the markup + ) + company_id = fields.Many2one( + 'res.company', + default=lambda self: self.env.company, + ) + + @api.depends('repair_order_id.name', 'flowchart_id.name') + def _compute_name(self): + for r in self: + r.name = _('%s on %s') % ( + r.flowchart_id.name or '?', r.repair_order_id.name or '?', + ) + + @api.depends('step_ids') + def _compute_step_count(self): + for r in self: + r.step_count = len(r.step_ids) + + @api.depends('step_ids.chosen_edge_id', 'step_ids.node_id', + 'flowchart_id.start_node_id') + def _compute_current_node(self): + for r in self: + if not r.step_ids: + r.current_node_id = r.flowchart_id.start_node_id + continue + last = r.step_ids[-1] + # If they chose an edge on the last step, we should already be + # showing the target. If not (just landed on a card), it's the + # current node itself. + if last.chosen_edge_id: + r.current_node_id = last.chosen_edge_id.target_node_id + else: + r.current_node_id = last.node_id + + @api.depends('step_ids.node_name_snapshot', 'step_ids.chosen_label_snapshot', + 'step_ids.cs_note', 'outcome') + def _compute_transcript(self): + for r in self: + if not r.step_ids: + r.transcript_html = '

No steps recorded yet.

' + continue + lines = ['
    '] + for s in r.step_ids: + line = f'
  1. {s.node_name_snapshot or "?"}' + if s.chosen_label_snapshot: + line += f' → {s.chosen_label_snapshot}' + if s.cs_note: + line += f' (note: {s.cs_note})' + line += '
  2. ' + lines.append(line) + lines.append('
') + outcome_label = dict(self._fields['outcome'].selection).get(r.outcome, r.outcome) + lines.append(f'

Outcome: {outcome_label}

') + r.transcript_html = ''.join(lines) + + # ------------------------------------------------------------------ + # RUNTIME RPC API (called from the OWL runner component) + # ------------------------------------------------------------------ + @api.model + def runtime_start_or_resume(self, repair_id): + """Open or resume a troubleshooting run for the given repair. + + - If an in-progress run exists, return it. + - Otherwise resolve the flowchart from the repair's (category, symptom) + and create a fresh run starting at the chart's start node. + + Returns the dict shape that the OWL runner consumes. + """ + repair = self.env['repair.order'].browse(repair_id).exists() + if not repair: + raise UserError(_('Repair order not found.')) + existing = self.search([ + ('repair_order_id', '=', repair.id), + ('outcome', '=', 'in_progress'), + ], limit=1) + if existing: + return existing._runtime_dict() + if not repair.x_fc_symptom_class_id: + raise UserError(_('Pick a Symptom on the repair before starting ' + 'troubleshooting.')) + chart = self.env['fusion.repair.flowchart'].sudo().search([ + ('category_id', '=', repair.x_fc_repair_category_id.id), + ('symptom_class_id', '=', repair.x_fc_symptom_class_id.id), + ('published', '=', True), + ('active', '=', True), + ], limit=1) + if not chart: + raise UserError(_( + 'No published troubleshooting flowchart found for ' + '%(cat)s + %(sym)s. Ask a manager to publish one in ' + 'Configuration > Troubleshooting Flowcharts.' + ) % { + 'cat': repair.x_fc_repair_category_id.name or '?', + 'sym': repair.x_fc_symptom_class_id.name or '?', + }) + run = self.sudo().create({ + 'repair_order_id': repair.id, + 'flowchart_id': chart.id, + 'flowchart_version': chart.version, + }) + return run._runtime_dict() + + def runtime_choose(self, edge_id, cs_note=''): + """CS clicked an answer button on the current card. + + Records a step (current_node + chosen_edge + note) and returns the + updated run dict (which the OWL component uses to render the next card). + """ + self.ensure_one() + if self.outcome != 'in_progress': + raise UserError(_('This run has already been completed.')) + edge = self.env['fusion.repair.flowchart.edge'].browse(edge_id).exists() + if not edge: + raise UserError(_('Chosen answer not found.')) + if edge.source_node_id != self.current_node_id: + raise UserError(_( + 'That answer does not belong to the current step. The chart ' + 'may have been edited - please reload the runner.' + )) + self.env['fusion.repair.flowchart.run.step'].sudo().create({ + 'run_id': self.id, + 'sequence': len(self.step_ids) + 1, + 'node_id': self.current_node_id.id, + 'node_name_snapshot': self.current_node_id.name, + 'chosen_edge_id': edge.id, + 'chosen_label_snapshot': edge.label, + 'cs_note': cs_note or False, + }) + # current_node_id auto-recomputes via depends + self.invalidate_recordset(['current_node_id']) + return self._runtime_dict() + + def runtime_complete(self, outcome, cs_note=''): + """CS hit a terminal outcome (resolved / escalate / order_part) OR + manually abandoned. Closes the run and triggers downstream effects.""" + self.ensure_one() + valid = {'resolved', 'escalated', 'order_part', 'abandoned'} + if outcome not in valid: + raise UserError(_('Unknown outcome %s.') % outcome) + if self.outcome != 'in_progress': + return self._runtime_dict() + # Snapshot the final node as a step so the transcript shows it. + node = self.current_node_id + if node and (not self.step_ids or self.step_ids[-1].node_id != node): + self.env['fusion.repair.flowchart.run.step'].sudo().create({ + 'run_id': self.id, + 'sequence': len(self.step_ids) + 1, + 'node_id': node.id, + 'node_name_snapshot': node.name, + 'cs_note': cs_note or False, + }) + self.write({ + 'outcome': outcome, + 'completed_at': fields.Datetime.now(), + 'completed_by_id': self.env.uid, + }) + # Dispatch the side-effects on the repair (see _apply_outcome). + self._apply_outcome() + return self._runtime_dict() + + def _apply_outcome(self): + """Run the side-effects on the parent repair depending on outcome.""" + for r in self: + repair = r.repair_order_id + transcript = Markup(r.transcript_html or '') + if r.outcome == 'resolved': + repair.x_fc_resolved_on_call = True + repair.message_post(body=Markup(_( + '

Resolved on call via troubleshooting flowchart ' + '%(name)s:

%(transcript)s' + )) % {'name': r.flowchart_id.name, 'transcript': transcript}) + # Close the repair through the Odoo state machine. Reuse the + # visit-report wizard helper pattern. + try: + if repair.state == 'draft': + try: + repair.action_validate() + except Exception: + repair._action_repair_confirm() + if repair.state == 'confirmed': + repair.action_repair_start() + if repair.state == 'under_repair': + repair.action_repair_end() + except Exception as e: + _logger.warning( + 'Could not auto-close repair %s after on-call ' + 'resolution: %s', repair.name, e, + ) + elif r.outcome == 'escalated': + # Prepend the transcript to internal_notes so the tech sees + # what was tried at the top of the form. + existing = repair.internal_notes or '' + header = Markup( + '

CS troubleshooting summary (flowchart %s):

' + ) % r.flowchart_id.name + repair.internal_notes = (header + transcript + + Markup('
') + Markup(existing)) + repair.message_post(body=Markup(_( + '

Escalated to technician via troubleshooting ' + 'flowchart %(name)s:

%(transcript)s' + )) % {'name': r.flowchart_id.name, 'transcript': transcript}) + elif r.outcome == 'order_part': + repair.message_post(body=Markup(_( + '

Part order needed per troubleshooting flowchart ' + '%(name)s:

%(transcript)s' + )) % {'name': r.flowchart_id.name, 'transcript': transcript}) + else: # abandoned + repair.message_post(body=Markup(_( + '

Troubleshooting run abandoned by %(user)s.

' + '%(transcript)s' + )) % { + 'user': self.env.user.name, + 'transcript': transcript, + }) + + def _runtime_dict(self): + """Serialise the run + current node + available edges for the OWL UI.""" + self.ensure_one() + node = self.current_node_id + outgoing = self.env['fusion.repair.flowchart.edge'].sudo().search([ + ('source_node_id', '=', node.id), + ], order='sequence, id') if node else self.env['fusion.repair.flowchart.edge'] + return { + 'run_id': self.id, + 'repair_id': self.repair_order_id.id, + 'repair_name': self.repair_order_id.name, + 'partner_name': self.partner_id.name or '', + 'flowchart_id': self.flowchart_id.id, + 'flowchart_name': self.flowchart_id.name, + 'canvas_layout': self.flowchart_id.canvas_layout or '', + 'outcome': self.outcome, + 'current_node': node._designer_dict() if node else None, + 'options': [{ + 'edge_id': e.id, + 'label': e.label or _('Continue'), + 'target_node_id': e.target_node_id.id, + 'target_node_type': e.target_node_id.node_type, + 'target_outcome_kind': e.target_node_id.outcome_kind or '', + } for e in outgoing], + 'transcript_html': self.transcript_html, + 'visited_node_ids': [s.node_id.id for s in self.step_ids if s.node_id], + 'visited_edge_ids': [s.chosen_edge_id.id for s in self.step_ids if s.chosen_edge_id], + 'step_count': self.step_count, + } + + +class FusionRepairFlowchartRunStep(models.Model): + _name = 'fusion.repair.flowchart.run.step' + _description = 'Flowchart Run Step' + _order = 'run_id, sequence, id' + + run_id = fields.Many2one( + 'fusion.repair.flowchart.run', + required=True, + index=True, + ondelete='cascade', + ) + sequence = fields.Integer(default=10) + node_id = fields.Many2one( + 'fusion.repair.flowchart.node', + ondelete='set null', + index=True, + ) + node_name_snapshot = fields.Char( + help='Frozen at the moment the step was recorded so transcript ' + 'survives later chart edits.', + ) + chosen_edge_id = fields.Many2one( + 'fusion.repair.flowchart.edge', + ondelete='set null', + ) + chosen_label_snapshot = fields.Char() + cs_note = fields.Text() + recorded_at = fields.Datetime(default=fields.Datetime.now) diff --git a/fusion_repairs/models/repair_order.py b/fusion_repairs/models/repair_order.py index 80595374..14e490a5 100644 --- a/fusion_repairs/models/repair_order.py +++ b/fusion_repairs/models/repair_order.py @@ -1010,10 +1010,86 @@ class RepairOrder(models.Model): index=True, ) x_fc_issue_category = fields.Char( - string='Issue Category', - help='Symptom classification (e.g. "battery", "motor", "remote"). Used by ' - 'service catalogue matcher and AI prompt context.', + string='Issue Category (legacy)', + help='LEGACY free-text symptom classification. Superseded by ' + 'x_fc_symptom_class_id - kept for backwards compatibility with ' + 'historical records and the AI prompt context. New intakes ' + 'should set the M2O instead.', ) + # Bundle 11: proper structured symptom classification. + x_fc_symptom_class_id = fields.Many2one( + 'fusion.repair.symptom.class', + string='Symptom', + index=True, + tracking=True, + domain="[('category_id', '=', x_fc_repair_category_id)]", + help='Drives flowchart lookup. Pick the (category, symptom) pair and ' + 'CS can start a troubleshooting flowchart if one is published.', + ) + # Bundle 11: troubleshooting runs against this repair. + x_fc_flowchart_run_ids = fields.One2many( + 'fusion.repair.flowchart.run', 'repair_order_id', + string='Troubleshooting Runs', + ) + x_fc_flowchart_run_count = fields.Integer( + compute='_compute_flowchart_run_count', + ) + x_fc_resolved_on_call = fields.Boolean( + string='Resolved on Call', + copy=False, + tracking=True, + help='True when the CS troubleshooting flowchart hit a "resolved" ' + 'outcome - no technician was ever dispatched.', + ) + + @api.depends('x_fc_flowchart_run_ids') + def _compute_flowchart_run_count(self): + for r in self: + r.x_fc_flowchart_run_count = len(r.x_fc_flowchart_run_ids) + + def action_view_flowchart_runs(self): + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'name': _('Troubleshooting Runs'), + 'res_model': 'fusion.repair.flowchart.run', + 'view_mode': 'list,form', + 'domain': [('repair_order_id', '=', self.id)], + 'context': {'default_repair_order_id': self.id}, + } + + def action_start_troubleshoot(self): + """Open the OWL flowchart runner for this repair (creates or resumes + an in-progress run). Raises a helpful UserError if the symptom is + not picked or no published chart exists for the (category, symptom) + pair.""" + self.ensure_one() + if not self.x_fc_symptom_class_id: + raise UserError(_( + 'Pick a Symptom on this repair first (Callout Pricing tab or ' + 'the Fusion Repairs section), then start troubleshooting.' + )) + chart = self.env['fusion.repair.flowchart'].sudo().search([ + ('category_id', '=', self.x_fc_repair_category_id.id), + ('symptom_class_id', '=', self.x_fc_symptom_class_id.id), + ('published', '=', True), + ('active', '=', True), + ], limit=1) + if not chart: + raise UserError(_( + 'No published troubleshooting flowchart for %(cat)s + %(sym)s. ' + 'Ask a manager to publish one in Configuration > ' + 'Troubleshooting Flowcharts.' + ) % { + 'cat': self.x_fc_repair_category_id.name or '?', + 'sym': self.x_fc_symptom_class_id.name or '?', + }) + return { + 'type': 'ir.actions.client', + 'tag': 'fusion_repair_flowchart_runner', + 'name': _('Troubleshooting - %s') % self.name, + 'params': {'repair_id': self.id}, + } # ------------------------------------------------------------------ # PHOTOS diff --git a/fusion_repairs/models/repair_part_order.py b/fusion_repairs/models/repair_part_order.py index 57ff9cb1..15c78193 100644 --- a/fusion_repairs/models/repair_part_order.py +++ b/fusion_repairs/models/repair_part_order.py @@ -16,13 +16,28 @@ visit-report wizard in a structured way so: auto-creates a follow-up dispatch task The grumpy-old-client never has to call us asking for status updates. + +Bundle 11 extension: tech often orders the part directly from the factory +WHILE on the phone. They want to: + - Pick the vendor from our contacts (filtered to vendors) + - Capture the OEM part #, cost, ETA date + - Auto-create a draft purchase.order line (office reviews + sends) + - Capture the factory's ticket # + RA # for tracking and warranty claims + - Tell the system whether the factory said it's under warranty """ +import logging from datetime import timedelta from markupsafe import Markup from odoo import _, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + + +SPARE_PARTS_CATEGORY_NAME = 'Spare Parts' class FusionRepairPartOrder(models.Model): @@ -109,6 +124,64 @@ class FusionRepairPartOrder(models.Model): copy=False, ) + # Bundle 11: vendor + PO + cost + factory tracking refs. + vendor_partner_id = fields.Many2one( + 'res.partner', + string='Vendor', + tracking=True, + domain="[('is_company', '=', True)]", + help='Pick from our existing vendors. Filtered to companies; the tech ' + 'usually knows the factory by name (Handicare, Bruno, Pride...).', + ) + purchase_order_id = fields.Many2one( + 'purchase.order', + string='Draft Purchase Order', + readonly=True, + copy=False, + ondelete='set null', + help='Auto-created in DRAFT state when the tech submits a part order ' + 'with a vendor + cost. Office reviews and sends it to the factory.', + ) + product_id = fields.Many2one( + 'product.product', + string='Product', + readonly=True, + copy=False, + help='Auto-resolved or created based on the OEM part number.', + ) + unit_cost = fields.Monetary( + string='Unit Cost', + currency_field='currency_id', + tracking=True, + help='Per-unit cost from the factory. Used as price_unit on the draft PO.', + ) + currency_id = fields.Many2one( + 'res.currency', + default=lambda self: self.env.company.currency_id, + ) + internal_po_ref = fields.Char( + string='PO Reference (read to factory)', + tracking=True, + help='The Westin PO number the tech read to the factory for tracking. ' + 'Stamped on the draft purchase.order as partner_ref.', + ) + factory_ticket_ref = fields.Char( + string='Factory Ticket #', + tracking=True, + help='Ticket number from the factory call - used for follow-up enquiries.', + ) + factory_ra_number = fields.Char( + string='Factory RA #', + tracking=True, + help='Return Authorization number, when the factory issued a warranty ' + 'replacement that requires the old part returned.', + ) + under_warranty = fields.Boolean( + string='Factory Warranty', + tracking=True, + help='Tick when the factory confirmed warranty coverage on the call.', + ) + company_id = fields.Many2one( 'res.company', default=lambda self: self.env.company, @@ -144,6 +217,103 @@ class FusionRepairPartOrder(models.Model): # ------------------------------------------------------------------ # ACTIONS # ------------------------------------------------------------------ + def action_create_draft_po(self): + """Bundle 11: build a draft purchase.order for this part. + + Idempotent - returns the existing PO if one is already linked. + Resolves / creates the product from the OEM number, sets the line + cost from unit_cost, stamps Westin's internal PO ref on the + purchase.order's partner_ref so the factory can find it. Office + reviews + confirms via the normal Odoo flow. + """ + self.ensure_one() + if self.purchase_order_id: + return self._open_record(self.purchase_order_id) + if not self.vendor_partner_id: + raise UserError(_( + 'Pick a Vendor first - the draft PO needs to know who to send to.' + )) + product = self._resolve_or_create_product() + PO = self.env['purchase.order'].sudo() + # Let Odoo derive product_uom from the product to avoid Odoo 19's + # uom_po_id moving between template/variant: passing product_id is + # enough for the PO line's onchange to populate the UOM correctly. + line_vals = { + 'product_id': product.id, + 'name': self.description or product.display_name, + 'product_qty': self.quantity or 1.0, + 'price_unit': self.unit_cost or 0.0, + 'date_planned': fields.Datetime.now() + timedelta( + days=7 if not self.expected_date + else max((self.expected_date - fields.Date.context_today(self)).days, 0) + ), + } + try: + order = PO.create({ + 'partner_id': self.vendor_partner_id.id, + 'partner_ref': self.internal_po_ref or False, + 'origin': self.repair_order_id.name or self.name, + 'company_id': self.company_id.id, + 'order_line': [(0, 0, line_vals)], + }) + except Exception as e: + _logger.exception('Could not create draft PO for part %s: %s', self.name, e) + raise UserError(_( + 'Could not create the draft purchase order: %s. The part info ' + 'was saved - you can create the PO manually from Purchase > ' + 'Orders > New.' + ) % e) + self.write({ + 'purchase_order_id': order.id, + 'product_id': product.id, + }) + self.message_post(body=Markup(_( + 'Draft purchase order %(po)s created with %(vendor)s. ' + 'Office to review and send.' + )) % {'po': order.name, 'vendor': self.vendor_partner_id.name}) + return self._open_record(order) + + def _resolve_or_create_product(self): + """Find product.product by OEM part number (default_code) or create + a new service-type product in the 'Spare Parts' category. Returns + the product record.""" + self.ensure_one() + Product = self.env['product.product'].sudo() + if self.product_id: + return self.product_id + if self.oem_part_number: + hit = Product.search([ + ('default_code', '=', self.oem_part_number), + ], limit=1) + if hit: + return hit + # Create new. Use "service" type so it doesn't need inventory tracking, + # purchaseable + can be added to PO lines without warehouse setup. + ProductCategory = self.env['product.category'].sudo() + category = ProductCategory.search([ + ('name', '=', SPARE_PARTS_CATEGORY_NAME), + ], limit=1) + if not category: + category = ProductCategory.create({'name': SPARE_PARTS_CATEGORY_NAME}) + return Product.create({ + 'name': self.description or (self.oem_part_number or 'Spare Part'), + 'default_code': self.oem_part_number or False, + 'type': 'consu', + 'purchase_ok': True, + 'sale_ok': False, + 'categ_id': category.id, + 'standard_price': self.unit_cost or 0.0, + }) + + def _open_record(self, record): + return { + 'type': 'ir.actions.act_window', + 'name': record.display_name, + 'res_model': record._name, + 'view_mode': 'form', + 'res_id': record.id, + } + def action_mark_ordered(self): """Office marks this part as ordered with the manufacturer.""" for rec in self: @@ -153,6 +323,7 @@ class FusionRepairPartOrder(models.Model): if not rec.expected_date: rec.expected_date = fields.Date.context_today(rec) + timedelta(days=7) rec._notify_client_parts_ordered() + rec._schedule_eta_activity() def action_mark_received(self): """Office marks this part as received - triggers follow-up dispatch.""" @@ -197,6 +368,39 @@ class FusionRepairPartOrder(models.Model): except Exception: pass + def _schedule_eta_activity(self): + """Bundle 11: schedule an activity on the repair so the office is + reminded on the ETA day to call the client back and book the + return visit.""" + for rec in self: + repair = rec.repair_order_id + if not repair or not rec.expected_date: + continue + try: + act_type = self.env.ref( + 'fusion_repairs.mail_activity_type_assign_technician', + raise_if_not_found=False, + ) + repair.activity_schedule( + activity_type_id=act_type.id if act_type else False, + date_deadline=rec.expected_date, + summary=_('Parts arriving (%s) - schedule re-visit') % rec.name, + note=_( + 'Part %(ref)s (%(desc)s) from %(vendor)s is expected ' + 'today. Call the client to confirm a return visit.' + ) % { + 'ref': rec.name, + 'desc': rec.description or '', + 'vendor': rec.vendor_partner_id.name or rec.manufacturer or '?', + }, + user_id=repair.user_id.id or self.env.uid, + ) + except Exception: + _logger.exception( + 'Could not schedule ETA activity for part %s on repair %s', + rec.name, repair.name, + ) + def _maybe_redispatch(self): """When the LAST outstanding part on a repair arrives, auto-create a follow-up tech task so the office doesn't have to remember. diff --git a/fusion_repairs/models/repair_symptom_class.py b/fusion_repairs/models/repair_symptom_class.py new file mode 100644 index 00000000..d5f23c96 --- /dev/null +++ b/fusion_repairs/models/repair_symptom_class.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +"""Symptom classification. + +Lookup table replacing the flat `x_fc_issue_category` Char on repair.order. +One symptom class belongs to one equipment category (stairlift > "Not Moving", +"Beeps Alarm", "Stops Midway"...). Together with the category, it's the key +that fusion.repair.flowchart uses to look up which troubleshooting flowchart +to run. +""" + +from odoo import api, fields, models + + +class FusionRepairSymptomClass(models.Model): + _name = 'fusion.repair.symptom.class' + _description = 'Repair Symptom Class' + _order = 'category_id, sequence, name' + + name = fields.Char(string='Name', required=True, translate=True) + code = fields.Char( + string='Code', + required=True, + help='Stable code used by data files and automation (e.g. "not_moving").', + ) + category_id = fields.Many2one( + 'fusion.repair.product.category', + string='Equipment Category', + required=True, + index=True, + ondelete='cascade', + ) + sequence = fields.Integer(default=10) + icon = fields.Char( + string='Icon', + default='fa-exclamation-circle', + help='Font Awesome icon class shown next to the symptom in pickers.', + ) + description = fields.Text(translate=True) + active = fields.Boolean(default=True) + company_id = fields.Many2one( + 'res.company', + default=lambda self: self.env.company, + ) + + _code_per_category_unique = models.Constraint( + 'unique(category_id, code, company_id)', + 'Symptom code must be unique within a category.', + ) + + @api.depends('name', 'category_id.name') + def _compute_display_name(self): + for r in self: + r.display_name = ( + f"{r.category_id.name} - {r.name}" if r.category_id else (r.name or '') + ) diff --git a/fusion_repairs/security/ir.model.access.csv b/fusion_repairs/security/ir.model.access.csv index 71d0806d..35306278 100644 --- a/fusion_repairs/security/ir.model.access.csv +++ b/fusion_repairs/security/ir.model.access.csv @@ -47,6 +47,24 @@ access_callout_rate_user,Callout Rate User Read,model_fusion_repair_callout_rate access_callout_rate_manager,Callout Rate Manager Full,model_fusion_repair_callout_rate,group_fusion_repairs_manager,1,1,1,1 access_delivery_charge_user,Delivery Charge User Read,model_fusion_repair_delivery_charge,group_fusion_repairs_user,1,0,0,0 access_delivery_charge_manager,Delivery Charge Manager Full,model_fusion_repair_delivery_charge,group_fusion_repairs_manager,1,1,1,1 +access_symptom_class_user,Symptom Class User Read,model_fusion_repair_symptom_class,group_fusion_repairs_user,1,0,0,0 +access_symptom_class_manager,Symptom Class Manager Full,model_fusion_repair_symptom_class,group_fusion_repairs_manager,1,1,1,1 +access_symptom_class_tech,Symptom Class Field Tech Read,model_fusion_repair_symptom_class,fusion_tasks.group_field_technician,1,0,0,0 +access_flowchart_user,Flowchart User Read,model_fusion_repair_flowchart,group_fusion_repairs_user,1,0,0,0 +access_flowchart_manager,Flowchart Manager Full,model_fusion_repair_flowchart,group_fusion_repairs_manager,1,1,1,1 +access_flowchart_tech,Flowchart Field Tech Read,model_fusion_repair_flowchart,fusion_tasks.group_field_technician,1,0,0,0 +access_flowchart_node_user,Flowchart Node User Read,model_fusion_repair_flowchart_node,group_fusion_repairs_user,1,0,0,0 +access_flowchart_node_manager,Flowchart Node Manager Full,model_fusion_repair_flowchart_node,group_fusion_repairs_manager,1,1,1,1 +access_flowchart_node_tech,Flowchart Node Field Tech Read,model_fusion_repair_flowchart_node,fusion_tasks.group_field_technician,1,0,0,0 +access_flowchart_edge_user,Flowchart Edge User Read,model_fusion_repair_flowchart_edge,group_fusion_repairs_user,1,0,0,0 +access_flowchart_edge_manager,Flowchart Edge Manager Full,model_fusion_repair_flowchart_edge,group_fusion_repairs_manager,1,1,1,1 +access_flowchart_edge_tech,Flowchart Edge Field Tech Read,model_fusion_repair_flowchart_edge,fusion_tasks.group_field_technician,1,0,0,0 +access_flowchart_run_user,Flowchart Run User Full,model_fusion_repair_flowchart_run,group_fusion_repairs_user,1,1,1,0 +access_flowchart_run_manager,Flowchart Run Manager Full,model_fusion_repair_flowchart_run,group_fusion_repairs_manager,1,1,1,1 +access_flowchart_run_tech,Flowchart Run Field Tech Read,model_fusion_repair_flowchart_run,fusion_tasks.group_field_technician,1,0,0,0 +access_flowchart_run_step_user,Flowchart Run Step User Full,model_fusion_repair_flowchart_run_step,group_fusion_repairs_user,1,1,1,0 +access_flowchart_run_step_manager,Flowchart Run Step Manager Full,model_fusion_repair_flowchart_run_step,group_fusion_repairs_manager,1,1,1,1 +access_flowchart_run_step_tech,Flowchart Run Step Field Tech Read,model_fusion_repair_flowchart_run_step,fusion_tasks.group_field_technician,1,0,0,0 access_labor_warranty_user,Labor Warranty User Read,model_fusion_repair_labor_warranty,group_fusion_repairs_user,1,0,0,0 access_labor_warranty_sales_rep,Labor Warranty Sales Rep Write,model_fusion_repair_labor_warranty,group_fusion_repairs_sales_rep,1,1,0,0 access_labor_warranty_manager,Labor Warranty Manager Full,model_fusion_repair_labor_warranty,group_fusion_repairs_manager,1,1,1,1 diff --git a/fusion_repairs/static/src/components/flowchart_designer/flowchart_designer.js b/fusion_repairs/static/src/components/flowchart_designer/flowchart_designer.js new file mode 100644 index 00000000..532de5a6 --- /dev/null +++ b/fusion_repairs/static/src/components/flowchart_designer/flowchart_designer.js @@ -0,0 +1,410 @@ +/** @odoo-module **/ +/* + * Drag-and-drop flowchart designer (Drawflow + OWL). + * + * Opened as a client action `fusion_repair_flowchart_designer` from the + * fusion.repair.flowchart form's 'Open Designer' header button. Loads the + * chart via designer_load RPC, renders Drawflow nodes by node_type, lets + * admin drag/connect/edit, and saves the whole snapshot back via + * designer_save (atomic replace - matches the model's API). + * + * Node types -> CSS color band: + * question - blue (multiple outgoing edges, ask user a question) + * suggestion - green (ask user to try something, then "Worked?" branches) + * info - gray (informational, single continue) + * outcome - red/green/amber depending on outcome_kind + * + * Drawflow JSON serialises positions + connections. Our model also stores + * structured nodes/edges for query + runtime, so the canvas_layout blob is + * the source of truth for the designer view and the node/edge tables are + * the source of truth for the runtime traversal. + */ +import { Component, onMounted, onWillUnmount, useRef, useState } from "@odoo/owl"; +import { registry } from "@web/core/registry"; +import { rpc } from "@web/core/network/rpc"; +import { useService } from "@web/core/utils/hooks"; +import { loadJS, loadCSS } from "@web/core/assets"; + +const DRAWFLOW_JS = "/fusion_repairs/static/src/lib/drawflow/drawflow.min.js"; +const DRAWFLOW_CSS = "/fusion_repairs/static/src/lib/drawflow/drawflow.min.css"; + +// Color tokens reused via SCSS too - keep in sync. +const NODE_HEADERS = { + question: { color: "#2563eb", label: "Question" }, + suggestion: { color: "#16a34a", label: "Suggestion" }, + info: { color: "#6b7280", label: "Info" }, + outcome: { color: "#dc2626", label: "Outcome" }, +}; +const OUTCOME_COLORS = { + resolved: "#16a34a", + escalate: "#dc2626", + order_part: "#d97706", +}; + +export class FlowchartDesigner extends Component { + static template = "fusion_repairs.FlowchartDesigner"; + static props = ["*"]; + + setup() { + this.notification = useService("notification"); + this.canvasRef = useRef("canvas"); + this.editorPanelRef = useRef("editorPanel"); + + // Read flowchart_id from the action params. Cursor + OWL action + // service exposes them under this.props.action.params. + this.flowchartId = this.props.action?.params?.flowchart_id; + if (!this.flowchartId) { + this.notification.add("Missing flowchart_id - open from the form view.", { type: "danger" }); + } + + this.state = useState({ + loading: true, + chart: null, + selectedNodeId: null, + dirty: false, + saving: false, + // client_id -> drawflow numeric id mapping for new nodes (those + // without a DB id yet, created via "Add Node") + clientToDfId: {}, + // dfId -> {client_id, node_type, name, content_html, is_start, + // outcome_kind, media_ids} + nodeMeta: {}, + }); + + onMounted(async () => { + await Promise.all([loadJS(DRAWFLOW_JS), loadCSS(DRAWFLOW_CSS)]); + this._initDrawflow(); + await this._loadChart(); + }); + + onWillUnmount(() => { + try { this.editor?.clear(); } catch {} + }); + } + + _initDrawflow() { + // eslint-disable-next-line no-undef + this.editor = new Drawflow(this.canvasRef.el); + this.editor.reroute = true; + this.editor.curvature = 0.5; + this.editor.editor_mode = "edit"; + this.editor.start(); + this.editor.on("nodeSelected", (id) => { + const meta = this.state.nodeMeta[id]; + this.state.selectedNodeId = id; + this.state.selectedClientId = meta?.client_id || null; + }); + this.editor.on("nodeUnselected", () => { + this.state.selectedNodeId = null; + }); + this.editor.on("nodeCreated", () => { this.state.dirty = true; }); + this.editor.on("nodeRemoved", () => { this.state.dirty = true; }); + this.editor.on("nodeMoved", () => { this.state.dirty = true; }); + this.editor.on("connectionCreated", () => { this.state.dirty = true; }); + this.editor.on("connectionRemoved", () => { this.state.dirty = true; }); + } + + async _loadChart() { + try { + const chart = await rpc("/web/dataset/call_kw/fusion.repair.flowchart/designer_load", { + model: "fusion.repair.flowchart", + method: "designer_load", + args: [this.flowchartId], + kwargs: {}, + }); + this.state.chart = chart; + this.state.loading = false; + // Map DB id -> client_id (we just use the DB id as the client_id + // when loading from server; new nodes get "tmp-N" client ids). + this._renderChart(chart); + } catch (e) { + this.notification.add("Failed to load flowchart: " + (e?.message || e), { type: "danger" }); + this.state.loading = false; + } + } + + _renderChart(chart) { + this.editor.clear(); + this.state.clientToDfId = {}; + this.state.nodeMeta = {}; + + // Add each node. + for (const n of chart.nodes) { + const clientId = String(n.id); + const html = this._renderNodeBody(n); + // Drawflow: addNode(name, inputs, outputs, posx, posy, class, data, html) + const outputs = n.node_type === "outcome" ? 0 : 1; + const inputs = n.is_start ? 0 : 1; + const cssClass = `fr-node fr-node-${n.node_type} ${n.is_start ? "fr-node-start" : ""}`; + const dfId = this.editor.addNode( + clientId, + inputs, outputs, + n.canvas_x || 100, n.canvas_y || 100, + cssClass, + { client_id: clientId }, + html, + ); + this.state.clientToDfId[clientId] = dfId; + this.state.nodeMeta[dfId] = { + client_id: clientId, + db_id: n.id, + name: n.name, + node_type: n.node_type, + content_html: n.content_html || "", + is_start: n.is_start, + outcome_kind: n.outcome_kind || "", + media_ids: n.media_ids || [], + media: n.media || [], + }; + } + + // Connect edges. + for (const e of chart.edges) { + const srcDf = this.state.clientToDfId[String(e.source_node_id)]; + const tgtDf = this.state.clientToDfId[String(e.target_node_id)]; + if (!srcDf || !tgtDf) continue; + this.editor.addConnection(srcDf, tgtDf, "output_1", "input_1"); + // Drawflow doesn't natively label edges - we stash the label in + // the edge's data via a follow-up mutation. + // Save/restore handles it via our state.edgeLabels below. + this.state.edgeLabels = this.state.edgeLabels || {}; + this.state.edgeLabels[`${srcDf}->${tgtDf}`] = e.label || ""; + } + + this.state.dirty = false; + } + + _renderNodeBody(n) { + const header = NODE_HEADERS[n.node_type] || NODE_HEADERS.info; + const color = n.node_type === "outcome" + ? (OUTCOME_COLORS[n.outcome_kind] || header.color) + : header.color; + const safeName = (n.name || "Untitled").replace(/START' : ''; + const outcomeBadge = n.node_type === "outcome" && n.outcome_kind + ? `${n.outcome_kind.toUpperCase()}` : ''; + const mediaCount = (n.media || n.media_ids || []).length; + const mediaBadge = mediaCount + ? ` ${mediaCount}` : ''; + return ` +
+
+ ${header.label} ${startBadge} ${outcomeBadge} +
+
${safeName}
+
${safeContent}
+
${mediaBadge}
+
+ `; + } + + // ------------------------------------------------------------------ + // TOOLBAR ACTIONS + // ------------------------------------------------------------------ + onAddNode(nodeType) { + const clientId = "tmp-" + Date.now(); + const meta = { + client_id: clientId, + db_id: null, + name: `New ${nodeType}`, + node_type: nodeType, + content_html: "", + is_start: false, + outcome_kind: nodeType === "outcome" ? "escalate" : "", + media_ids: [], + media: [], + }; + const outputs = nodeType === "outcome" ? 0 : 1; + const html = this._renderNodeBody(meta); + const dfId = this.editor.addNode( + clientId, + 1, outputs, + 120, 120, + `fr-node fr-node-${nodeType}`, + { client_id: clientId }, + html, + ); + this.state.clientToDfId[clientId] = dfId; + this.state.nodeMeta[dfId] = meta; + this.state.selectedNodeId = dfId; + this.state.selectedClientId = clientId; + this.state.dirty = true; + } + + onZoomIn() { this.editor.zoom_in(); } + onZoomOut() { this.editor.zoom_out(); } + onZoomReset() { this.editor.zoom_reset(); } + + // ------------------------------------------------------------------ + // EDITOR PANEL (right side - edit selected node's metadata) + // ------------------------------------------------------------------ + onEditorFieldChange(field, value) { + if (!this.state.selectedNodeId) return; + const meta = this.state.nodeMeta[this.state.selectedNodeId]; + if (!meta) return; + meta[field] = value; + // Re-render the node body to reflect the change visually. + const dfNode = this.editor.getNodeFromId(this.state.selectedNodeId); + if (dfNode) { + const html = this._renderNodeBody(meta); + const el = document.getElementById("node-" + this.state.selectedNodeId); + if (el) { + const content = el.querySelector(".drawflow_content_node"); + if (content) content.innerHTML = html; + } + } + this.state.dirty = true; + } + + onSetStart() { + if (!this.state.selectedNodeId) return; + // Clear is_start on every other node, set on the selected. + for (const [dfId, meta] of Object.entries(this.state.nodeMeta)) { + meta.is_start = (parseInt(dfId, 10) === this.state.selectedNodeId); + } + this.state.dirty = true; + // Re-render all nodes' bodies so the START badge updates. + for (const dfId of Object.keys(this.state.nodeMeta)) { + const meta = this.state.nodeMeta[dfId]; + const html = this._renderNodeBody(meta); + const el = document.getElementById("node-" + dfId); + if (el) { + const content = el.querySelector(".drawflow_content_node"); + if (content) content.innerHTML = html; + } + } + } + + async onUploadMedia(ev) { + if (!this.state.selectedNodeId) return; + const files = ev.target.files; + if (!files || !files.length) return; + const meta = this.state.nodeMeta[this.state.selectedNodeId]; + for (const file of files) { + const buf = await file.arrayBuffer(); + const b64 = btoa( + new Uint8Array(buf).reduce((s, b) => s + String.fromCharCode(b), "") + ); + const att = await rpc("/web/dataset/call_kw/ir.attachment/create", { + model: "ir.attachment", + method: "create", + args: [{ + name: file.name, + datas: b64, + mimetype: file.type || "application/octet-stream", + res_model: "fusion.repair.flowchart.node", + }], + kwargs: {}, + }); + meta.media_ids.push(att); + meta.media.push({ id: att, name: file.name, mimetype: file.type, url: `/web/image/${att}` }); + } + this.state.dirty = true; + // Re-render body to bump the badge count. + this.onEditorFieldChange("media_ids", meta.media_ids); + ev.target.value = ""; + } + + // ------------------------------------------------------------------ + // SAVE + // ------------------------------------------------------------------ + async onSave() { + if (this.state.saving) return; + this.state.saving = true; + try { + // Pull the current Drawflow state. + const dfData = this.editor.export(); + const nodesPayload = []; + const edgesPayload = []; + // dfData.drawflow.Home.data is { [dfId]: { class, data, html, pos_x, pos_y, inputs, outputs } } + const home = dfData.drawflow?.Home?.data || {}; + for (const [dfId, node] of Object.entries(home)) { + const meta = this.state.nodeMeta[parseInt(dfId, 10)]; + if (!meta) continue; + nodesPayload.push({ + client_id: meta.client_id, + name: meta.name, + node_type: meta.node_type, + content_html: meta.content_html, + is_start: !!meta.is_start, + outcome_kind: meta.outcome_kind, + canvas_x: Math.round(node.pos_x || 0), + canvas_y: Math.round(node.pos_y || 0), + media_ids: meta.media_ids || [], + }); + // Outgoing connections - drawflow stores them on the source node's outputs. + const outs = node.outputs || {}; + let seq = 10; + for (const out of Object.values(outs)) { + for (const conn of out.connections || []) { + const tgtMeta = this.state.nodeMeta[parseInt(conn.node, 10)]; + if (!tgtMeta) continue; + const key = `${dfId}->${conn.node}`; + const label = this.state.edgeLabels?.[key] || ""; + edgesPayload.push({ + source_client_id: meta.client_id, + target_client_id: tgtMeta.client_id, + label: label, + sequence: seq, + }); + seq += 10; + } + } + } + const result = await rpc("/web/dataset/call_kw/fusion.repair.flowchart/designer_save", { + model: "fusion.repair.flowchart", + method: "designer_save", + args: [[this.flowchartId], { + canvas_layout: JSON.stringify(dfData), + nodes: nodesPayload, + edges: edgesPayload, + }], + kwargs: {}, + }); + this.notification.add(`Saved (version ${result.version})`, { type: "success" }); + this.state.chart = result; + this._renderChart(result); + this.state.dirty = false; + } catch (e) { + this.notification.add("Save failed: " + (e?.data?.message || e?.message || e), { type: "danger" }); + } finally { + this.state.saving = false; + } + } + + onEdgeLabelChange(ev, key) { + this.state.edgeLabels = this.state.edgeLabels || {}; + this.state.edgeLabels[key] = ev.target.value; + this.state.dirty = true; + } + + get selectedMeta() { + if (!this.state.selectedNodeId) return null; + return this.state.nodeMeta[this.state.selectedNodeId]; + } + + get outgoingEdgesForSelected() { + if (!this.state.selectedNodeId || !this.editor) return []; + try { + const node = this.editor.getNodeFromId(this.state.selectedNodeId); + const outs = node?.outputs || {}; + const result = []; + for (const out of Object.values(outs)) { + for (const conn of out.connections || []) { + const tgtMeta = this.state.nodeMeta[parseInt(conn.node, 10)]; + const key = `${this.state.selectedNodeId}->${conn.node}`; + result.push({ + target_name: tgtMeta?.name || `Node ${conn.node}`, + key: key, + label: this.state.edgeLabels?.[key] || "", + }); + } + } + return result; + } catch { return []; } + } +} + +registry.category("actions").add("fusion_repair_flowchart_designer", FlowchartDesigner); diff --git a/fusion_repairs/static/src/components/flowchart_designer/flowchart_designer.scss b/fusion_repairs/static/src/components/flowchart_designer/flowchart_designer.scss new file mode 100644 index 00000000..874d026a --- /dev/null +++ b/fusion_repairs/static/src/components/flowchart_designer/flowchart_designer.scss @@ -0,0 +1,250 @@ +// Drawflow designer + runner shared theming. +// Uses the Bundle 1 SCSS token pattern with dark-mode-safe explicit hex. + +$o-webclient-color-scheme: bright !default; + +$_fr_page-hex: #f3f4f6; +$_fr_card-hex: #ffffff; +$_fr_border-hex: #d8dadd; +$_fr_text-muted-hex: #6b7280; +$_fr_panel-hex: #ffffff; + +@if $o-webclient-color-scheme == dark { + $_fr_page-hex: #1a1d21 !global; + $_fr_card-hex: #22262d !global; + $_fr_border-hex: #2d3138 !global; + $_fr_text-muted-hex: #9aa1aa !global; + $_fr_panel-hex: #1f2329 !global; +} + +$fr-page: var(--fr-page-bg, #{$_fr_page-hex}); +$fr-card: var(--fr-card-bg, #{$_fr_card-hex}); +$fr-border: var(--fr-border-color, #{$_fr_border-hex}); +$fr-muted: var(--fr-text-muted, #{$_fr_text-muted-hex}); +$fr-panel: var(--fr-panel-bg, #{$_fr_panel-hex}); + +.fr-designer-wrap { + display: flex; + flex-direction: column; + height: calc(100vh - 46px); + background: $fr-page; +} + +.fr-designer-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 16px; + background: $fr-panel; + border-bottom: 1px solid $fr-border; + .fr-designer-title { font-size: 14px; } + .fr-designer-toolbar-actions { display: flex; gap: 4px; } +} + +.fr-designer-body { + display: flex; + flex: 1; + overflow: hidden; +} + +.fr-designer-canvas-wrap { + flex: 1; + overflow: hidden; + position: relative; + background: $fr-page; + .drawflow { + width: 100%; + height: 100%; + background: $fr-page !important; + } +} + +.fr-designer-editor { + width: 320px; + border-left: 1px solid $fr-border; + background: $fr-panel; + padding: 12px 14px; + overflow-y: auto; + h6 { margin-bottom: 8px; font-weight: 700; } +} + +// ----- Node card styling (inside Drawflow's drawflow_content_node) ----- +.drawflow .drawflow-node { + background: transparent !important; + padding: 0 !important; + border: 0 !important; + box-shadow: none !important; + min-width: 220px; + &.selected .fr-node-card { + outline: 2px solid #2563eb; + outline-offset: 2px; + } +} + +.fr-node-card { + background: $fr-card; + color: inherit; + border: 1px solid $fr-border; + border-top: 4px solid #6b7280; + border-radius: 6px; + width: 220px; + overflow: hidden; + font-size: 12px; + box-shadow: 0 1px 3px rgba(0,0,0,0.08); +} + +.fr-node-head { + padding: 4px 8px; + color: #ffffff; + font-weight: 600; + text-transform: uppercase; + font-size: 10px; + letter-spacing: 0.05em; + display: flex; + justify-content: space-between; + align-items: center; +} + +.fr-start-badge, +.fr-outcome-badge { + background: rgba(0,0,0,0.25); + color: #ffffff; + border-radius: 3px; + font-size: 9px; + padding: 1px 5px; + margin-left: 4px; +} + +.fr-node-title { + padding: 8px 10px 4px; + font-weight: 600; + color: #222; +} + +.fr-node-body { + padding: 0 10px 6px; + color: $fr-muted; + max-height: 60px; + overflow: hidden; + font-size: 11px; + line-height: 1.4; +} + +.fr-node-foot { + padding: 4px 10px 8px; + font-size: 10px; + color: $fr-muted; +} + +.fr-media-badge { + background: $fr-page; + border: 1px solid $fr-border; + padding: 1px 6px; + border-radius: 3px; +} + +.fr-media-thumbs { + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.fr-media-thumb { + width: 48px; + height: 48px; + object-fit: cover; + border-radius: 4px; + border: 1px solid $fr-border; +} + +// ----- Runner (Phase 3) styling ----- +.fr-runner-wrap { + display: flex; + flex-direction: column; + height: calc(100vh - 46px); + background: $fr-page; +} + +.fr-runner-header { + background: $fr-panel; + border-bottom: 1px solid $fr-border; + padding: 10px 18px; + display: flex; + justify-content: space-between; + align-items: center; + .fr-runner-title { font-size: 15px; font-weight: 700; } +} + +.fr-runner-body { + flex: 1; + overflow-y: auto; + padding: 30px 20px; +} + +.fr-runner-card { + max-width: 760px; + margin: 0 auto; + background: $fr-card; + border: 1px solid $fr-border; + border-radius: 10px; + padding: 30px; + box-shadow: 0 2px 12px rgba(0,0,0,0.08); +} + +.fr-runner-card h2 { + font-size: 24px; + font-weight: 700; + margin-bottom: 16px; +} + +.fr-runner-content { + font-size: 16px; + line-height: 1.5; + margin-bottom: 20px; +} + +.fr-runner-media { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; + margin: 16px 0; +} +.fr-runner-media img, +.fr-runner-media video { + width: 100%; + border-radius: 6px; + border: 1px solid $fr-border; +} + +.fr-runner-options { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 18px; +} + +.fr-runner-option-btn { + text-align: left; + padding: 14px 18px; + font-size: 15px; + border-radius: 6px; +} + +.fr-runner-note { + margin-top: 16px; +} + +.fr-runner-transcript { + margin-top: 24px; + padding: 12px 16px; + background: $fr-page; + border-radius: 6px; + font-size: 12px; + color: $fr-muted; + border: 1px solid $fr-border; +} + +// Tree-view (canvas) read-only highlights for the runner toggle. +.fr-runner-tree .fr-node-card { opacity: 0.55; } +.fr-runner-tree .fr-visited .fr-node-card { opacity: 1; outline: 2px solid #16a34a; } +.fr-runner-tree .fr-current .fr-node-card { opacity: 1; outline: 3px solid #facc15; } diff --git a/fusion_repairs/static/src/components/flowchart_designer/flowchart_designer.xml b/fusion_repairs/static/src/components/flowchart_designer/flowchart_designer.xml new file mode 100644 index 00000000..073a64b9 --- /dev/null +++ b/fusion_repairs/static/src/components/flowchart_designer/flowchart_designer.xml @@ -0,0 +1,129 @@ + + + + +
+
+ + + v + +
+
+ + + + +
+
+ + + +
+ +
+
+ +
+
+
+
+ +
+
Node Editor
+ +

Click a node on the canvas to edit it. Drag from a node's right edge to another node to create an edge.

+
+ +
+ + +
+
+ + +
+
+ + +
+
+ +