Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
189 changes: 168 additions & 21 deletions npd_project_module/install/after_install.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,110 @@ def create_item_custom_fields():
print(" ✓ Custom field for Item doctype created")


def hsl_to_rgb(h, s, l):
"""
Convert HSL (Hue, Saturation, Lightness) color space to RGB.

Args:
h (float): Hue in range [0, 1]
s (float): Saturation in range [0, 1]
l (float): Lightness in range [0, 1]

Returns:
tuple: (R, G, B) values in range [0, 1]
"""
if s == 0:
# Achromatic (gray)
return (l, l, l)

def hue_to_rgb(p, q, t):
if t < 0:
t += 1
if t > 1:
t -= 1
if t < 1 / 6:
return p + (q - p) * 6 * t
if t < 1 / 2:
return q
if t < 2 / 3:
return p + (q - p) * (2 / 3 - t) * 6
return p

q = l * (1 + s) if l < 0.5 else l + s - l * s
p = 2 * l - q
r = hue_to_rgb(p, q, h + 1 / 3)
g = hue_to_rgb(p, q, h)
b = hue_to_rgb(p, q, h - 1 / 3)

return (r, g, b)


def generate_unique_colors(count, saturation=0.75, lightness=0.5):
"""
Generate a set of unique, visually distinct colors using HSL color space.

This function implements a color generation algorithm based on graph coloring principles:
1. Distributes colors evenly across the hue spectrum (0-360 degrees)
2. Uses optimal saturation and lightness values for visibility
3. Ensures maximum perceptual distance between adjacent colors
4. For many colors, uses golden ratio spacing for better distribution

Args:
count (int): Number of colors to generate
saturation (float): Color saturation (0.0-1.0), default 0.75 for vibrant colors
lightness (float): Color lightness (0.0-1.0), default 0.5 for balanced contrast

Returns:
list: List of hex color codes (e.g., ["#FF6B6B", "#4ECDC4", ...])
"""
if count <= 0:
return []

colors = []

if count <= 20:
# For up to 20 colors, use evenly distributed hues
# This ensures maximum visual distinction with predictable spacing
# 360 degrees / count gives optimal hue spacing
hue_step = 360.0 / count
for i in range(count):
hue = (i * hue_step) % 360.0
# Convert HSL to RGB, then to hex
rgb = hsl_to_rgb(hue / 360.0, saturation, lightness)
hex_color = "#{:02X}{:02X}{:02X}".format(int(rgb[0] * 255), int(rgb[1] * 255), int(rgb[2] * 255))
colors.append(hex_color)
else:
# For more colors, use golden ratio spacing for optimal distribution
# Golden ratio: φ = (1 + √5) / 2 ≈ 0.618 when used multiplicatively
golden_ratio = 0.618033988749895
for i in range(count):
# Use golden ratio to create evenly distributed, non-repeating hues
hue = (i * golden_ratio * 360.0) % 360.0

# Vary saturation and lightness slightly for better distinction with many colors
# This creates a more diverse and visually appealing palette
variant_saturation = saturation if i % 2 == 0 else saturation * 0.85
# Vary lightness in three levels for maximum distinction
if i % 3 == 0:
variant_lightness = lightness
elif i % 3 == 1:
variant_lightness = max(0.35, lightness * 0.85) # Darker
else:
variant_lightness = min(0.65, lightness * 1.15) # Lighter

# Convert HSL to RGB, then to hex
rgb = hsl_to_rgb(hue / 360.0, variant_saturation, variant_lightness)
hex_color = "#{:02X}{:02X}{:02X}".format(int(rgb[0] * 255), int(rgb[1] * 255), int(rgb[2] * 255))
colors.append(hex_color)

return colors


def create_npd_template():
"""
Create default NPD Project Template with 18 tasks.
This template will be used to fetch the task sequence for new projects.
Each task is assigned a unique color for visual distinction.
"""
template_name = "NPD Template"

Expand All @@ -151,29 +251,52 @@ def create_npd_template():
"Handover to Production",
]

# Check if template already exists and has correct number of tasks
# Generate unique colors using HSL color space algorithm
# This ensures maximum visual distinction between tasks
# Using saturation=0.75 and lightness=0.5 for vibrant, professional colors
task_colors = generate_unique_colors(len(task_names), saturation=0.75, lightness=0.5)

# Check if template already exists and handle accordingly
needs_update = False
if frappe.db.exists("Project Template", template_name):
template_exists = frappe.db.exists("Project Template", template_name)
existing_template_tasks_map = {}

if template_exists:
template = frappe.get_doc("Project Template", template_name)
if template.tasks and len(template.tasks) == len(task_names):
print(f" ✓ NPD Template already exists with {len(task_names)} tasks")
return
# Template exists but needs updating
needs_update = True
# Template exists with correct number of tasks
# Create a map of existing tasks by subject for quick lookup
for template_task_row in template.tasks:
if template_task_row.task:
task_doc = frappe.get_doc("Task", template_task_row.task)
existing_template_tasks_map[task_doc.subject] = template_task_row.task
else:
# Template exists but needs updating (wrong number of tasks)
needs_update = True

# Create template Task documents (these are just templates, not real tasks)
# We need to create them in sequence and add dependencies
template_tasks = []
previous_task_name = None

for task_name in task_names:
# Check if template task already exists by subject (without project)
existing_tasks = frappe.get_all(
"Task", filters={"subject": task_name, "project": ["is", "not set"]}, fields=["name"], limit=1
)
for index, task_name in enumerate(task_names):
# Get unique color for this task
task_color = task_colors[index] if index < len(task_colors) else "#808080" # Default gray if missing

if existing_tasks:
existing_task_name = existing_tasks[0].name
# Check if this task already exists in the template (if template exists)
existing_task_name = None
if task_name in existing_template_tasks_map:
existing_task_name = existing_template_tasks_map[task_name]
else:
# Check if template task already exists by subject (without project)
existing_tasks = frappe.get_all(
"Task", filters={"subject": task_name, "project": ["is", "not set"]}, fields=["name"], limit=1
)
if existing_tasks:
existing_task_name = existing_tasks[0].name

if existing_task_name:
# Task already exists - update it to ensure correct color and dependencies
template_tasks.append(existing_task_name)

# Ensure existing template task has is_template set and reset dependencies to sequential order
Expand All @@ -188,6 +311,10 @@ def create_npd_template():
if existing_task_doc.is_template and existing_task_doc.status != "Template":
existing_task_doc.status = "Template"
needs_save = True
# Assign unique color to this task
if existing_task_doc.color != task_color:
existing_task_doc.color = task_color
needs_save = True

# Reset dependencies to only have the sequential dependency
# This ensures all dependencies are in the template_tasks list
Expand Down Expand Up @@ -217,6 +344,7 @@ def create_npd_template():
"status": "Template",
"is_group": 0,
"is_template": 1,
"color": task_color, # Assign unique color to each task
}
)

Expand All @@ -230,11 +358,27 @@ def create_npd_template():
previous_task_name = template_task.name

# Create or update Project Template
template_needs_save = False
if frappe.db.exists("Project Template", template_name):
template = frappe.get_doc("Project Template", template_name)
# Clear existing tasks if we're updating (to avoid duplication)
if needs_update:
template.tasks = []
template_needs_save = True
# Add tasks to template in sequence (only if we cleared them)
for task_name in template_tasks:
template.append("tasks", {"task": task_name})
else:
# Template exists with correct number of tasks
# We've already updated task colors/dependencies in the loop above
# Verify tasks are in correct order and match expected sequence
current_task_ids = [row.task for row in template.tasks] if template.tasks else []
if current_task_ids != template_tasks:
# Tasks are out of order or don't match expected sequence, need to reorder
template.tasks = []
for task_id in template_tasks:
template.append("tasks", {"task": task_id})
template_needs_save = True
else:
# Create new Project Template with name "NPD Template"
template = frappe.get_doc(
Expand All @@ -248,14 +392,17 @@ def create_npd_template():
# Explicitly set the name for doctypes with autoname "Prompt"
template.name = template_name

# Add tasks to template in sequence
for task_name in template_tasks:
template.append("tasks", {"task": task_name})
# Add tasks to template in sequence
for task_name in template_tasks:
template.append("tasks", {"task": task_name})
template_needs_save = True

if frappe.db.exists("Project Template", template_name):
template.save(ignore_permissions=True)
else:
template.insert(ignore_permissions=True)
frappe.db.commit()
# Save template if needed
if template_needs_save:
if frappe.db.exists("Project Template", template_name):
template.save(ignore_permissions=True)
else:
template.insert(ignore_permissions=True)
frappe.db.commit()

print(f" ✓ Created/Updated NPD Template with {len(task_names)} tasks")
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ frappe.query_reports["Part Stage Matrix"] = {
},
],


formatter: function (value, row, column, data, default_formatter) {
// Apply color coding based on status
if (column.fieldname === "stage") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,8 @@ def get_data(self):
tasks = self.get_tasks(part_codes)

# Build matrix: for each stage, get status for each part
for stage_index, stage_name in enumerate(self.task_sequence):
for stage_index, stage_info in enumerate(self.task_sequence):
stage_name = stage_info["subject"]
row = {"stage": stage_name}

for part in parts:
Expand Down Expand Up @@ -205,7 +206,8 @@ def get_stage_status_for_part(self, tasks, part_code, stage_index, stage_name):
else:
# Check if previous stage exists in any iteration
if stage_index > 0 and self.task_sequence:
prev_stage_name = self.task_sequence[stage_index - 1]
prev_stage_info = self.task_sequence[stage_index - 1]
prev_stage_name = prev_stage_info["subject"]
prev_stage_exists = any(self.is_stage_task(t, prev_stage_name) for t in part_tasks)
if not prev_stage_exists:
return "Not Started"
Expand Down Expand Up @@ -244,7 +246,8 @@ def check_if_blocked(self, part_tasks, stage_index, iteration_number):
# Check if previous stage is completed in this iteration
if not self.task_sequence or stage_index < 1:
return "Not Started"
prev_stage_name = self.task_sequence[stage_index - 1]
prev_stage_info = self.task_sequence[stage_index - 1]
prev_stage_name = prev_stage_info["subject"]
prev_stage_task = None

for t in part_tasks:
Expand Down
62 changes: 26 additions & 36 deletions npd_project_module/public/js/project.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@

frappe.ui.form.on("Project", {
validate: function (frm) {
// Validate that items don't already belong to another project
Comment thread
cursor[bot] marked this conversation as resolved.
if (!frm.doc.part_numbers || frm.doc.part_numbers.length === 0) {
return;
}
Expand Down Expand Up @@ -31,50 +30,41 @@ frappe.ui.form.on("Project", {

after_save: function (frm) {
// Handle task generation and project assignment
if (!frm.doc.part_numbers || frm.doc.part_numbers.length === 0) {
return;
}

// Map part_numbers child table to array of objects
// Handle both object rows and potential edge cases
const part_numbers_data = frm.doc.part_numbers
.map((row) => {
// Ensure row is an object
if (typeof row !== "object" || row === null) {
return null;
}
// Extract part_number - handle both direct property and potential string values
const part_number =
typeof row.part_number === "string"
? row.part_number
: row.part_number || null;
if (!part_number) {
return null;
}
return {
part_number: part_number,
iteration_number:
typeof row.iteration_number === "number"
? row.iteration_number
: parseInt(row.iteration_number) || 0,
};
})
.filter((row) => row !== null && row.part_number);

if (part_numbers_data.length === 0) {
return;
}

// Check if this is a new document
// After save, __islocal should be false, but we can also check if name was just assigned
const is_new = frm.doc.__islocal || !frm.doc.name || frm.is_new();
const part_numbers_data =
frm.doc.part_numbers && frm.doc.part_numbers.length > 0
? frm.doc.part_numbers
.map((row) => {
// Ensure row is an object
if (typeof row !== "object" || row === null) {
return null;
}
// Extract part_number - handle both direct property and potential string values
const part_number =
typeof row.part_number === "string"
? row.part_number
: row.part_number || null;
if (!part_number) {
return null;
}
return {
part_number: part_number,
iteration_number:
typeof row.iteration_number === "number"
? row.iteration_number
: parseInt(row.iteration_number) || 0,
};
})
.filter((row) => row !== null && row.part_number)
: [];

frappe.call({
method: "npd_project_module.utils.project_utils.handle_project_save",
args: {
project_name: frm.doc.name,
part_numbers_data: part_numbers_data,
is_new: is_new,
},
callback: function (r) {
if (r.message && r.message.success) {
Expand Down
Loading