diff --git a/.github/workflows/agent-task-governance.yml b/.github/workflows/agent-task-governance.yml index 894c0fe5..a1a95a0d 100644 --- a/.github/workflows/agent-task-governance.yml +++ b/.github/workflows/agent-task-governance.yml @@ -26,10 +26,10 @@ jobs: steps: - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: 26.7.0 cache: npm diff --git a/.github/workflows/desktop.yml b/.github/workflows/desktop.yml index 23483088..4d75f190 100644 --- a/.github/workflows/desktop.yml +++ b/.github/workflows/desktop.yml @@ -77,7 +77,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Install Linux desktop dependencies if: matrix.platform == 'ubuntu-22.04' @@ -88,7 +88,7 @@ jobs: sudo timeout --foreground 8m apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=60 -o Acquire::https::Timeout=60 install -y --no-install-recommends libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: 26.7.0 cache: npm @@ -543,10 +543,10 @@ jobs: steps: - name: Checkout release metadata generator - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Node.js for manifest generation - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: 26.7.0 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index fbbd180e..df99c369 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout Code - uses: actions/checkout@v4 + uses: actions/checkout@v5 # - name: Setup PHP # uses: shivammathur/setup-php@v2 @@ -30,7 +30,7 @@ jobs: # run: composer install -q --no-ansi --no-interaction --no-scripts --no-progress --prefer-dist - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: 26.7.0 cache: npm diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9a6977ce..0dcbf613 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -12,7 +12,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable @@ -47,7 +47,7 @@ jobs: options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Setup PHP uses: shivammathur/setup-php@v2 @@ -89,13 +89,13 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - name: Install Rust toolchain uses: dtolnay/rust-toolchain@stable - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v5 with: node-version: 26.7.0 cache: npm diff --git a/backend/app/Domains/EnterpriseCore/OrganizationGovernance/Services/ModuleReadinessService.php b/backend/app/Domains/EnterpriseCore/OrganizationGovernance/Services/ModuleReadinessService.php index 910aaf17..9971497b 100644 --- a/backend/app/Domains/EnterpriseCore/OrganizationGovernance/Services/ModuleReadinessService.php +++ b/backend/app/Domains/EnterpriseCore/OrganizationGovernance/Services/ModuleReadinessService.php @@ -17,15 +17,8 @@ */ class ModuleReadinessService { - public const ONBOARDING_POLICY_VERSION = '2026-08-technology-commerce-v1'; - - /** - * The default profile supports a small technology retailer or technology - * organisation with a physical device store, warehouse, POS, finance, and - * purchasing flows. Optional delivery/project/manufacturing capabilities - * remain governed by their own module requirements. - */ - public const DEFAULT_ONBOARDING_PROFILE = 'technology_commerce'; + public const ONBOARDING_POLICY_VERSION = '2026-08-profile-driven-setup-v1'; + public const DEFAULT_ONBOARDING_PROFILE = 'guided_setup'; /** @var list */ private const FOUNDATION_NODE_TYPES = ['COMP_CODE', 'CONTROLLING_AREA', 'COST_CENTER', 'PROFIT_CENTER']; @@ -36,12 +29,19 @@ class ModuleReadinessService /** @var list */ private const CORE_STARTER_MODULES = ['sales', 'products', 'purchases', 'general_ledger']; - public function __construct(private readonly OrgStructureService $orgStructureService) - { + public function __construct( + private readonly OrgStructureService $orgStructureService, + private readonly OrganizationTemplateService $templates, + ) { } public function evaluate(?int $userId = null): array { + $templateProfile = $this->templates->profile(); + $profileKey = (string) ($templateProfile['template_key'] ?? self::DEFAULT_ONBOARDING_PROFILE); + $coreOperatingNodeTypes = $this->coreOperatingNodeTypes($profileKey); + $requiresOperatingContext = $this->requiresOperatingContext($profileKey); + $starterModules = $this->starterModules($profileKey); $integrityIssues = $this->orgStructureService->runIntegrityCheck(); $activeNodeTypes = $this->activeNodeTypes(); $context = $this->defaultContext($userId); @@ -49,7 +49,7 @@ public function evaluate(?int $userId = null): array $operatingStructure = $this->validateOperatingStructure($context, $integrityIssues); $accountingReadiness = $this->accountingReadiness(); $foundation = $this->foundationReadiness($activeNodeTypes, $integrityIssues, $accountingReadiness); - $coreOperations = $this->coreOperationsReadiness($context, $activeNodeTypes, $integrityIssues, $operatingStructure); + $coreOperations = $this->coreOperationsReadiness($context, $activeNodeTypes, $integrityIssues, $operatingStructure, $coreOperatingNodeTypes, $requiresOperatingContext); $hasActiveStructure = $activeNodeTypes !== []; $modules = Module::query()->orderBy('category')->orderBy('sort_order')->get(); @@ -132,7 +132,7 @@ public function evaluate(?int $userId = null): array $baselineReady = $foundation['ready'] && $coreOperations['ready']; $onboarding = [ 'policy_version' => self::ONBOARDING_POLICY_VERSION, - 'profile' => self::DEFAULT_ONBOARDING_PROFILE, + 'profile' => $profileKey, 'baseline_ready' => $baselineReady, 'phases' => [ 'foundation' => $foundation, @@ -141,7 +141,7 @@ public function evaluate(?int $userId = null): array 'next_phase' => !$foundation['ready'] ? 'foundation' : (!$coreOperations['ready'] ? 'core_operations' : 'module_activation'), - 'starter_module_keys' => self::CORE_STARTER_MODULES, + 'starter_module_keys' => $starterModules, ]; return [ @@ -371,15 +371,17 @@ private function foundationReadiness(array $activeNodeTypes, array $integrityIss * hierarchy, warehouse, and POS terminal. The organisation can later add * project, manufacturing, and HR structures without reopening this gate. */ - private function coreOperationsReadiness(?OperatingContext $context, array $activeNodeTypes, array $integrityIssues, array $operatingStructure): array + private function coreOperationsReadiness(?OperatingContext $context, array $activeNodeTypes, array $integrityIssues, array $operatingStructure, array $coreOperatingNodeTypes, bool $requiresOperatingContext): array { - $missingNodeTypes = array_values(array_diff(self::CORE_OPERATING_NODE_TYPES, $activeNodeTypes)); - $relevantIssues = $this->relevantIntegrityIssues($integrityIssues, self::CORE_OPERATING_NODE_TYPES); - $validNodeTypes = $this->validCoreOperatingNodeTypes($context); - $unlinkedNodeTypes = array_values(array_diff(self::CORE_OPERATING_NODE_TYPES, $validNodeTypes)); + $missingNodeTypes = array_values(array_diff($coreOperatingNodeTypes, $activeNodeTypes)); + $relevantIssues = $this->relevantIntegrityIssues($integrityIssues, $coreOperatingNodeTypes); + $validNodeTypes = $requiresOperatingContext + ? $this->validCoreOperatingNodeTypes($context, $coreOperatingNodeTypes) + : $coreOperatingNodeTypes; + $unlinkedNodeTypes = array_values(array_diff($coreOperatingNodeTypes, $validNodeTypes)); $reasonCodes = []; - if (!$operatingStructure['ready']) { + if ($requiresOperatingContext && !$operatingStructure['ready']) { $reasonCodes = [...$reasonCodes, ...$operatingStructure['reason_codes']]; } if ($missingNodeTypes !== []) { @@ -397,7 +399,7 @@ private function coreOperationsReadiness(?OperatingContext $context, array $acti return [ 'id' => 'core_operations', 'ready' => $reasonCodes === [], - 'required_node_types' => self::CORE_OPERATING_NODE_TYPES, + 'required_node_types' => $coreOperatingNodeTypes, 'missing_node_types' => $missingNodeTypes, 'unlinked_node_types' => $unlinkedNodeTypes, 'integrity_issue_count' => count($relevantIssues), @@ -406,7 +408,7 @@ private function coreOperationsReadiness(?OperatingContext $context, array $acti } /** @return list */ - private function validCoreOperatingNodeTypes(?OperatingContext $context): array + private function validCoreOperatingNodeTypes(?OperatingContext $context, array $coreOperatingNodeTypes): array { if (!$context?->org_node_uuid) { return []; @@ -419,7 +421,7 @@ private function validCoreOperatingNodeTypes(?OperatingContext $context): array } $valid = []; - foreach (self::CORE_OPERATING_NODE_TYPES as $nodeType) { + foreach ($coreOperatingNodeTypes as $nodeType) { $hasCompanyLinkedNode = StructureNode::query() ->where('node_type_id', $nodeType) ->where('status', 'active') @@ -438,6 +440,36 @@ private function validCoreOperatingNodeTypes(?OperatingContext $context): array return $valid; } + /** @return list */ + private function coreOperatingNodeTypes(string $profileKey): array + { + return match ($profileKey) { + 'single_store_retail', 'multi_site_retail', 'manufacturing' => ['PLANT', 'STORAGE_LOC', 'SALES_ORG', 'PURCH_ORG'], + 'single_store_service', 'professional_services' => ['SALES_ORG'], + // Enterprise blueprints deliberately defer operational scope until + // each legal entity/site is governed through the advanced designer. + 'enterprise_blueprint' => [], + default => [], + }; + } + + private function requiresOperatingContext(string $profileKey): bool + { + return in_array($profileKey, ['single_store_retail', 'multi_site_retail', 'manufacturing'], true); + } + + /** @return list */ + private function starterModules(string $profileKey): array + { + return match ($profileKey) { + 'single_store_retail', 'multi_site_retail' => ['sales', 'products', 'purchases', 'general_ledger'], + 'manufacturing' => ['sales', 'products', 'purchases', 'general_ledger'], + 'single_store_service', 'professional_services' => ['sales', 'general_ledger'], + 'enterprise_blueprint' => ['general_ledger'], + default => self::CORE_STARTER_MODULES, + }; + } + private function activeNodeTypes(): array { return StructureNode::query() diff --git a/backend/app/Domains/EnterpriseCore/OrganizationGovernance/Services/ModuleSelectionService.php b/backend/app/Domains/EnterpriseCore/OrganizationGovernance/Services/ModuleSelectionService.php index 7a61b508..72e6a268 100644 --- a/backend/app/Domains/EnterpriseCore/OrganizationGovernance/Services/ModuleSelectionService.php +++ b/backend/app/Domains/EnterpriseCore/OrganizationGovernance/Services/ModuleSelectionService.php @@ -33,8 +33,10 @@ final class ModuleSelectionService */ public const CORE_STARTER_MODULES = ['sales', 'products', 'purchases', 'general_ledger']; - public function __construct(private readonly ModuleReadinessService $readiness) - { + public function __construct( + private readonly ModuleReadinessService $readiness, + private readonly OrganizationTemplateService $templates, + ) { } /** @return list */ @@ -204,6 +206,7 @@ public function state(?int $userId = null): array 'active_module_keys' => $activeBusiness->pluck('module_key')->all(), 'pending_module_keys' => $pendingBusiness->pluck('module_key')->all(), 'modules' => $modules->all(), + 'organization_template' => $this->templates->state(), ]; } diff --git a/backend/app/Domains/EnterpriseCore/OrganizationGovernance/Services/OrganizationTemplateService.php b/backend/app/Domains/EnterpriseCore/OrganizationGovernance/Services/OrganizationTemplateService.php new file mode 100644 index 00000000..abecef39 --- /dev/null +++ b/backend/app/Domains/EnterpriseCore/OrganizationGovernance/Services/OrganizationTemplateService.php @@ -0,0 +1,363 @@ +> */ + private const TEMPLATES = [ + 'single_store_retail' => [ + 'industry' => 'retail', + 'requires_inventory' => true, + 'label_ar' => 'متجر تجزئة واحد مع مخزون', + 'label_en' => 'Single-store retail with inventory', + 'description_ar' => 'لبقالة أو صيدلية أو متجر يبيع من نقطة بيع ويحتفظ بمخزون.', + 'generated_types' => ['COMP_CODE', 'CONTROLLING_AREA', 'COST_CENTER', 'PROFIT_CENTER', 'PLANT', 'STORAGE_LOC', 'PURCH_ORG', 'SALES_ORG'], + ], + 'single_store_service' => [ + 'industry' => 'services', + 'requires_inventory' => false, + 'label_ar' => 'منشأة خدمات أو متجر بلا مخزون', + 'label_en' => 'Service business or stock-free store', + 'description_ar' => 'لشركة خدمات أو بيع مباشر لا يحتاج إلى موقع مخزون في البداية.', + 'generated_types' => ['COMP_CODE', 'CONTROLLING_AREA', 'COST_CENTER', 'PROFIT_CENTER', 'SALES_ORG'], + ], + 'multi_site_retail' => [ + 'industry' => 'retail', + 'requires_inventory' => true, + 'label_ar' => 'تجزئة متعددة الفروع', + 'label_en' => 'Multi-site retail', + 'description_ar' => 'ينشئ الفرع الأول بأمان ويترك إضافة الفروع التالية لمسار توسع واضح.', + 'generated_types' => ['COMP_CODE', 'CONTROLLING_AREA', 'COST_CENTER', 'PROFIT_CENTER', 'PLANT', 'STORAGE_LOC', 'PURCH_ORG', 'SALES_ORG'], + ], + 'professional_services' => [ + 'industry' => 'professional_services', + 'requires_inventory' => false, + 'label_ar' => 'خدمات مهنية ومشاريع', + 'label_en' => 'Professional services and projects', + 'description_ar' => 'لبيوت الخبرة والاستشارات والخدمات المهنية قبل تفعيل المشاريع والموارد البشرية.', + 'generated_types' => ['COMP_CODE', 'CONTROLLING_AREA', 'COST_CENTER', 'PROFIT_CENTER', 'SALES_ORG'], + ], + 'manufacturing' => [ + 'industry' => 'manufacturing', + 'requires_inventory' => true, + 'label_ar' => 'تصنيع أو تشغيل بمخزون', + 'label_en' => 'Manufacturing or inventory operations', + 'description_ar' => 'ينشئ موقع تشغيل ومخزونًا ومبيعات ومشتريات، ثم يفتح التصنيع كتوسّع منفصل.', + 'generated_types' => ['COMP_CODE', 'CONTROLLING_AREA', 'COST_CENTER', 'PROFIT_CENTER', 'PLANT', 'STORAGE_LOC', 'PURCH_ORG', 'SALES_ORG'], + ], + 'enterprise_blueprint' => [ + 'industry' => 'enterprise', + 'requires_inventory' => false, + 'label_ar' => 'مخطط مؤسسة كبيرة', + 'label_en' => 'Enterprise blueprint', + 'description_ar' => 'يؤسس الشركة والأبعاد الرقابية فقط، ثم يدار التوسع متعدد الكيانات والفروع عبر المصمم المتقدم.', + 'generated_types' => ['COMP_CODE', 'CONTROLLING_AREA', 'COST_CENTER', 'PROFIT_CENTER'], + ], + ]; + + public function __construct(private readonly OrgStructureService $orgStructure) + { + } + + /** @return array */ + public function state(): array + { + $profile = $this->profile(); + + return [ + 'profile' => $profile, + 'templates' => collect(self::TEMPLATES)->map(function (array $template, string $key): array { + return [ + 'key' => $key, + ...$template, + ]; + })->values()->all(), + 'is_applied' => (bool) Arr::get($profile, 'applied_at'), + 'can_apply' => $profile !== null && !$this->hasActiveFoundation(), + ]; + } + + /** @return array|null */ + public function profile(): ?array + { + $raw = Setting::query()->where('setting_key', self::PROFILE_KEY)->value('setting_value'); + if (!is_string($raw) || trim($raw) === '') { + return null; + } + + $decoded = json_decode($raw, true); + + return is_array($decoded) ? $decoded : null; + } + + /** @param array $data */ + public function saveProfile(array $data): array + { + $templateKey = (string) ($data['template_key'] ?? ''); + if (!isset(self::TEMPLATES[$templateKey])) { + throw ValidationException::withMessages(['template_key' => ['Select a supported organization template.']]); + } + + $template = self::TEMPLATES[$templateKey]; + $countryCode = strtoupper(trim((string) ($data['country_code'] ?? ''))); + $currencyId = $data['currency_id'] ?? null; + if (!$currencyId || !Currency::active()->whereKey($currencyId)->exists()) { + throw ValidationException::withMessages(['currency_id' => ['Select an active operating currency.']]); + } + if ($countryCode === '') { + throw ValidationException::withMessages(['country_code' => ['Select the legal country or region.']]); + } + + $profile = [ + 'template_key' => $templateKey, + 'industry' => $template['industry'], + 'organization_size' => (string) ($data['organization_size'] ?? 'small'), + 'company_name' => trim((string) ($data['company_name'] ?? '')), + 'company_code' => strtoupper(trim((string) ($data['company_code'] ?? ''))), + 'country_code' => $countryCode, + 'currency_id' => (int) $currencyId, + 'primary_site_name' => trim((string) ($data['primary_site_name'] ?? '')), + 'primary_site_code' => strtoupper(trim((string) ($data['primary_site_code'] ?? ''))), + 'factory_calendar_id' => filled($data['factory_calendar_id'] ?? null) ? (int) $data['factory_calendar_id'] : null, + 'language' => in_array(($data['language'] ?? null), ['ar-SA', 'en-US'], true) ? $data['language'] : 'ar-SA', + 'inventory_enabled' => (bool) $template['requires_inventory'], + 'updated_at' => now()->toISOString(), + 'applied_at' => Arr::get($this->profile(), 'applied_at'), + ]; + + if ($profile['company_name'] === '' || $profile['company_code'] === '') { + throw ValidationException::withMessages(['company_name' => ['Company name and code are required.']]); + } + if (!preg_match('/^[A-Z0-9_-]{2,20}$/', $profile['company_code'])) { + throw ValidationException::withMessages(['company_code' => ['Company code must use 2–20 uppercase letters, numbers, underscores, or hyphens.']]); + } + + if ($profile['inventory_enabled']) { + if ($profile['primary_site_name'] === '' || $profile['primary_site_code'] === '') { + throw ValidationException::withMessages(['primary_site_name' => ['Site name and code are required for inventory templates.']]); + } + $calendar = $profile['factory_calendar_id'] ? FactoryCalendar::active()->find($profile['factory_calendar_id']) : null; + if (!$calendar) { + throw ValidationException::withMessages(['factory_calendar_id' => ['Select an active operating calendar for the primary site.']]); + } + if (strcasecmp($calendar->country_code, $profile['country_code']) !== 0) { + throw ValidationException::withMessages(['factory_calendar_id' => ['The operating calendar must match the legal country.']]); + } + } + + Setting::updateOrCreate( + ['setting_key' => self::PROFILE_KEY], + ['setting_value' => json_encode($profile, JSON_THROW_ON_ERROR)] + ); + + return $this->state(); + } + + /** @return array */ + public function apply(?int $userId = null): array + { + $profile = $this->profile(); + if (!$profile) { + throw ValidationException::withMessages(['profile' => ['Save the organization profile before applying a template.']]); + } + if (filled($profile['applied_at'] ?? null)) { + throw ValidationException::withMessages(['profile' => ['This organization template has already been applied. Use the advanced designer for controlled expansion.']]); + } + if ($this->hasActiveFoundation()) { + throw ValidationException::withMessages(['profile' => ['An active organizational foundation already exists. Template application is blocked to prevent duplicate structures.']]); + } + + $templateKey = (string) $profile['template_key']; + $template = self::TEMPLATES[$templateKey] ?? null; + if (!$template) { + throw ValidationException::withMessages(['template_key' => ['The saved template is no longer available.']]); + } + $this->assertBaselineLedger(); + + return DB::transaction(function () use ($profile, $template, $userId): array { + $client = $this->orgStructure->createNodeWithLinks([ + 'node_type_id' => 'CLIENT', + 'code' => 'CLIENT-'.$profile['company_code'], + 'attributes' => [ + 'name' => $profile['company_name'], + 'default_language' => $profile['language'], + ], + 'status' => 'active', + ])['node']; + + $company = $this->orgStructure->createNodeWithLinks([ + 'node_type_id' => 'COMP_CODE', + 'code' => $profile['company_code'], + 'attributes' => [ + 'name' => $profile['company_name'], + 'country_code' => $profile['country_code'], + 'currency_id' => (string) $profile['currency_id'], + 'chart_of_accounts_id' => self::PRIMARY_GENERAL_LEDGER_REFERENCE, + 'fiscal_year_variant' => 'K4', + 'language' => $profile['language'], + ], + 'status' => 'active', + ], [['target_node_uuid' => $client->node_uuid, 'validate_constraints' => true]])['node']; + + $controlling = $this->orgStructure->createNodeWithLinks([ + 'node_type_id' => 'CONTROLLING_AREA', + 'code' => 'CA-'.$profile['company_code'], + 'attributes' => [ + 'name' => 'منطقة التحكم — '.$profile['company_name'], + 'currency_id' => (string) $profile['currency_id'], + ], + 'status' => 'active', + ], [['target_node_uuid' => $company->node_uuid, 'validate_constraints' => true]])['node']; + + $costCenter = $this->orgStructure->createNodeWithLinks([ + 'node_type_id' => 'COST_CENTER', + 'code' => 'CC-'.$profile['company_code'], + 'attributes' => [ + 'name' => 'مركز تكلفة — '.$profile['company_name'], + 'cost_center_category' => 'operational', + ], + 'status' => 'active', + ], [['target_node_uuid' => $controlling->node_uuid, 'validate_constraints' => true]])['node']; + + $profitCenter = $this->orgStructure->createNodeWithLinks([ + 'node_type_id' => 'PROFIT_CENTER', + 'code' => 'PC-'.$profile['company_code'], + 'attributes' => [ + 'name' => 'مركز ربح — '.$profile['company_name'], + 'profit_center_group' => 'default', + ], + 'status' => 'active', + ], [['target_node_uuid' => $controlling->node_uuid, 'validate_constraints' => true]])['node']; + + $created = [ + 'client' => $client->node_uuid, + 'company_code' => $company->node_uuid, + 'controlling_area' => $controlling->node_uuid, + 'cost_center' => $costCenter->node_uuid, + 'profit_center' => $profitCenter->node_uuid, + ]; + + if (($template['requires_inventory'] ?? false) === true) { + $site = $this->orgStructure->createNodeWithLinks([ + 'node_type_id' => 'PLANT', + 'code' => $profile['primary_site_code'], + 'attributes' => [ + 'name' => $profile['primary_site_name'], + 'country_code' => $profile['country_code'], + 'factory_calendar_id' => (string) $profile['factory_calendar_id'], + 'language' => $profile['language'], + ], + 'status' => 'active', + ], [ + ['target_node_uuid' => $company->node_uuid, 'validate_constraints' => true], + ['target_node_uuid' => $profitCenter->node_uuid, 'validate_constraints' => true], + ])['node']; + + $storage = $this->orgStructure->createNodeWithLinks([ + 'node_type_id' => 'STORAGE_LOC', + 'code' => 'ST-'.$profile['primary_site_code'], + 'attributes' => ['name' => 'مخزون '.$profile['primary_site_name']], + 'status' => 'active', + ], [['target_node_uuid' => $site->node_uuid, 'validate_constraints' => true]])['node']; + + $purchasing = $this->orgStructure->createNodeWithLinks([ + 'node_type_id' => 'PURCH_ORG', + 'code' => 'PO-'.$profile['company_code'], + 'attributes' => ['name' => 'مشتريات '.$profile['company_name']], + 'status' => 'active', + ], [ + ['target_node_uuid' => $company->node_uuid, 'validate_constraints' => true], + ['target_node_uuid' => $site->node_uuid, 'validate_constraints' => true], + ])['node']; + + $sales = $this->createSalesOrganization($profile, $company->node_uuid); + $this->orgStructure->createLink($site->node_uuid, $sales->node_uuid); + $created += [ + 'site' => $site->node_uuid, + 'storage_location' => $storage->node_uuid, + 'purchasing_organization' => $purchasing->node_uuid, + 'sales_organization' => $sales->node_uuid, + ]; + } elseif (in_array('SALES_ORG', $template['generated_types'], true)) { + $sales = $this->createSalesOrganization($profile, $company->node_uuid); + $created['sales_organization'] = $sales->node_uuid; + } + + $profile['applied_at'] = now()->toISOString(); + $profile['applied_by'] = $userId; + $profile['created_nodes'] = $created; + Setting::updateOrCreate( + ['setting_key' => self::PROFILE_KEY], + ['setting_value' => json_encode($profile, JSON_THROW_ON_ERROR)] + ); + + return [ + 'profile' => $profile, + 'created_nodes' => $created, + 'next_actions' => [ + 'Create a scoped warehouse and POS terminal through the master-data flow before configuring the operating context.', + 'Use the advanced organization designer only when adding branches, channels, sales teams, HR, projects, or other governed extensions.', + ], + ]; + }); + } + + private function createSalesOrganization(array $profile, string $companyUuid): StructureNode + { + return $this->orgStructure->createNodeWithLinks([ + 'node_type_id' => 'SALES_ORG', + 'code' => 'SO-'.$profile['company_code'], + 'attributes' => [ + 'name' => 'مبيعات '.$profile['company_name'], + 'currency_id' => (string) $profile['currency_id'], + ], + 'status' => 'active', + ], [['target_node_uuid' => $companyUuid, 'validate_constraints' => true]])['node']; + } + + private function hasActiveFoundation(): bool + { + return StructureNode::query() + ->where('status', 'active') + ->whereIn('node_type_id', ['COMP_CODE', 'CONTROLLING_AREA', 'COST_CENTER', 'PROFIT_CENTER']) + ->exists(); + } + + private function assertBaselineLedger(): void + { + $required = ['asset', 'liability', 'equity', 'revenue', 'expense']; + $available = ChartOfAccount::query() + ->where('is_active', true) + ->pluck('account_type') + ->map(fn ($type) => strtolower((string) $type)) + ->unique() + ->all(); + + if (array_diff($required, $available) !== []) { + throw ValidationException::withMessages([ + 'chart_of_accounts' => ['The seeded baseline chart of accounts is incomplete. Restore the basic chart before applying an organization template.'], + ]); + } + } +} diff --git a/backend/app/Http/Controllers/Api/V2/EnterpriseCore/OrganizationGovernance/SetupStateController.php b/backend/app/Http/Controllers/Api/V2/EnterpriseCore/OrganizationGovernance/SetupStateController.php index b64cfd4f..7ee42a77 100644 --- a/backend/app/Http/Controllers/Api/V2/EnterpriseCore/OrganizationGovernance/SetupStateController.php +++ b/backend/app/Http/Controllers/Api/V2/EnterpriseCore/OrganizationGovernance/SetupStateController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api\V2\EnterpriseCore\OrganizationGovernance; use App\Domains\EnterpriseCore\OrganizationGovernance\Services\ModuleSelectionService; +use App\Domains\EnterpriseCore\OrganizationGovernance\Services\OrganizationTemplateService; use App\Http\Controllers\Api\V2\Shared\BaseApiController; use App\Http\Controllers\Controller; use Illuminate\Http\JsonResponse; @@ -12,8 +13,10 @@ class SetupStateController extends Controller { use BaseApiController; - public function __construct(private readonly ModuleSelectionService $modules) - { + public function __construct( + private readonly ModuleSelectionService $modules, + private readonly OrganizationTemplateService $templates, + ) { } public function show(Request $request): JsonResponse @@ -23,6 +26,45 @@ public function show(Request $request): JsonResponse ]); } + public function organizationTemplates(): JsonResponse + { + return $this->successResponse([ + 'data' => $this->templates->state(), + ]); + } + + public function saveOrganizationProfile(Request $request): JsonResponse + { + $validated = $request->validate([ + 'template_key' => ['required', 'string', 'max:80'], + 'organization_size' => ['required', 'in:micro,small,medium,enterprise'], + 'company_name' => ['required', 'string', 'max:255'], + 'company_code' => ['required', 'string', 'max:20'], + 'country_code' => ['required', 'string', 'size:2'], + 'currency_id' => ['required', 'integer'], + 'primary_site_name' => ['nullable', 'string', 'max:255'], + 'primary_site_code' => ['nullable', 'string', 'max:20'], + 'factory_calendar_id' => ['nullable', 'integer'], + 'language' => ['nullable', 'in:ar-SA,en-US'], + ]); + + return $this->successResponse([ + 'data' => $this->templates->saveProfile($validated), + ], 'Organization profile saved.'); + } + + public function applyOrganizationTemplate(Request $request): JsonResponse + { + $result = $this->templates->apply($request->user()?->id); + + return $this->successResponse([ + 'data' => [ + 'application' => $result, + 'state' => $this->modules->state($request->user()?->id), + ], + ], 'Organization template applied safely.'); + } + public function selectModules(Request $request): JsonResponse { $validated = $request->validate([ diff --git a/backend/routes/domains/01-enterprise-core.php b/backend/routes/domains/01-enterprise-core.php index 715660ae..145c00b3 100644 --- a/backend/routes/domains/01-enterprise-core.php +++ b/backend/routes/domains/01-enterprise-core.php @@ -96,6 +96,9 @@ // ── 04. Setup lifecycle (OrganizationGovernance) Route::group(['prefix' => 'setup', 'middleware' => 'can:settings,view'], function () { Route::get('/state', [SetupStateController::class, 'show'])->name('v2.setup.state'); + Route::get('/organization-templates', [SetupStateController::class, 'organizationTemplates'])->name('v2.setup.organization_templates'); + Route::middleware(['can:settings,edit', 'throttle:api-write'])->post('/organization-profile', [SetupStateController::class, 'saveOrganizationProfile'])->name('v2.setup.organization_profile.save'); + Route::middleware(['can:settings,create', 'throttle:api-critical'])->post('/apply-organization-template', [SetupStateController::class, 'applyOrganizationTemplate'])->name('v2.setup.organization_template.apply'); Route::middleware(['can:settings,edit', 'throttle:api-write'])->post('/modules', [SetupStateController::class, 'selectModules'])->name('v2.setup.modules.select'); Route::middleware(['can:settings,edit', 'throttle:api-critical'])->post('/activate-selected', [SetupStateController::class, 'activateSelected'])->name('v2.setup.modules.activate_selected'); }); diff --git a/backend/tests/Feature/Api/SetupStateApiTest.php b/backend/tests/Feature/Api/SetupStateApiTest.php index 4a244960..e050ed2e 100644 --- a/backend/tests/Feature/Api/SetupStateApiTest.php +++ b/backend/tests/Feature/Api/SetupStateApiTest.php @@ -40,6 +40,16 @@ public function test_fresh_installation_requires_explicit_business_module_select ->assertJsonPath('data.onboarding.starter_bundle_active', false); } + public function test_organization_template_catalog_is_available_before_the_profile_is_saved(): void + { + $response = $this->authGet(route('v2.setup.organization_templates')); + + $this->assertSuccessResponse($response); + $response->assertJsonPath('data.is_applied', false) + ->assertJsonPath('data.can_apply', false) + ->assertJsonPath('data.templates.0.key', 'single_store_retail'); + } + public function test_optional_reporting_module_cannot_bypass_the_mandatory_baseline(): void { $selected = $this->authPost(route('v2.setup.modules.select'), [ @@ -59,13 +69,13 @@ public function test_optional_reporting_module_cannot_bypass_the_mandatory_basel $this->assertDatabaseHas('modules', ['module_key' => 'reports', 'is_active' => false]); } - public function test_technology_commerce_configuration_activates_the_mandatory_bundle_and_keeps_optional_projects_scoped(): void + public function test_guided_setup_baseline_activates_the_starter_bundle_and_keeps_optional_projects_scoped(): void { $this->configureTechnologyCommerceBaseline(); $state = $this->authGet(route('v2.setup.state')); $this->assertSuccessResponse($state); - $state->assertJsonPath('data.onboarding.profile', 'technology_commerce') + $state->assertJsonPath('data.onboarding.profile', 'guided_setup') ->assertJsonPath('data.onboarding.phases.foundation.ready', true) ->assertJsonPath('data.onboarding.phases.core_operations.ready', true) ->assertJsonPath('data.onboarding.next_phase', 'module_activation'); diff --git a/docs/Plans/enterprise-setup-ux-principles.md b/docs/Plans/enterprise-setup-ux-principles.md new file mode 100644 index 00000000..d43d253a --- /dev/null +++ b/docs/Plans/enterprise-setup-ux-principles.md @@ -0,0 +1,24 @@ +# Enterprise setup UX principles + +## Source-informed decisions + +The setup experience should use one coherent responsive implementation across desktop, tablet, and mobile, while progressively reducing low-priority complexity on narrower screens. SAP Fiori recommends responsive controls and a mobile-first approach; it also recommends an adaptive reduction of complexity when a desktop workflow cannot be presented unchanged on smaller devices. [1] + +The layout should use repeatable spacing, constrained reading width, predictable grouping, and responsive reflow. Fluent explains that consistent spacing establishes relationships and hierarchy, and that layouts should reposition, resize, reflow, or progressively disclose content according to viewport size. [2] + +The setup should keep labels visible, provide concise helper text for format or business context, use selection controls rather than free-form entry where values are governed, and keep related controls aligned in a form grid. These decisions follow Carbon’s enterprise form guidance. [3] + +## Applied rules + +1. The guided setup will have a stable, single-column reading flow with a constrained maximum width and a single primary action per phase. +2. Template choice will be a concise overview with progressive detail; the advanced organization designer remains outside onboarding. +3. Profile fields will use visible labels and helper text, with responsive reflow from paired columns to one column. +4. Smart text direction is a component behavior: `dir="auto"` and content-aware alignment will follow the first strong character of a user-entered value. Technical fields retain a forced left-to-right direction. +5. The existing component library remains the first choice. Global selectors in `globals.css` are used only to compose those components into setup-specific layouts. +6. Status is shown as a layered progression: business profile, organization foundation, operating context, and enabled capabilities. A global final-ready state must not conceal a completed intermediate state. + +## References + +[1]: https://www.sap.com/design-system/fiori-design-web/v1-96/discover/sap-design-system/vision-and-mission/responsiveness-adaptiveness "SAP Fiori responsive and adaptive design" +[2]: https://fluent2.microsoft.design/layout "Fluent 2 layout guidance" +[3]: https://carbondesignsystem.com/components/form/usage/ "Carbon form usage guidance" diff --git a/docs/Plans/profile-driven-setup-templates.md b/docs/Plans/profile-driven-setup-templates.md new file mode 100644 index 00000000..6eef58a7 --- /dev/null +++ b/docs/Plans/profile-driven-setup-templates.md @@ -0,0 +1,43 @@ +# Profile-driven setup templates + +## Decision + +The guided setup must collect business intent before it exposes any organizational objects. A selected template creates the smallest safe technical structure using the existing organization metadata and topology services. The advanced organization designer remains available for every defined organizational type; templates never constrain the system to a fixed catalogue. + +## Profile inputs + +- **Industry:** retail, wholesale, services, manufacturing, technology, professional-services, enterprise. +- **Scale:** micro, small, medium, enterprise. +- **Operational choices:** inventory, point of sale, purchasing, online sales, branches. +- **Organization inputs:** legal company name/code, country, currency, primary site name/code, factory calendar where inventory is enabled. + +## Initial templates + +| Template | Intended organization | Generated minimum structure | +|---|---|---| +| `single_store_retail` | Grocery, pharmacy, small store | Company Code, Controlling Area, Cost Center, Profit Center, Store Site (`PLANT`), Store Inventory (`STORAGE_LOC`), Retail Purchasing Org, Retail Sales Org | +| `single_store_service` | Service shop or small office | Company Code, Controlling Area, Cost Center, Profit Center, Service Sales Org | +| `multi_site_retail` | Chain or regional retailer | Same core structure, named as a primary site and ready for additional site templates | +| `professional_services` | Consulting and professional services | Company Code, Controlling Area, Cost Center, Profit Center, Service Sales Org | +| `manufacturing` | Manufacturing business | Company Code, Controlling Area, Cost Center, Profit Center, Plant, Storage Location, Purchasing Org, Sales Org | +| `enterprise_blueprint` | Large organization / Microsoft-scale rollout | Company Code, Controlling Area, Cost Center, Profit Center; leaves advanced dimensions and separate legal entities to governed expansion flows | + +## Rules + +1. The primary general ledger remains the seeded default template. The setup only validates its availability; it does not create a second chart-of-accounts concept. +2. A template applies only when no active foundational organization nodes exist. This prevents accidental overwrite of an existing institution. +3. The template service uses `OrgStructureService::createNodeWithLinks()` inside a single database transaction, so all metadata validation, topology rules, change history, and cost/profit integrations remain authoritative. +4. A plant is named **site/store** in the business UI. It is not described as a factory unless the chosen template is manufacturing. +5. Sales office, sales group, distribution channel, division, purchasing group, HR, and project units are extension flows. They must never be required for the initial template. +6. The setup header is refreshed immediately after a successful profile save or template application. The returned setup state and readiness data are stored together, avoiding stale completion badges. + +## Extension model + +The profile service provides a template catalogue, not a list of all possible organizational types. Existing metadata and the advanced designer remain the path for all other structures. A future metadata-managed template registry can move this catalogue to the database without changing the UI contract. + +## UI principles + +- Use `SetupSection`, `SetupField`, `SearchableSelect`, `SegmentedToggle`, and `Button` before creating a new component. +- Global setup classes live only in `frontend/app/globals.css`. +- The guided setup shows business choices and a human-readable preview. It does not show raw `PLANT`, `SALES_GROUP`, or topology codes. +- The advanced designer keeps the raw organizational graph for administrators and consultants after initial setup. diff --git a/docs/Plans/quality-remediation-notes-ar.md b/docs/Plans/quality-remediation-notes-ar.md new file mode 100644 index 00000000..d5a67fbc --- /dev/null +++ b/docs/Plans/quality-remediation-notes-ar.md @@ -0,0 +1,30 @@ +# معالجة تقرير الجودة + +**الفرع:** `feat/profile-driven-setup-templates` + +## النتيجة + +تمت معالجة جميع الأخطاء العشرة والتحذيرات الإحدى عشرة الواردة في المحتوى المرفق ضمن الملفات المشار إليها، مع تحسينين إضافيين متعلقين بإدارة دورة حياة مؤقت التحديث في شاشة الدفعات وقابلية عرض بيانات السمات غير الموثوقة. + +| الفئة | المعالجة المنفذة | النتيجة | +|---|---|---| +| أنواع TypeScript العامة | استبدال `any` بعقود لبيانات اللوحة، العقد التنظيمية، مراكز التكلفة، نقاط البيع، وسمات سياق التشغيل. | إزالة أخطاء `no-explicit-any` الواردة في التقرير. | +| خطافات React | تصحيح تبعيات `useCallback` و`useMemo` التي تعتمد على الترجمة، وإزالة تعبيرات ذات أثر جانبي من تبديل العقد. | إزالة تحذيرات التبعيات والتعبيرات غير المستعملة في الملفات المستهدفة. | +| حالات غير مستعملة | حذف حالات اختيار الدفعة ونوع تنفيذها التي لم تكن تؤثر في الواجهة، وحالة المستخدم غير المستعملة في لوحة المعلومات. | تقليل حالة الواجهة غير الضرورية وتحسين وضوح المسار. | +| المعالجة الدفعيّة | نقل حجم الصفحة إلى ثابت، وإصلاح تنظيف مؤقت التحديث الدوري عند مغادرة الصفحة. | لا يبقى مؤقت تحديث حي بعد إلغاء تحميل المكوّن. | +| استجابات API | إضافة محلل آمن لقوائم الاستجابات المتداخلة، وتحويل قيم السمات غير المعروفة إلى نص قبل عرضها. | تتعامل الواجهة مع استجابات `unknown` بدون فقد سلامة الأنواع. | +| CI | تحديث `actions/checkout` و`actions/setup-node` في مسارات GitHub Actions إلى `v5`. | إزالة مراجع إصدارات الإجراءات التي تعمل بوقت تشغيل Node.js 20 الموقوف. | + +## التحقق المنفذ + +| الفحص | النتيجة | +|---|---| +| ESLint للملفات الثمانية الواردة في التقرير | ناجح، دون أخطاء أو تحذيرات. | +| `npx tsc --noEmit` | ناجح. | +| اختبارات الواجهة `npm test` | ناجحة: 22 ملف اختبار و122 اختبارًا. | +| `git diff --check` | ناجح، دون مشكلات مسافات. | +| فحص مراجع `checkout/setup-node` القديمة في `.github/workflows` | لا توجد مراجع إلى `v3` أو `v4`. | + +## ملاحظات + +يظل تشغيل Laravel وعمليات GitHub Actions نفسها من مسؤولية CI، إذ لا تتوفر بيئة PHP محليًا في جلسة المراجعة. التقرير السابق كان محددًا بهذه الملفات؛ لا تعني النتيجة أن كامل المستودع خالٍ من أي ملاحظة جودة أخرى خارج نطاقه. يجب أن يكون فتح طلب الدمج هو الخطوة التالية ليشغّل المسار الكامل على بيئة GitHub. diff --git a/frontend/app/01-enterprise-core/automation/recurring/batch-processing/(pages)/page.tsx b/frontend/app/01-enterprise-core/automation/recurring/batch-processing/(pages)/page.tsx index 59989fea..8ebe111a 100644 --- a/frontend/app/01-enterprise-core/automation/recurring/batch-processing/(pages)/page.tsx +++ b/frontend/app/01-enterprise-core/automation/recurring/batch-processing/(pages)/page.tsx @@ -10,6 +10,8 @@ import { getIcon } from "@/lib/icons"; import { formatDate } from "@/lib/utils"; import { useCallback, useEffect, useState } from "react"; +const ITEMS_PER_PAGE = 20; + interface Batch { id: number; batch_name: string; @@ -42,10 +44,8 @@ export default function BatchProcessingPage() { const [confirmDialog, setConfirmDialog] = useState(false); const [deleteBatchId, setDeleteBatchId] = useState(null); const [executeBatchId, setExecuteBatchId] = useState(null); - const [executeBatchType, setExecuteBatchType] = useState(""); // Selected batch for items view - const [selectedBatchId, setSelectedBatchId] = useState(null); const [batchItems, setBatchItems] = useState([]); const [isLoadingItems, setIsLoadingItems] = useState(false); @@ -54,16 +54,14 @@ export default function BatchProcessingPage() { const [batchType, setBatchType] = useState(""); const [batchDescription, setBatchDescription] = useState(""); - const itemsPerPage = 20; - const loadBatches = useCallback(async (page: number = 1) => { try { setIsLoading(true); - const response = await fetchAPI(`${API_ENDPOINTS.ENTERPRISE_CORE.BATCH}?page=${page}&limit=${itemsPerPage}`); + const response = await fetchAPI(`${API_ENDPOINTS.ENTERPRISE_CORE.BATCH}?page=${page}&limit=${ITEMS_PER_PAGE}`); if (response.success && response.data) { setBatches(response.data as Batch[]); const total = Number(response.total) || 0; - setTotalPages(Math.ceil(total / itemsPerPage)); + setTotalPages(Math.ceil(total / ITEMS_PER_PAGE)); setCurrentPage(page); } else { showToast(response.message || i18n.catalog["enterpriseCore.batchProcessing.failedLoadBatches"], "error"); @@ -73,25 +71,26 @@ export default function BatchProcessingPage() { } finally { setIsLoading(false); } - }, []); + }, [i18n.catalog]); useEffect(() => { + let refreshInterval: ReturnType | null = null; + const init = async () => { const authenticated = await checkAuth(); if (!authenticated) return; - const storedUser = getStoredUser(); - setUser(storedUser); + setUser(getStoredUser()); await loadBatches(); - - // Auto-refresh every 30 seconds - const interval = setInterval(() => { - loadBatches(currentPage); + refreshInterval = setInterval(() => { + void loadBatches(currentPage); }, 30000); + }; - return () => clearInterval(interval); + void init(); + return () => { + if (refreshInterval) clearInterval(refreshInterval); }; - init(); }, [loadBatches, currentPage]); const getBatchTypeLabel = (type: string) => { @@ -130,7 +129,6 @@ export default function BatchProcessingPage() { const closeItemsDialog = () => { setItemsDialog(false); - setSelectedBatchId(null); setBatchItems([]); }; @@ -173,7 +171,6 @@ export default function BatchProcessingPage() { }; const viewBatchItems = async (batchId: number) => { - setSelectedBatchId(batchId); setIsLoadingItems(true); setItemsDialog(true); @@ -194,9 +191,8 @@ export default function BatchProcessingPage() { } }; - const confirmExecuteBatch = (batchId: number, batchType: string) => { + const confirmExecuteBatch = (batchId: number) => { setExecuteBatchId(batchId); - setExecuteBatchType(batchType); setConfirmDialog(true); }; @@ -331,7 +327,7 @@ export default function BatchProcessingPage() { <> + ); + })} + + + +
+
+ 2 +
+

{i18n.catalog["enterpriseCore.setup.profile.companyDetails.title"]}

+

{i18n.catalog["enterpriseCore.setup.profile.companyDetails.description"]}

+
+
+
+ + onChange({ organization_size: value as OrganizationSize })} + /> + + + onChange({ company_name: event.target.value })} + disabled={isApplied} + required + /> + + + onChange({ company_code: normalizeCode(event.target.value) })} + disabled={isApplied} + required + /> + + + onChange({ country_code: event.target.value.toUpperCase() })} + disabled={isApplied} + required + /> + + + onChange({ currency_id: typeof value === "number" ? value : Number(value) || 0 })} + disabled={isApplied} + /> + +
+
+ + {requiresInventory ? ( +
+
+ 3 +
+

{i18n.catalog["enterpriseCore.setup.profile.siteDetails.title"]}

+

{i18n.catalog["enterpriseCore.setup.profile.siteDetails.description"]}

+
+
+
+ + onChange({ primary_site_name: event.target.value })} + disabled={isApplied} + required + /> + + + onChange({ primary_site_code: normalizeCode(event.target.value) })} + disabled={isApplied} + required + /> + + + onChange({ factory_calendar_id: typeof value === "number" ? value : Number(value) || null })} + disabled={isApplied} + /> + +
+
+ ) : null} + + + +
+ + +
+ + ); +} diff --git a/frontend/app/setup/components/SetupReadinessSummary.tsx b/frontend/app/setup/components/SetupReadinessSummary.tsx index a7ffdff6..7f970cc6 100644 --- a/frontend/app/setup/components/SetupReadinessSummary.tsx +++ b/frontend/app/setup/components/SetupReadinessSummary.tsx @@ -1,5 +1,5 @@ import { Button } from "@/components/ui"; -import { Readiness } from "../types"; +import { Onboarding, Readiness } from "../types"; interface SetupReadinessSummaryProps { title: string; @@ -9,6 +9,10 @@ interface SetupReadinessSummaryProps { completeLabel: string; incompleteLabel: string; readiness: Readiness | null; + onboarding?: Onboarding; + templateApplied?: boolean; + foundationLabel: string; + templateLabel: string; readinessLabels: Record; isLoading: boolean; canOpenDashboard: boolean; @@ -24,6 +28,10 @@ export function SetupReadinessSummary({ completeLabel, incompleteLabel, readiness, + onboarding, + templateApplied = false, + foundationLabel, + templateLabel, readinessLabels, isLoading, canOpenDashboard, @@ -31,6 +39,7 @@ export function SetupReadinessSummary({ onOpenDashboard, }: SetupReadinessSummaryProps) { const checks = readiness?.checks ?? []; + const foundationReady = onboarding?.phases.foundation.ready; const statusClass = readiness?.ready ? "is-ready" : "is-blocked"; return ( @@ -50,6 +59,14 @@ export function SetupReadinessSummary({
+ {foundationReady !== undefined ? ( + + {foundationLabel}: {foundationReady ? completeLabel : incompleteLabel} + + ) : null} + {templateApplied ? ( + {templateLabel}: {completeLabel} + ) : null} {checks.map((check) => ( (null); const [setupState, setSetupState] = useState(null); - const [nodes, setNodes] = useState([]); - const [metaTypes, setMetaTypes] = useState([]); - const [topologyRules, setTopologyRules] = useState([]); - const [organizationIntegrity, setOrganizationIntegrity] = useState(null); const [costCenters, setCostCenters] = useState([]); const [currencies, setCurrencies] = useState([]); const [factoryCalendars, setFactoryCalendars] = useState([]); const [accounts, setAccounts] = useState([]); const [periods, setPeriods] = useState([]); const [posTerminals, setPosTerminals] = useState([]); + const [nodes, setNodes] = useState([]); const [selectedModuleKeys, setSelectedModuleKeys] = useState([]); const [journeyStep, setJourneyStep] = useState("foundation"); + const [organizationProfile, setOrganizationProfile] = useState(emptyOrganizationProfile); + const [selectedTemplateKey, setSelectedTemplateKey] = useState(""); const [accountCode, setAccountCode] = useState(""); const [accountName, setAccountName] = useState(""); @@ -70,13 +82,10 @@ export default function SetupPage() { const load = useCallback(async () => { setIsLoading(true); try { - const [readinessResponse, setupResponse, nodesResponse, typesResponse, topologyResponse, integrityResponse, costsResponse, currenciesResponse, factoryCalendarsResponse, accountsResponse, periodsResponse, posTerminalsResponse] = await Promise.all([ + const [readinessResponse, setupResponse, nodesResponse, costsResponse, currenciesResponse, factoryCalendarsResponse, accountsResponse, periodsResponse, posTerminalsResponse] = await Promise.all([ fetchAPI(API_ENDPOINTS.ENTERPRISE_CORE.OPERATING_CONTEXT.READINESS), fetchAPI(API_ENDPOINTS.ENTERPRISE_CORE.SETUP.STATE), fetchAPI(API_ENDPOINTS.ENTERPRISE_CORE.ORG.NODES), - fetchAPI(API_ENDPOINTS.ENTERPRISE_CORE.ORG.META_TYPES), - fetchAPI(API_ENDPOINTS.ENTERPRISE_CORE.ORG.TOPOLOGY_RULES), - fetchAPI(API_ENDPOINTS.ENTERPRISE_CORE.ORG.INTEGRITY_CHECK), fetchAPI(`${API_ENDPOINTS.FINANCE.COST_CENTERS.BASE}?limit=500`), fetchAPI(API_ENDPOINTS.FINANCE.FOREIGN_EXCHANGE.CURRENCIES.BASE), fetchAPI(API_ENDPOINTS.ENTERPRISE_CORE.ORG.FACTORY_CALENDARS), @@ -89,10 +98,15 @@ export default function SetupPage() { const state = setupResponse.success ? setupResponse.data ?? null : null; setSetupState(state); setSelectedModuleKeys(state?.selected_module_keys ?? []); - setNodes(listFrom(nodesResponse) as OrganizationWorkspaceNode[]); - setMetaTypes(listFrom(typesResponse) as OrganizationMetaType[]); - setTopologyRules(listFrom(topologyResponse) as OrganizationTopologyRule[]); - setOrganizationIntegrity(integrityResponse.success ? integrityResponse.data ?? null : null); + const templateState = state?.organization_template; + const savedProfile = templateState?.profile; + if (savedProfile) { + setOrganizationProfile({ ...emptyOrganizationProfile, ...savedProfile }); + setSelectedTemplateKey(savedProfile.template_key); + } else if (templateState?.templates[0]) { + setSelectedTemplateKey((current) => current || templateState.templates[0].key); + } + setNodes(listFrom(nodesResponse).filter((node) => node.status === "active")); setCostCenters(listFrom(costsResponse).filter((item) => item.is_active !== false)); setCurrencies(listFrom(currenciesResponse).filter((item) => item.is_active !== false)); setFactoryCalendars(listFrom(factoryCalendarsResponse).filter((item) => item.is_active !== false)); @@ -112,24 +126,25 @@ export default function SetupPage() { useEffect(() => { void load(); }, [load]); - const activeNodes = useMemo(() => nodes.filter((node) => node.status === "active"), [nodes]); - const workingUnitOptions = useMemo(() => activeNodes - .filter((node) => node.node_type_id === "COMP_CODE") - .map((node) => ({ - value: node.node_uuid, - label: catalogText(i18n, "enterpriseCore.setup.nodeSummary", { - value0: node.code, - value1: node.meta_type?.display_name_ar || node.meta_type?.display_name || node.node_type_id, - }), - })), [activeNodes, i18n]); + const templateState = setupState?.organization_template; + const templateApplied = templateState?.is_applied === true; + const activeNodes = useMemo(() => nodes.filter((node) => text(node, "node_type_id") === "COMP_CODE"), [nodes]); + const workingUnitOptions = useMemo(() => activeNodes.map((node) => ({ + value: text(node, "node_uuid"), + label: catalogText(i18n, "enterpriseCore.setup.nodeSummary", { + value0: text(node, "code"), + value1: text((node.meta_type as Item | undefined) ?? {}, locale === "ar-SA" ? "display_name_ar" : "display_name") || text(node, "node_type_id"), + }), + })), [activeNodes, i18n, locale]); const selectedWorkingUnitNode = useMemo( - () => nodes.find((node) => node.node_uuid === workingUnit) ?? null, + () => nodes.find((node) => text(node, "node_uuid") === workingUnit) ?? null, [nodes, workingUnit], ); useEffect(() => { if (!selectedWorkingUnitNode) return; - setWorkingUnitCode(selectedWorkingUnitNode.code); - setWorkingUnitName(typeof selectedWorkingUnitNode.attributes_json?.name === "string" ? selectedWorkingUnitNode.attributes_json.name : ""); + setWorkingUnitCode(text(selectedWorkingUnitNode, "code")); + const attributes = selectedWorkingUnitNode.attributes_json as Item | undefined; + setWorkingUnitName(text(attributes ?? {}, "name")); }, [selectedWorkingUnitNode]); const costOptions = useMemo(() => costCenters.map((center) => ({ value: Number(center.id), @@ -142,28 +157,14 @@ export default function SetupPage() { value: Number(terminal.id), label: [text(terminal, "code"), text(terminal, "name")].filter(Boolean).join(" — "), })), [posTerminals]); - const organizationReferenceOptions = useMemo(() => ({ - currency_id: currencies.map((currency) => ({ - value: Number(currency.id), - label: [text(currency, "code"), text(currency, "name")].filter(Boolean).join(" — "), - subtitle: text(currency, "symbol"), - })), - chart_of_accounts_id: (() => { - const activeTypes = new Set(accounts.map((account) => text(account, "account_type").toLowerCase())); - const hasGovernedLedger = accountTypes.every((type) => activeTypes.has(type)); - if (!hasGovernedLedger) return []; - return [{ - value: PRIMARY_GENERAL_LEDGER_REFERENCE, - label: i18n.catalog["enterpriseCore.orgWorkspace.reference.chartOfAccounts.primary"], - subtitle: catalogText(i18n, "enterpriseCore.orgWorkspace.reference.chartOfAccounts.summary", { value0: accounts.length, value1: activeTypes.size }), - }]; - })(), - factory_calendar_id: factoryCalendars.map((calendar) => ({ - value: Number(calendar.id), - label: [text(calendar, "code"), text(calendar, locale === "ar-SA" ? "name_ar" : "name")].filter(Boolean).join(" — "), - subtitle: [text(calendar, "country_code"), text(calendar, "time_zone")].filter(Boolean).join(" · "), - })), - }), [accounts, currencies, factoryCalendars, i18n, locale]); + const currencyOptions = useMemo(() => currencies.map((currency) => ({ + value: Number(currency.id), + label: [text(currency, "code"), text(currency, "name")].filter(Boolean).join(" — "), + })), [currencies]); + const calendarOptions = useMemo(() => factoryCalendars.map((calendar) => ({ + value: Number(calendar.id), + label: [text(calendar, "code"), text(calendar, locale === "ar-SA" ? "name_ar" : "name")].filter(Boolean).join(" — "), + })), [factoryCalendars, locale]); const callAndReload = async (action: () => Promise<{ success?: boolean; message?: string; data?: T }>): Promise => { setIsSaving(true); @@ -182,13 +183,15 @@ export default function SetupPage() { } }; - const createOrganizationNode = async (draft: OrganizationNodeDraft): Promise => { - const saved = await callAndReload(() => fetchAPI(API_ENDPOINTS.ENTERPRISE_CORE.ORG.NODES, { + const saveOrganizationProfile = async () => { + await callAndReload(() => fetchAPI(API_ENDPOINTS.ENTERPRISE_CORE.SETUP.ORGANIZATION_PROFILE, { method: "POST", - body: JSON.stringify({ ...draft, status: "active" }), + body: JSON.stringify({ ...organizationProfile, template_key: selectedTemplateKey }), })); + }; - return saved !== null; + const applyOrganizationTemplate = async () => { + await callAndReload(() => fetchAPI(API_ENDPOINTS.ENTERPRISE_CORE.SETUP.APPLY_ORGANIZATION_TEMPLATE, { method: "POST" })); }; const createAccount = async () => { @@ -237,26 +240,17 @@ export default function SetupPage() { })); }; - const selectWorkingUnit = (nodeUuid: string) => { - setWorkingUnit(nodeUuid); - const node = nodes.find((candidate) => candidate.node_uuid === nodeUuid); - setWorkingUnitCode(node?.code ?? ""); - setWorkingUnitName(typeof node?.attributes_json?.name === "string" ? node.attributes_json.name : ""); - }; - const saveWorkingUnit = async () => { if (!selectedWorkingUnitNode || !workingUnitCode.trim()) { showToast(i18n.catalog["enterpriseCore.setup.failedToSave"], "error"); return; } - await callAndReload(() => fetchAPI(API_ENDPOINTS.ENTERPRISE_CORE.ORG.NODE(selectedWorkingUnitNode.node_uuid), { + const currentAttributes = (selectedWorkingUnitNode.attributes_json as Item | undefined) ?? {}; + await callAndReload(() => fetchAPI(API_ENDPOINTS.ENTERPRISE_CORE.ORG.NODE(text(selectedWorkingUnitNode, "node_uuid")), { method: "PUT", body: JSON.stringify({ code: workingUnitCode.trim(), - attributes: { - ...selectedWorkingUnitNode.attributes_json, - ...(workingUnitName.trim() ? { name: workingUnitName.trim() } : {}), - }, + attributes: { ...currentAttributes, ...(workingUnitName.trim() ? { name: workingUnitName.trim() } : {}) }, }), })); }; @@ -279,9 +273,9 @@ export default function SetupPage() { }; const onboarding = setupState?.onboarding ?? readiness?.onboarding; - const canConfigureModules = onboarding?.phases.core_operations.ready === true; const foundationComplete = onboarding?.phases.foundation.ready === true; const operatingLinksComplete = onboarding?.phases.core_operations.ready === true; + const canConfigureModules = operatingLinksComplete; useEffect(() => { if (journeyStep === "operating_links" && !foundationComplete) setJourneyStep("foundation"); @@ -316,6 +310,10 @@ export default function SetupPage() { completeLabel={i18n.catalog["enterpriseCore.setup.complete"]} incompleteLabel={i18n.catalog["enterpriseCore.setup.incomplete"]} readiness={readiness} + onboarding={onboarding} + templateApplied={templateApplied} + foundationLabel={i18n.catalog["enterpriseCore.setup.profile.step.foundationLabel"]} + templateLabel={i18n.catalog["enterpriseCore.setup.profile.step.templateLabel"]} readinessLabels={readinessLabels} isLoading={isLoading} canOpenDashboard={readiness?.ready === true && setupState?.setup_required === false} @@ -328,118 +326,144 @@ export default function SetupPage() { nextLabel={i18n.catalog["common.general.next"]} completeLabel={i18n.catalog["enterpriseCore.setup.complete"]} steps={[ - { id: "foundation", title: i18n.catalog["enterpriseCore.setup.phase.foundation"], description: i18n.catalog["enterpriseCore.setup.phase.foundationDescription"], complete: foundationComplete, enabled: true }, - { id: "operating_links", title: i18n.catalog["enterpriseCore.setup.phase.coreOperations"], description: i18n.catalog["enterpriseCore.setup.phase.coreOperationsDescription"], complete: operatingLinksComplete, enabled: foundationComplete }, - { id: "optional_capabilities", title: i18n.catalog["enterpriseCore.setup.phase.moduleActivation"], description: i18n.catalog["enterpriseCore.setup.phase.moduleActivationDescription"], complete: onboarding?.starter_bundle_active === true, enabled: operatingLinksComplete }, + { + id: "foundation", + title: i18n.catalog["enterpriseCore.setup.profile.step.foundationTitle"], + description: i18n.catalog["enterpriseCore.setup.profile.step.foundationDescription"], + complete: foundationComplete, + enabled: true, + }, + { + id: "operating_links", + title: i18n.catalog["enterpriseCore.setup.profile.step.operationsTitle"], + description: i18n.catalog["enterpriseCore.setup.profile.step.operationsDescription"], + complete: operatingLinksComplete, + enabled: foundationComplete, + }, + { + id: "optional_capabilities", + title: i18n.catalog["enterpriseCore.setup.profile.step.expansionTitle"], + description: i18n.catalog["enterpriseCore.setup.profile.step.expansionDescription"], + complete: onboarding?.starter_bundle_active === true, + enabled: operatingLinksComplete, + }, ]} onPrevious={() => setJourneyStep((current) => current === "optional_capabilities" ? "operating_links" : "foundation")} onNext={() => setJourneyStep((current) => current === "foundation" ? "operating_links" : "optional_capabilities")} /> {journeyStep === "foundation" ? <> - void load()} - onCreateNode={createOrganizationNode} - /> - void createAccount()} - onCreatePeriod={() => void createPeriod()} - /> + { + setSelectedTemplateKey(templateKey); + setOrganizationProfile((current) => ({ ...current, template_key: templateKey })); + }} + onChange={(changes) => setOrganizationProfile((current) => ({ ...current, ...changes }))} + onSave={() => void saveOrganizationProfile()} + onApply={() => void applyOrganizationTemplate()} + /> + {templateApplied ?

+ {i18n.catalog["enterpriseCore.setup.profile.appliedNotice"]} +

: null} + void createAccount()} + onCreatePeriod={() => void createPeriod()} + /> : null} {journeyStep === "operating_links" ? ( - void saveWorkingUnit()} - onCostCenterChange={setCostCenterId} - onPosTerminalChange={setPosTerminalId} - onSave={() => void saveContext()} - /> + void saveWorkingUnit()} + onCostCenterChange={setCostCenterId} + onPosTerminalChange={setPosTerminalId} + onSave={() => void saveContext()} + /> ) : null} {journeyStep === "optional_capabilities" ? ( - void saveModuleSelection()} - onActivate={() => void activateSelectedModules()} - /> + void saveModuleSelection()} + onActivate={() => void activateSelectedModules()} + /> ) : null}
); diff --git a/frontend/app/setup/types.ts b/frontend/app/setup/types.ts index 4ed3c0b7..d0f420a7 100644 --- a/frontend/app/setup/types.ts +++ b/frontend/app/setup/types.ts @@ -29,6 +29,42 @@ export type Onboarding = { }; }; +export type OrganizationSize = "micro" | "small" | "medium" | "enterprise"; + +export type OrganizationProfile = { + template_key: string; + industry: string; + organization_size: OrganizationSize; + company_name: string; + company_code: string; + country_code: string; + currency_id: number; + primary_site_name?: string; + primary_site_code?: string; + factory_calendar_id?: number | null; + language?: "ar-SA" | "en-US"; + inventory_enabled: boolean; + applied_at?: string | null; + created_nodes?: Record; +}; + +export type OrganizationTemplate = { + key: string; + industry: string; + requires_inventory: boolean; + label_ar: string; + label_en: string; + description_ar: string; + generated_types: string[]; +}; + +export type OrganizationTemplateState = { + profile: OrganizationProfile | null; + templates: OrganizationTemplate[]; + is_applied: boolean; + can_apply: boolean; +}; + export type Readiness = { ready: boolean; context?: { @@ -79,6 +115,7 @@ export type SetupState = { active_module_keys: string[]; pending_module_keys: string[]; modules: SetupModule[]; + organization_template?: OrganizationTemplateState; }; export type SelectOption = { diff --git a/frontend/components/ui/Input.tsx b/frontend/components/ui/Input.tsx index 2d616ab0..934a2f89 100644 --- a/frontend/components/ui/Input.tsx +++ b/frontend/components/ui/Input.tsx @@ -1,9 +1,9 @@ "use client"; import { catalogMessage } from "@/lib/i18n"; -import { useState, useRef, forwardRef } from "react"; +import { forwardRef, useState } from "react"; import { Icon, IconName } from "@/lib/icons"; -import { isArabic as checkIsArabic } from "@/lib/utils"; +import { getTextDirection } from "@/lib/utils"; export interface InputProps extends React.InputHTMLAttributes { icon?: IconName; @@ -11,6 +11,10 @@ export interface InputProps extends React.InputHTMLAttributes containerClassName?: string; } +function valueAsText(value: React.InputHTMLAttributes["value"]): string { + return typeof value === "string" || typeof value === "number" ? String(value) : ""; +} + export const Input = forwardRef(({ className = "", containerClassName = "", @@ -19,86 +23,54 @@ export const Input = forwardRef(({ value, onChange, type = "text", + dir, + style, ...props }, ref) => { - const [isArabic, setIsArabic] = useState(false); - const containerRef = useRef(null); + const [uncontrolledValue, setUncontrolledValue] = useState(""); + const explicitDirection = dir === "rtl" || dir === "ltr" ? dir : null; + const textValue = value === undefined ? uncontrolledValue : valueAsText(value); + const textDirection = explicitDirection ?? getTextDirection(textValue); - // Detect text direction based on input - const handleInputChange = (e: React.ChangeEvent) => { - const val = e.target.value; - // Check if starts with Arabic char - setIsArabic(checkIsArabic(val)); - - if (onChange) { - onChange(e); + const handleInputChange = (event: React.ChangeEvent) => { + if (value === undefined) { + setUncontrolledValue(event.target.value); } + onChange?.(event); }; - // Determine padding based on icon presence - // In LTR: Icon usually on Left. In RTL: Icon usually on Right. - // But Login page seems to force LTR and put icon on Left. - // We will stick to the generic design: Icon on "Start"? - // The previous code put icon at the bottom? No. - return ( -
+
- {icon && ( -
+ {icon ? ( +
- )} - {value && onClear && ( + + ) : null} + {valueAsText(value) && onClear ? ( - )} + ) : null}
); }); Input.displayName = "Input"; - - - diff --git a/frontend/components/ui/SearchableSelect.tsx b/frontend/components/ui/SearchableSelect.tsx index dca3a95d..1c7251e6 100644 --- a/frontend/components/ui/SearchableSelect.tsx +++ b/frontend/components/ui/SearchableSelect.tsx @@ -28,7 +28,6 @@ interface SearchableSelectProps { renderOption?: (option: SelectOption) => React.ReactNode; filterOption?: (option: SelectOption, searchTerm: string) => boolean; /** Automatically focus this input on mount */ - /** Automatically focus this input on mount */ autoFocus?: boolean; /** Called after a barcode exact-match or Enter-key auto-select so the parent can advance focus */ onAutoSelect?: () => void; @@ -191,7 +190,7 @@ export function SearchableSelect({ const textDirection = getTextDirection(displayValue); return ( -
+
{ label?: string; @@ -13,6 +13,10 @@ export interface TextareaProps extends React.TextareaHTMLAttributes["value"]): string { + return typeof value === "string" || typeof value === "number" ? String(value) : ""; +} + export const Textarea = forwardRef(({ className = "", containerClassName = "", @@ -23,89 +27,58 @@ export const Textarea = forwardRef(({ onClear, value, onChange, + dir, + style, ...props }, ref) => { - const [isArabic, setIsArabic] = useState(false); - const containerRef = useRef(null); + const [uncontrolledValue, setUncontrolledValue] = useState(""); + const explicitDirection = dir === "rtl" || dir === "ltr" ? dir : null; + const textValue = value === undefined ? uncontrolledValue : valueAsText(value); + const textDirection = explicitDirection ?? getTextDirection(textValue); - // Detect text direction based on input - const handleInputChange = (e: React.ChangeEvent) => { - const val = e.target.value; - setIsArabic(checkIsArabic(val)); - - if (onChange) { - onChange(e); + const handleInputChange = (event: React.ChangeEvent) => { + if (value === undefined) { + setUncontrolledValue(event.target.value); } + onChange?.(event); }; return ( -
- {label && } - -
+
+ {label ? : null} +