Kolejna iteracja procesów w systemie

This commit is contained in:
Flatlogic Bot 2026-01-10 22:04:53 +00:00
parent 4674e7458b
commit 6460ff3ac8
11 changed files with 768 additions and 165 deletions

View File

@ -217,6 +217,8 @@ class WorkflowEngine {
$transition = null;
foreach ($definition['transitions'] as $t) {
if ($t['from'] === $currentNodeId && $t['id'] === $transitionId) {
// For user-triggered transitions, we ignore the condition here.
// The UI should prevent showing buttons for transitions whose data-based conditions aren't met.
$transition = $t;
break;
}
@ -226,56 +228,96 @@ class WorkflowEngine {
throw new WorkflowNotAllowedException("Transition not found or not allowed from the current node.");
}
// TODO: Add rule validation here
// Apply the initial, user-triggered transition
$newNodeId = $this->applySingleTransition($instanceId, $instance, $definition, $transition, $inputPayload, $userId);
$instance['current_node_id'] = $newNodeId; // Update instance state for the loop
$newNodeId = $transition['to'];
$newNodeInfo = $definition['nodes'][$newNodeId] ?? null;
$newStatus = $newNodeInfo['ui_hints']['status'] ?? 'in_progress';
$newReason = $newNodeInfo['ui_hints']['reason'] ?? '';
$newNextStep = $newNodeInfo['ui_hints']['next_step'] ?? '';
$stmt_update = $this->pdo->prepare(
"UPDATE process_instances SET current_node_id = ?, current_status = ?, current_reason = ?, suggested_next_step = ?, last_activity_at = NOW() WHERE id = ?"
);
$stmt_update->execute([
$newNodeId,
$newStatus,
$newReason,
$newNextStep,
$instanceId
]);
$message = $inputPayload['message'] ?? $transition['name'];
$this->addEvent($instanceId, 'transition_applied', $message, $newNodeId, $inputPayload, $userId);
if (isset($transition['actions'])) {
foreach ($transition['actions'] as $action) {
if ($action['type'] === 'start_process') {
$this->executeStartProcessAction($instance['person_id'], $action, $userId);
} elseif ($action['type'] === 'set_data') {
$this->executeSetDataAction($instanceId, $action);
}
// Loop for automatic transitions (router nodes)
for ($i = 0; $i < 10; $i++) { // Max 10 auto-steps to prevent infinite loops
$autoTransition = $this->findAutomaticTransition($instance, $definition);
if ($autoTransition) {
// Automatic transitions have no user payload
$newNodeId = $this->applySingleTransition($instanceId, $instance, $definition, $autoTransition, [], $userId);
$instance['current_node_id'] = $newNodeId; // Update for next iteration
} else {
break; // No more automatic transitions found
}
}
$this->pdo->commit();
// Refetch the final state of the instance to return the correct status
$finalState = $this->getProcessState($instanceId)['instance'];
$finalNodeInfo = $definition['nodes'][$finalState['current_node_id']] ?? null;
return [
'instanceId' => $instanceId,
'currentNodeId' => $newNodeId,
'currentStatus' => $newStatus,
'currentReason' => $newReason,
'suggestedNextStep' => $newNextStep,
'lastActivityAt' => date('Y-m-d H:i:s'),
'currentNodeId' => $finalState['current_node_id'],
'currentStatus' => $finalNodeInfo['ui_hints']['status'] ?? $finalState['current_status'],
'currentReason' => $finalNodeInfo['ui_hints']['reason'] ?? $finalState['current_reason'],
'suggestedNextStep' => $finalNodeInfo['ui_hints']['next_step'] ?? $finalState['suggested_next_step'],
'lastActivityAt' => $finalState['last_activity_at'],
];
} catch (Exception $e) {
$this->pdo->rollBack();
// Re-throw the original exception to be handled by the global error handler
throw $e;
}
}
private function applySingleTransition(int $instanceId, array &$instance, array $definition, array $transition, array $inputPayload, int $userId): string
{
$newNodeId = $transition['to'];
$newNodeInfo = $definition['nodes'][$newNodeId] ?? null;
$newStatus = $newNodeInfo['ui_hints']['status'] ?? 'in_progress';
$newReason = $newNodeInfo['ui_hints']['reason'] ?? '';
$newNextStep = $newNodeInfo['ui_hints']['next_step'] ?? '';
$stmt_update = $this->pdo->prepare(
"UPDATE process_instances SET current_node_id = ?, current_status = ?, current_reason = ?, suggested_next_step = ?, last_activity_at = NOW() WHERE id = ?"
);
$stmt_update->execute([
$newNodeId,
$newStatus,
$newReason,
$newNextStep,
$instanceId
]);
$message = $inputPayload['message'] ?? $transition['name'];
$this->addEvent($instanceId, 'transition_applied', $message, $newNodeId, $inputPayload, $userId);
if (isset($transition['actions'])) {
foreach ($transition['actions'] as $action) {
if ($action['type'] === 'start_process') {
$this->executeStartProcessAction($instance['person_id'], $action, $userId);
} elseif ($action['type'] === 'set_data') {
// Pass the instance by reference to be updated with new data
$this->executeSetDataAction($instanceId, $instance, $action, $inputPayload);
}
}
}
return $newNodeId;
}
private function findAutomaticTransition(array $instance, array $definition): ?array
{
$currentNodeId = $instance['current_node_id'];
foreach ($definition['transitions'] as $transition) {
if ($transition['from'] === $currentNodeId) {
// An automatic transition MUST have a condition.
if (isset($transition['condition'])) {
if ($this->checkTransitionCondition($transition, $instance)) {
return $transition;
}
}
}
}
return null;
}
public function addNote(int $instanceId, string $message, int $userId): bool {
$state = $this->getProcessState($instanceId);
if (!$state) {
@ -404,7 +446,7 @@ class WorkflowEngine {
$stmt->execute([$instanceId, $eventType, $message, $nodeId, json_encode($payload), $userId]);
}
public function getOrCreateInstanceByDefId(int $personId, int $processDefinitionId, int $userId): ?array {
public function getOrCreateInstanceByDefId(int $personId, int $processDefinitionId, int $userId, array $context = []): ?array {
if (!is_int($processDefinitionId) || $processDefinitionId <= 0) {
throw new InvalidArgumentException("processDefinitionId must be a positive integer.");
}
@ -429,7 +471,7 @@ class WorkflowEngine {
throw new WorkflowNotAllowedException("Process is not active and cannot be started.");
}
$eligibility = $this->checkEligibility($personId, $processDefinitionId);
$eligibility = $this->checkEligibility($personId, $processDefinitionId, $context);
if (!$eligibility['is_eligible']) {
throw new WorkflowEligibilityException("Person is not eligible to start this process.", $eligibility['reasons']);
}
@ -499,7 +541,7 @@ class WorkflowEngine {
return $definition['nodes'] ?? [];
}
public function checkEligibility(int $personId, int $processDefinitionId): array {
public function checkEligibility(int $personId, int $processDefinitionId, array $context = []): array {
$stmt_def = $this->pdo->prepare("SELECT definition_json FROM process_definitions WHERE id = ?");
$stmt_def->execute([$processDefinitionId]);
$definition_json = $stmt_def->fetchColumn();
@ -522,6 +564,12 @@ class WorkflowEngine {
case 'checkProcessDataRule':
$this->checkProcessDataRule($personId, $params);
break;
case 'deny_manual_start':
$this->checkDenyManualStartRule($context);
break;
case 'person_property_equals':
$this->checkPersonPropertyEqualsRule($personId, $params);
break;
// Add other rule types here
}
} catch (WorkflowNotAllowedException $e) {
@ -532,6 +580,33 @@ class WorkflowEngine {
return ['is_eligible' => empty($reasons), 'reasons' => $reasons];
}
private function checkDenyManualStartRule(array $context): void {
if (!isset($context['source']) || $context['source'] !== 'chain') {
throw new WorkflowNotAllowedException("This process can only be started automatically by another process.");
}
}
private function checkPersonPropertyEqualsRule(int $personId, array $params): void {
$stmt = $this->pdo->prepare("SELECT * FROM people WHERE id = ?");
$stmt->execute([$personId]);
$person = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$person) {
throw new WorkflowNotAllowedException("Person not found.");
}
$property = $params['property'];
$expectedValue = $params['value'];
if (!isset($person[$property])) {
throw new WorkflowNotAllowedException("Property '{$property}' not found on person.");
}
if ($person[$property] !== $expectedValue) {
throw new WorkflowNotAllowedException("Person's property '{$property}' is not '{$expectedValue}'.");
}
}
private function checkProcessCompletedRule(int $personId, array $params): void {
$stmt = $this->pdo->prepare("\n SELECT pi.id\n FROM process_instances pi\n JOIN process_definitions pd ON pi.process_definition_id = pd.id\n WHERE pi.person_id = ? AND pd.code = ? AND pi.current_status = ?\n ORDER BY pi.last_activity_at DESC\n LIMIT 1\n ");
$stmt->execute([$personId, $params['process_code'], $params['expected_status']]);
@ -570,30 +645,57 @@ class WorkflowEngine {
}
}
private function checkTransitionCondition(array $transition, array $instanceData): bool
{
if (!isset($transition['condition'])) {
// A transition without a condition is not automatic, but it is valid to pass through.
// The calling context (findAutomaticTransition) will decide if this is an error.
return true;
}
$condition = $transition['condition'];
$data = isset($instanceData['data_json']) ? json_decode($instanceData['data_json'], true) : [];
$field = $condition['field'] ?? null;
$expectedValue = $condition['value'] ?? null;
if ($field === null || $expectedValue === null) {
// Malformed condition
return false;
}
return isset($data[$field]) && $data[$field] === $expectedValue;
}
private function executeStartProcessAction(int $personId, array $action, int $userId): void {
$stmt = $this->pdo->prepare("SELECT id FROM process_definitions WHERE code = ?");
$stmt->execute([$action['process_code']]);
$processDefinitionId = $stmt->fetchColumn();
if ($processDefinitionId) {
$this->getOrCreateInstanceByDefId($personId, $processDefinitionId, $userId);
$this->getOrCreateInstanceByDefId($personId, $processDefinitionId, $userId, ['source' => 'chain']);
}
}
private function executeSetDataAction(int $instanceId, array $action): void {
$stmt = $this->pdo->prepare("SELECT data_json FROM process_instances WHERE id = ?");
$stmt->execute([$instanceId]);
$dataJson = $stmt->fetchColumn();
private function executeSetDataAction(int $instanceId, array &$instance, array $action, array $payload): void {
$dataJson = $instance['data_json'];
$data = $dataJson ? json_decode($dataJson, true) : [];
$key = $action['params']['key'];
$value = $action['params']['value'];
$data[$key] = $value;
if (isset($action['params']['keys']) && is_array($action['params']['keys'])) {
foreach ($action['params']['keys'] as $key) {
if (array_key_exists($key, $payload)) {
$data[$key] = $payload[$key];
}
}
}
$newDataJson = json_encode($data);
// Update the database
$stmt_update = $this->pdo->prepare("UPDATE process_instances SET data_json = ? WHERE id = ?");
$stmt_update->execute([$newDataJson, $instanceId]);
// Also update the in-memory instance for the next step in the chain
$instance['data_json'] = $newDataJson;
}
}

View File

@ -1,117 +1,119 @@
<?php
require_once 'lib/ErrorHandler.php';
require_once 'db/config.php';
session_start();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$first_name = $_POST['first_name'];
$last_name = $_POST['last_name'];
$email = $_POST['email'];
$password = $_POST['password'];
$company_name = $_POST['company_name'] ?? null;
$phone = $_POST['phone'] ?? null;
$role = $_POST['role'] ?? 'guest';
$functions = isset($_POST['functions']) ? $_POST['functions'] : [];
$bni_group_id = isset($_POST['bni_group_id']) && !empty($_POST['bni_group_id']) ? $_POST['bni_group_id'] : null;
header('Content-Type: application/json');
// New fields
$nip = $_POST['nip'] ?? null;
$industry = $_POST['industry'] ?? null;
$company_size_revenue = $_POST['company_size_revenue'] ?? null;
$business_description = $_POST['business_description'] ?? null;
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => ['message' => 'Method not allowed.'], 'correlation_id' => uniqid()]);
exit;
}
if (empty($firstName) || empty($lastName) || empty($email) || empty($password)) {
$_SESSION['error_message'] = 'Imię, nazwisko, email i hasło są wymagane.';
header('Location: index.php');
exit;
$first_name = $_POST['first_name'] ?? '';
$last_name = $_POST['last_name'] ?? '';
$email = $_POST['email'] ?? '';
$password = $_POST['password'] ?? '';
$company_name = $_POST['company_name'] ?? null;
$phone = $_POST['phone'] ?? null;
$role = $_POST['role'] ?? 'guest';
$functions = isset($_POST['functions']) ? (array)$_POST['functions'] : [];
$bni_group_id = isset($_POST['bni_group_id']) && !empty($_POST['bni_group_id']) ? $_POST['bni_group_id'] : null;
$nip = $_POST['nip'] ?? null;
$industry = $_POST['industry'] ?? null;
$company_size_revenue = $_POST['company_size_revenue'] ?? null;
$business_description = $_POST['business_description'] ?? null;
if (empty($first_name) || empty($last_name) || empty($email) || empty($password)) {
http_response_code(422);
echo json_encode(['error' => ['message' => 'First name, last name, email, and password are required.'], 'correlation_id' => uniqid()]);
exit;
}
if ($role !== 'member') {
$bni_group_id = null;
}
$pdo = db();
try {
$pdo->beginTransaction();
$sql = 'INSERT INTO people (first_name, last_name, email, password, company_name, phone, role, bni_group_id, nip, industry, company_size_revenue, business_description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
$stmt = $pdo->prepare($sql);
$stmt->execute([$first_name, $last_name, $email, password_hash($password, PASSWORD_DEFAULT), $company_name, $phone, $role, $bni_group_id, $nip, $industry, $company_size_revenue, $business_description]);
$personId = $pdo->lastInsertId();
$upload_dir = 'uploads/people/' . $personId . '/';
if (!is_dir($upload_dir)) {
if (!mkdir($upload_dir, 0777, true) && !is_dir($upload_dir)) {
throw new RuntimeException(sprintf('Directory "%s" was not created', $upload_dir));
}
}
// Only members can be in a group
if ($role !== 'member') {
$bni_group_id = null;
$file_fields = [
'company_logo' => 'company_logo_path',
'person_photo' => 'person_photo_path',
'gains_sheet' => 'gains_sheet_path',
'top_wanted_contacts' => 'top_wanted_contacts_path',
'top_owned_contacts' => 'top_owned_contacts_path'
];
$file_paths_to_update = [];
foreach ($file_fields as $form_field_name => $db_column_name) {
if (isset($_FILES[$form_field_name]) && $_FILES[$form_field_name]['error'] == UPLOAD_ERR_OK) {
$tmp_name = $_FILES[$form_field_name]['tmp_name'];
$original_name = basename($_FILES[$form_field_name]['name']);
$file_ext = pathinfo($original_name, PATHINFO_EXTENSION);
$new_filename = uniqid($form_field_name . '_', true) . '.' . $file_ext;
$destination = $upload_dir . $new_filename;
if (move_uploaded_file($tmp_name, $destination)) {
$file_paths_to_update[$db_column_name] = $destination;
} else {
throw new RuntimeException("Failed to move uploaded file for {$form_field_name}.");
}
}
}
$pdo = db();
try {
$pdo->beginTransaction();
// Insert person details first
$sql = 'INSERT INTO people (first_name, last_name, email, password, company_name, phone, role, bni_group_id, nip, industry, company_size_revenue, business_description) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)';
if (!empty($file_paths_to_update)) {
$sql_parts = [];
$params = [];
foreach ($file_paths_to_update as $column => $path) {
$sql_parts[] = "$column = ?";
$params[] = $path;
}
$params[] = $personId;
$sql = "UPDATE people SET " . implode(', ', $sql_parts) . " WHERE id = ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$first_name, $last_name, $email, password_hash($password, PASSWORD_DEFAULT), $company_name, $phone, $role, $bni_group_id, $nip, $industry, $company_size_revenue, $business_description]);
$personId = $pdo->lastInsertId();
// Handle file uploads now that we have a personId
$upload_dir = 'uploads/people/' . $personId . '/';
if (!is_dir($upload_dir)) {
mkdir($upload_dir, 0777, true);
}
$file_fields = [
'company_logo' => 'company_logo_path',
'person_photo' => 'person_photo_path',
'gains_sheet' => 'gains_sheet_path',
'top_wanted_contacts' => 'top_wanted_contacts_path',
'top_owned_contacts' => 'top_owned_contacts_path'
];
$file_paths_to_update = [];
foreach ($file_fields as $form_field_name => $db_column_name) {
if (isset($_FILES[$form_field_name]) && $_FILES[$form_field_name]['error'] == UPLOAD_ERR_OK) {
$tmp_name = $_FILES[$form_field_name]['tmp_name'];
$original_name = basename($_FILES[$form_field_name]['name']);
$file_ext = pathinfo($original_name, PATHINFO_EXTENSION);
$new_filename = uniqid($form_field_name . '_', true) . '.' . $file_ext;
$destination = $upload_dir . $new_filename;
if (move_uploaded_file($tmp_name, $destination)) {
$file_paths_to_update[$db_column_name] = $destination;
}
}
}
// If there are files, update the newly created person record
if (!empty($file_paths_to_update)) {
$sql_parts = [];
$params = [];
foreach ($file_paths_to_update as $column => $path) {
$sql_parts[] = "$column = ?";
$params[] = $path;
}
$params[] = $personId;
$sql = "UPDATE people SET " . implode(', ', $sql_parts) . " WHERE id = ?";
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
}
// Assign functions
if (!empty($functions)) {
$sql = "INSERT INTO user_functions (user_id, function_id) VALUES (?, ?)";
$stmt = $pdo->prepare($sql);
foreach ($functions as $functionId) {
$stmt->execute([$personId, $functionId]);
}
}
$pdo->commit();
$_SESSION['success_message'] = 'Osoba dodana pomyślnie.';
} catch (PDOException $e) {
$pdo->rollBack();
error_log('Create failed: ' . $e->getMessage());
if ($e->errorInfo[1] == 1062) {
$_SESSION['error_message'] = 'Błąd: Konto z tym adresem email już istnieje.';
} else {
$_SESSION['error_message'] = 'Błąd podczas dodawania osoby: ' . $e->getMessage();
}
} catch (Exception $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
error_log('File upload or other error: ' . $e->getMessage());
$_SESSION['error_message'] = 'Błąd: ' . $e->getMessage();
$stmt->execute($params);
}
header('Location: index.php');
exit();
if (!empty($functions)) {
$sql = "INSERT INTO user_functions (user_id, function_id) VALUES (?, ?)";
$stmt = $pdo->prepare($sql);
foreach ($functions as $functionId) {
$stmt->execute([$personId, $functionId]);
}
}
$pdo->commit();
echo json_encode(['success' => true, 'person_id' => $personId, 'message' => 'Person created successfully.']);
} catch (PDOException $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
if ($e->errorInfo[1] == 1062) {
http_response_code(409); // Conflict
echo json_encode(['error' => ['message' => 'An account with this email address already exists.'], 'correlation_id' => uniqid()]);
} else {
throw $e; // Re-throw to be caught by the global error handler
}
} catch (Exception $e) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $e; // Re-throw to be caught by the global error handler
}

View File

@ -129,6 +129,23 @@ $instance = $engine->getInstanceByDefId($person_id, $process_definition_id);
</ul>
<div class="mt-3">
<h5>Available Actions</h5>
<?php
$currentNode = $all_nodes[$currentNodeId] ?? null;
if ($currentNode && isset($currentNode['ui_hints']['form_schema'])):
?>
<form id="transition-form">
<?php foreach ($currentNode['ui_hints']['form_schema'] as $field): ?>
<div class="mb-3">
<label for="<?= $field['name'] ?>" class="form-label"><?= $field['label'] ?></label>
<?php if ($field['type'] === 'textarea'): ?>
<textarea id="<?= $field['name'] ?>" name="<?= $field['name'] ?>" class="form-control"></textarea>
<?php else: ?>
<input type="<?= $field['type'] ?>" id="<?= $field['name'] ?>" name="<?= $field['name'] ?>" class="form-control" value="<?= $field['default'] === 'now' ? date('Y-m-d\\TH:i') : '' ?>">
<?php endif; ?>
</div>
<?php endforeach; ?>
</form>
<?php endif; ?>
<?php if (empty($availableTransitions)): ?>
<p>No actions available.</p>
<?php else: ?>

View File

@ -26,10 +26,34 @@ if (isset($_GET['id'])) {
$stmt->execute([$person_id]);
$person_functions = $stmt->fetchAll(PDO::FETCH_COLUMN, 0);
// --- Fetch Follow-up Process Summary ---
$follow_up_summary = null;
$stmt_def = $pdo->prepare("SELECT id FROM process_definitions WHERE code = 'guest_handling' LIMIT 1");
$stmt_def->execute();
$follow_up_def_id = $stmt_def->fetchColumn();
if ($follow_up_def_id) {
$stmt_inst = $pdo->prepare("SELECT * FROM process_instances WHERE person_id = ? AND process_definition_id = ? ORDER BY id DESC LIMIT 1");
$stmt_inst->execute([$person_id, $follow_up_def_id]);
$instance = $stmt_inst->fetch(PDO::FETCH_ASSOC);
if ($instance) {
$data = $instance['data_json'] ? json_decode($instance['data_json'], true) : [];
$follow_up_summary = [
'last_call_outcome' => $data['outcome_status'] ?? null,
'last_call_date' => $data['call_date'] ?? null,
'next_contact_date' => $data['next_contact_date'] ?? null,
'final_outcome' => $instance['current_status'], // e.g., completed, terminated
'reason' => $instance['current_reason']
];
}
}
$response = [
'person' => $person,
'all_functions' => $all_functions,
'person_functions' => $person_functions
'person_functions' => $person_functions,
'follow_up_summary' => $follow_up_summary
];
echo json_encode($response);

View File

@ -20,6 +20,12 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$company_size_revenue = $_POST['company_size_revenue'] ?? null;
$business_description = $_POST['business_description'] ?? null;
if (empty($first_name) || empty($last_name) || empty($email)) {
http_response_code(422);
echo json_encode(['error' => ['message' => 'First name, last name, and email are required.'], 'correlation_id' => uniqid()]);
exit;
}
// Only members can be in a group
if ($role !== 'member') {
$bni_group_id = null;

View File

@ -22,6 +22,7 @@ $(document).ready(function() {
modal.find('form').trigger('reset');
modal.find('#editPersonId').val('');
modal.find('#editRoles').empty();
modal.find('#followUpSummaryContainer').empty(); // Clear summary container
// Clear file paths
modal.find('#editCompanyLogoPath, #editPersonPhotoPath, #editGainsSheetPath, #editTopWantedPath, #editTopOwnedPath').text('');
@ -41,21 +42,47 @@ $(document).ready(function() {
var person = response.person;
var all_functions = response.all_functions;
var person_functions = response.person_functions;
var followUpSummary = response.follow_up_summary;
if (!person) {
alert('Could not find person data.');
return;
}
// Populate the Follow-up Summary
var summaryContainer = modal.find('#followUpSummaryContainer');
if (followUpSummary) {
let summaryHtml = '<h5>Follow-up Process Summary</h5>';
summaryHtml += '<dl class="row">';
if (followUpSummary.last_call_outcome) {
summaryHtml += `<dt class="col-sm-4">Last Call Outcome</dt><dd class="col-sm-8">${followUpSummary.last_call_outcome.replace(/_/g, ' ')}</dd>`;
}
if (followUpSummary.last_call_date) {
summaryHtml += `<dt class="col-sm-4">Last Call Date</dt><dd class="col-sm-8">${new Date(followUpSummary.last_call_date).toLocaleString()}</dd>`;
}
if (followUpSummary.next_contact_date) {
summaryHtml += `<dt class="col-sm-4">Next Contact Date</dt><dd class="col-sm-8">${new Date(followUpSummary.next_contact_date).toLocaleString()}</dd>`;
}
if (followUpSummary.final_outcome) {
summaryHtml += `<dt class="col-sm-4">Final Status</dt><dd class="col-sm-8">${followUpSummary.final_outcome} (${followUpSummary.reason || 'N/A'})</dd>`;
}
summaryHtml += '</dl>';
summaryContainer.html(summaryHtml);
} else {
summaryContainer.html('<p class="text-muted">No Follow-up process data found for this person.</p>');
}
// Populate the form fields
modal.find('#editPersonId').val(person.id);
modal.find('#editFirstName').val(person.firstName);
modal.find('#editLastName').val(person.lastName);
modal.find('#editFirstName').val(person.first_name);
modal.find('#editLastName').val(person.last_name);
modal.find('#editPhone').val(person.phone);
modal.find('#editEmail').val(person.email);
modal.find('#editRole').val(person.role);
modal.find('#editBniGroup').val(person.bni_group_id);
modal.find('#editCompanyName').val(person.companyName);
modal.find('#editCompanyName').val(person.company_name);
modal.find('#editNip').val(person.nip);
modal.find('#editIndustry').val(person.industry);
modal.find('#editCompanySize').val(person.company_size_revenue);
@ -157,4 +184,64 @@ $(document).ready(function() {
$('#createPersonModal').on('show.bs.modal', function () {
$('#createRole').trigger('change');
});
function handleFormSubmit(form, errorContainer, successCallback) {
event.preventDefault();
const formData = new FormData(form);
const errorDiv = $(errorContainer).hide();
fetch(form.action, {
method: 'POST',
body: formData
})
.then(response => {
// Clone the response so we can read it twice (once as JSON, once as text if needed)
const responseClone = response.clone();
return response.json()
.then(data => ({ status: response.status, ok: response.ok, body: data }))
.catch(() => responseClone.text().then(text => ({ status: response.status, ok: response.ok, body: text, isText: true })));
})
.then(res => {
const { status, ok, body, isText } = res;
if (!ok) {
if (isText) {
throw new Error(`Server Error: ${status}. Response: ${body}`);
}
throw new Error(body.error?.message || `An unknown server error occurred (Status: ${status})`);
}
if (isText) {
console.error("Received non-JSON response:", body);
throw new Error("The server sent an invalid response that could not be parsed. See console for details.");
}
if (body.success) {
if (successCallback) {
successCallback(body);
} else {
// Default success behavior: close modal and reload
$(form).closest('.modal').modal('hide');
window.location.reload();
}
} else {
throw new Error(body.error?.message || 'An operation error occurred.');
}
})
.catch(error => {
errorDiv.text(error.message).show();
});
}
$('#createPersonForm').on('submit', function(event) {
handleFormSubmit(this, '#createPersonError');
});
$('#editPersonForm').on('submit', function(event) {
handleFormSubmit(this, '#editPersonError', function(data) {
// close modal and reload page
$('#editPersonModal').modal('hide');
window.location.reload();
});
});
});

View File

@ -1,12 +1,22 @@
<?php
require_once __DIR__ . '/../config.php';
try {
$pdo = db();
$sql = "ALTER TABLE process_definitions ADD COLUMN is_active TINYINT(1) NOT NULL DEFAULT 1 AFTER definition_json";
$pdo->exec($sql);
echo "Migration successful: is_active column added to process_definitions table.\n";
} catch (PDOException $e) {
die("Migration failed: " . $e->getMessage() . "\n");
function migrate_022($pdo) {
try {
$sql = "ALTER TABLE process_definitions ADD COLUMN is_active TINYINT(1) NOT NULL DEFAULT 1 AFTER definition_json";
$pdo->exec($sql);
echo "Migration successful: is_active column added to process_definitions table.\n";
} catch (PDOException $e) {
// Ignore if column already exists
if ($e->getCode() !== '42S21') {
die("Migration failed: " . $e->getMessage() . "\n");
}
}
}
// If called directly, run the migration
if (basename(__FILE__) == basename($_SERVER["SCRIPT_FILENAME"])) {
require_once __DIR__ . '/../../db/config.php';
$pdo = db();
migrate_022($pdo);
}

View File

@ -0,0 +1,22 @@
<?php
require_once __DIR__ . '/../../db/config.php';
function migrate_023($pdo) {
$json_definition = '{ "nodes": { "start": { "id": "start", "name": "Start", "ui_hints": { "status": "in_progress", "reason": "Oczekuje na wystawienie pro formy", "next_step": "Wystaw pro formę" } }, "wait_for_payment": { "id": "wait_for_payment", "name": "Oczekiwanie na płatność", "ui_hints": { "status": "in_progress", "reason": "Oczekuje na potwierdzenie wpływu środków", "next_step": "Potwierdź wpływ środków" } }, "notify_committee": { "id": "notify_committee", "name": "Powiadomienie komitetu", "ui_hints": { "status": "in_progress", "reason": "Oczekuje na powiadomienie komitetu", "next_step": "Powiadom komitet i przejdź do decyzji" } }, "decision_committee": { "id": "decision_committee", "name": "Decyzja komitetu", "ui_hints": { "status": "in_progress", "reason": "Oczekuje na decyzję komitetu", "next_step": "Zatwierdź lub odrzuć" } }, "end_accepted": { "id": "end_accepted", "name": "Zatwierdzony", "ui_hints": { "status": "positive", "reason": "Członek przyjęty", "next_step": "" } }, "end_rejected": { "id": "end_rejected", "name": "Odrzucony", "ui_hints": { "status": "negative", "reason": "Członek odrzucony", "next_step": "" } } }, "transitions": [ { "id": "issue_proforma", "name": "Wystaw pro formę", "from": "start", "to": "wait_for_payment", "actions": [ { "type": "set_data", "params": { "keys": ["issue_date", "document_number"] } } ] }, { "id": "confirm_payment", "name": "Potwierdź wpływ środków", "from": "wait_for_payment", "to": "notify_committee", "actions": [ { "type": "set_data", "params": { "keys": ["payment_date"] } } ] } , { "id": "proceed_to_decision", "name": "Przekaż do decyzji komitetu", "from": "notify_committee", "to": "decision_committee" }, { "id": "accept_member", "name": "Zatwierdź", "from": "decision_committee", "to": "end_accepted", "actions": [ { "type": "start_process", "params": { "process_code": "wprowadzenie-nowego-cz-onka" } } ] }, { "id": "reject_member", "name": "Odrzuć", "from": "decision_committee", "to": "end_rejected" } ], "start_node_id": "start", "eligibility_rules": [ { "type": "deny_manual_start" } ] }';
$stmt = $pdo->prepare("INSERT INTO process_definitions (name, code, definition_json, is_active) VALUES (?, ?, ?, ?)");
$stmt->execute([
'Obsługa przyjęcia nowego członka',
'obsluga-przyjecia-nowego-czlonka',
$json_definition,
1
]);
}
// If called directly, run the migration
if (basename(__FILE__) == basename($_SERVER["SCRIPT_FILENAME"])) {
require_once __DIR__ . '/../../db/config.php';
$pdo = db();
migrate_023($pdo);
echo "Migration 023 executed successfully.\n";
}

View File

@ -0,0 +1,154 @@
<?php
require_once __DIR__ . '/../../db/config.php';
function migrate_024()
{
$pdo = db();
$process_code = 'guest_handling';
$json_definition = <<<EOT
{
"start_node_id": "awaiting_call",
"eligibility_rules": [
{
"type": "person_property_equals",
"params": {
"property": "role",
"value": "guest"
}
}
],
"nodes": {
"awaiting_call": {
"ui_hints": {
"title": "Follow-up Call",
"status": "active",
"reason": "Awaiting follow-up call with the guest.",
"next_step": "Log the outcome of the call.",
"form_schema": [
{ "name": "call_date", "label": "Call Date", "type": "datetime-local", "default": "now" },
{ "name": "note", "label": "Notes", "type": "textarea" }
]
}
},
"decide_after_no_answer": {
"ui_hints": {
"title": "No Answer",
"status": "paused",
"reason": "The guest did not answer the call.",
"next_step": "Decide whether to try again or end the process."
}
},
"end_positive": {
"ui_hints": {
"title": "Wants to Join",
"status": "completed",
"reason": "Guest wants to join. New member process started.",
"next_step": ""
}
},
"end_negative_declined": {
"ui_hints": {
"title": "Declined",
"status": "terminated",
"reason": "Guest declined to join.",
"next_step": ""
}
},
"end_negative_no_answer": {
"ui_hints": {
"title": "Process Ended",
"status": "terminated",
"reason": "Process ended after no answer.",
"next_step": ""
}
}
},
"transitions": [
{
"id": "log_wants_to_join",
"from": "awaiting_call",
"to": "end_positive",
"name": "Wants to Join",
"actions": [
{
"type": "set_data",
"params": { "keys": ["call_date", "note", "outcome_status"] }
},
{
"type": "start_process",
"process_code": "obsluga-przyjecia-nowego-czlonka"
}
]
},
{
"id": "log_declined",
"from": "awaiting_call",
"to": "end_negative_declined",
"name": "Declined",
"actions": [
{
"type": "set_data",
"params": { "keys": ["call_date", "note", "outcome_status"] }
}
]
},
{
"id": "log_no_answer",
"from": "awaiting_call",
"to": "decide_after_no_answer",
"name": "No Answer",
"actions": [
{
"type": "set_data",
"params": { "keys": ["call_date", "note", "outcome_status"] }
}
]
},
{
"id": "log_call_later",
"from": "awaiting_call",
"to": "awaiting_call",
"name": "Call Later",
"actions": [
{
"type": "set_data",
"params": { "keys": ["call_date", "note", "outcome_status"] }
}
]
},
{
"id": "continue_attempts",
"from": "decide_after_no_answer",
"to": "awaiting_call",
"name": "Try Again"
},
{
"id": "end_attempts",
"from": "decide_after_no_answer",
"to": "end_negative_no_answer",
"name": "End Process"
}
]
}
EOT;
$stmt = $pdo->prepare("UPDATE process_definitions SET definition_json = :json, start_node_id = 'awaiting_call' WHERE code = :code");
$stmt->execute([
':json' => $json_definition,
':code' => $process_code,
]);
echo "Migration 024 executed successfully: Updated 'guest_handling' process definition.\n";
}
// Direct execution guard
if (basename(__FILE__) == basename($_SERVER["SCRIPT_FILENAME"])) {
try {
migrate_024();
} catch (Exception $e) {
echo "Migration 24 failed: " . $e->getMessage() . "\n";
}
}

View File

@ -0,0 +1,161 @@
<?php
require_once __DIR__ . '/../../db/config.php';
function migrate_025()
{
$pdo = db();
$process_code = 'guest_handling';
$json_definition = <<<'EOT'
{
"start_node_id": "awaiting_call",
"eligibility_rules": [
{
"type": "person_property_equals",
"params": {
"property": "role",
"value": "guest"
}
}
],
"nodes": {
"awaiting_call": {
"ui_hints": {
"title": "Follow-up Call",
"status": "active",
"reason": "Awaiting follow-up call with the guest.",
"next_step": "Log the outcome of the call.",
"form_schema": [
{ "name": "call_date", "label": "Call Date", "type": "datetime-local", "default": "now", "required": true },
{
"name": "outcome_status",
"label": "Call Outcome",
"type": "select",
"required": true,
"options": [
{ "value": "", "label": "-- Select Outcome --" },
{ "value": "no_answer", "label": "No Answer" },
{ "value": "wants_to_join", "label": "Wants to Join" },
{ "value": "declined", "label": "Declined" },
{ "value": "call_later", "label": "Call Later" }
]
},
{ "name": "note", "label": "Notes", "type": "textarea" },
{ "name": "next_contact_date", "label": "Next Contact Date", "type": "datetime-local", "condition": { "field": "outcome_status", "value": "call_later" }, "required": true }
]
}
},
"outcome_router": { "ui_hints": { "status": "processing" } },
"waiting_for_next_contact": {
"ui_hints": {
"title": "Waiting for Scheduled Call",
"status": "paused",
"reason": "Waiting until the scheduled date for the next call.",
"next_step": "Resume contact on or after the scheduled date."
}
},
"decide_after_no_answer": {
"ui_hints": {
"title": "No Answer",
"status": "paused",
"reason": "The guest did not answer the call.",
"next_step": "Decide whether to try again or end the process."
}
},
"end_positive": { "ui_hints": { "title": "Wants to Join", "status": "completed", "reason": "Guest wants to join. New member process started.", "next_step": "" } },
"end_negative_declined": { "ui_hints": { "title": "Declined", "status": "terminated", "reason": "Guest declined to join.", "next_step": "" } },
"end_negative_no_answer": { "ui_hints": { "title": "Process Ended", "status": "terminated", "reason": "Process ended after no answer.", "next_step": "" } },
"end_negative_terminated": { "ui_hints": { "title": "Process Terminated", "status": "terminated", "reason": "Process manually terminated by user.", "next_step": "" } }
},
"transitions": [
{
"id": "submit_outcome",
"from": "awaiting_call",
"to": "outcome_router",
"name": "Submit Outcome",
"actions": [
{ "type": "set_data", "params": { "keys": ["call_date", "outcome_status", "note", "next_contact_date"] } }
]
},
{
"id": "route_wants_to_join",
"from": "outcome_router",
"to": "end_positive",
"name": "Route to Positive End",
"condition": { "field": "outcome_status", "value": "wants_to_join" },
"actions": [
{ "type": "start_process", "process_code": "obsluga-przyjecia-nowego-czlonka" }
]
},
{
"id": "route_declined",
"from": "outcome_router",
"to": "end_negative_declined",
"name": "Route to Declined",
"condition": { "field": "outcome_status", "value": "declined" }
},
{
"id": "route_no_answer",
"from": "outcome_router",
"to": "decide_after_no_answer",
"name": "Route to No Answer",
"condition": { "field": "outcome_status", "value": "no_answer" }
},
{
"id": "route_call_later",
"from": "outcome_router",
"to": "waiting_for_next_contact",
"name": "Route to Call Later",
"condition": { "field": "outcome_status", "value": "call_later" }
},
{ "id": "continue_attempts", "from": "decide_after_no_answer", "to": "awaiting_call", "name": "Try Again" },
{ "id": "end_attempts", "from": "decide_after_no_answer", "to": "end_negative_no_answer", "name": "End Process" },
{ "id": "resume_contact", "from": "waiting_for_next_contact", "to": "awaiting_call", "name": "Resume / Attempt Call" },
{
"id": "terminate_from_awaiting_call",
"from": "awaiting_call",
"to": "end_negative_terminated",
"name": "Zakończ proces",
"actions": [ { "type": "set_data", "params": { "keys": ["termination_note"] } } ],
"form_schema": [ { "name": "termination_note", "label": "Reason for Termination (optional)", "type": "textarea" } ]
},
{
"id": "terminate_from_decide",
"from": "decide_after_no_answer",
"to": "end_negative_terminated",
"name": "Zakończ proces",
"actions": [ { "type": "set_data", "params": { "keys": ["termination_note"] } } ],
"form_schema": [ { "name": "termination_note", "label": "Reason for Termination (optional)", "type": "textarea" } ]
},
{
"id": "terminate_from_waiting",
"from": "waiting_for_next_contact",
"to": "end_negative_terminated",
"name": "Zakończ proces",
"actions": [ { "type": "set_data", "params": { "keys": ["termination_note"] } } ],
"form_schema": [ { "name": "termination_note", "label": "Reason for Termination (optional)", "type": "textarea" } ]
}
]
}
EOT;
$stmt = $pdo->prepare("UPDATE process_definitions SET definition_json = :json, start_node_id = 'awaiting_call' WHERE code = :code");
$stmt->execute([
':json' => $json_definition,
':code' => $process_code,
]);
echo "Migration 025 executed successfully: Updated 'guest_handling' process definition with structured data and router node.";
}
// Direct execution guard
if (basename(__FILE__) == basename($_SERVER["SCRIPT_FILENAME"]))
{
try {
migrate_025();
} catch (Exception $e) {
echo "Migration 25 failed: " . $e->getMessage() . "\n";
}
}

View File

@ -279,6 +279,7 @@ $status_colors = [
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Zamknij"></button>
</div>
<div class="modal-body">
<div id="editPersonError" class="alert alert-danger" style="display: none;"></div>
<input type="hidden" name="id" id="editPersonId">
<div class="row">
<div class="col-md-4">
@ -387,6 +388,8 @@ $status_colors = [
</div>
</div>
</div>
<hr class="my-3">
<div id="followUpSummaryContainer"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Zamknij</button>
@ -407,6 +410,7 @@ $status_colors = [
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="createPersonError" class="alert alert-danger" style="display: none;"></div>
<form id="createPersonForm" action="_create_person.php" method="post" enctype="multipart/form-data">
<div class="row">
<div class="col-md-4">
@ -688,13 +692,27 @@ document.addEventListener('DOMContentLoaded', function () {
const instanceId = button.dataset.instanceId;
const transitionId = button.dataset.transitionId;
if (!confirm(`Czy na pewno chcesz wykonać akcję \"'''${button.textContent.trim()}'''\"?`)) {
if (!confirm(`Czy na pewno chcesz wykonać akcję \"${button.textContent.trim()}\"?`)) {
return;
}
const formData = new FormData();
const form = document.getElementById('transition-form');
const formData = form ? new FormData(form) : new FormData();
formData.append('instanceId', instanceId);
formData.append('transitionId', transitionId);
// Add outcome_status based on transitionId for the new workflow
if (transitionId === 'log_wants_to_join') {
formData.append('outcome_status', 'wants_to_join');
} else if (transitionId === 'log_declined') {
formData.append('outcome_status', 'declined');
} else if (transitionId === 'log_no_answer') {
formData.append('outcome_status', 'no_answer');
} else if (transitionId === 'log_call_later') {
formData.append('outcome_status', 'call_later');
}
submitRequestAndReloadModal('_apply_transition.php', formData);
}