+
{{ t('system_detail.system_registered') }}
@@ -755,7 +757,7 @@ const diffTypeFilterModel = computed({
-
+
{{ formatGroupDate(systemDetailState.data.created_at.slice(0, 10)) }}
@@ -764,7 +766,7 @@ const diffTypeFilterModel = computed
({
style="left: 139px; top: 7px"
>
-
+
{{ t('system_detail.system_created') }}
diff --git a/frontend/src/components/systems/SystemNetworkCard.vue b/frontend/src/components/systems/SystemNetworkCard.vue
index 1152c345f..921e3703d 100644
--- a/frontend/src/components/systems/SystemNetworkCard.vue
+++ b/frontend/src/components/systems/SystemNetworkCard.vue
@@ -138,7 +138,7 @@ const getNetworkRoleForegroundStyle = (role: string | undefined) => {
{{ iface.name }}
-
+
{{ iface?.type || '-' }}
•
@@ -146,11 +146,14 @@ const getNetworkRoleForegroundStyle = (role: string | undefined) => {
-
+
{{ getIpAddressWithCidr(iface) }}
-
+
GW: {{ iface.props?.gateway }}
@@ -161,7 +164,7 @@ const getNetworkRoleForegroundStyle = (role: string | undefined) => {
{{ $t('system_detail.dns_servers') }}
-
+
{{ dnsServers.join(', ') }}
-
diff --git a/frontend/src/components/users/CreateOrEditUserDrawer.vue b/frontend/src/components/users/CreateOrEditUserDrawer.vue
index d102e879b..e9c0a7fba 100644
--- a/frontend/src/components/users/CreateOrEditUserDrawer.vue
+++ b/frontend/src/components/users/CreateOrEditUserDrawer.vue
@@ -12,6 +12,7 @@ import {
focusElement,
NeCombobox,
type NeComboboxOption,
+ NeFormItemLabel,
} from '@nethesis/vue-components'
import { computed, ref, useTemplateRef, watch, type Ref, type ShallowRef } from 'vue'
import {
@@ -38,6 +39,7 @@ import { normalize } from '@/lib/common'
import { organizationsQuery } from '@/queries/organizations/organizations'
import { userRolesQuery } from '@/queries/users/userRoles'
import { USER_FILTERS_KEY } from '@/lib/users/userFilters'
+import { combinePhoneParts, countryCodeComboOptions, parsePhoneForForm } from '@/lib/phone'
const { isShown = false, currentUser = undefined } = defineProps<{
isShown: boolean
@@ -138,6 +140,7 @@ const userRoles: Ref = ref([])
const userRoleIdsRef = useTemplateRef('userRoleIdsRef')
const phone = ref('')
const phoneRef = useTemplateRef('phoneRef')
+const countryCode = ref('')
const validationIssues = ref>({})
const fieldRefs: Record>> = {
@@ -187,15 +190,25 @@ watch(
// editing user
email.value = currentUser.email
name.value = currentUser.name
- phone.value = currentUser.phone || ''
organizationId.value = currentUser.organization?.logto_id || ''
userRoles.value = mapUserRoles()
+
+ // Parse phone number to extract country code and local part
+ if (currentUser.phone) {
+ const parsed = parsePhoneForForm(currentUser.phone)
+ countryCode.value = parsed.countryCode
+ phone.value = parsed.phone
+ } else {
+ countryCode.value = 'it'
+ phone.value = ''
+ }
} else {
// creating user, reset form to defaults
email.value = ''
name.value = ''
organizationId.value = ''
userRoles.value = []
+ countryCode.value = 'it'
phone.value = ''
}
}
@@ -298,7 +311,7 @@ async function saveUser() {
name: name.value,
user_role_ids: userRoles.value.map((role) => role.id),
organization_id: organizationId.value,
- phone: phone.value.replace(/[\+\s\.\-]/g, ''), // remove formatting characters from phone number
+ phone: combinePhoneParts(countryCode.value, phone.value),
custom_data: {},
}
@@ -416,16 +429,37 @@ function getEmailInvalidMessage(): string {
:user-input-label="t('ne_combobox.user_input_label')"
/>
-
+
+
+ {{ $t('users.phone_number') }}
+ {{ $t('common.optional') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('import.users.import_description') }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $t('import.users.import_existing_users_tooltip') }}
+
+
+
+
+
+
+
+
{{ $t('import.import_summary') }}
+
+ {{ $t('import.users.import_summary_detected', { count: validationResult.total_rows }) }}
+
+
+ -
+ {{ $t('import.import_summary_valid', { count: validationResult.valid_rows }) }}
+
+ -
+ {{
+ $t('import.import_summary_errors', {
+ count: validationResult.error_rows + validationResult.ambiguous_rows,
+ })
+ }}
+
+ -
+ {{
+ existingUsersOption === 'skip'
+ ? $t('import.users.import_summary_warnings_skip', {
+ count: validationResult.warning_rows,
+ })
+ : $t('import.users.import_summary_warnings_update', {
+ count: validationResult.warning_rows,
+ })
+ }}
+
+
+
+
+
+
+
+
+ -
+ {{ errorSummaryText(row) }}
+
+
+
+
+
+
+
+
+
+
+ -
+ {{ errorSummaryText(row) }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/src/components/users/ImportUsersPreviewTable.vue b/frontend/src/components/users/ImportUsersPreviewTable.vue
new file mode 100644
index 000000000..679a5f676
--- /dev/null
+++ b/frontend/src/components/users/ImportUsersPreviewTable.vue
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+ {{ $t('import.import_file_preview') }}
+
+
+ {{ $t('import.import_file_preview_description', { count: PREVIEW_ROWS }) }}
+
+
+
+
+ {{ $t('users.email') }}
+ {{ $t('users.name') }}
+ {{ $t('users.phone_number') }}
+ {{ $t('users.organization') }}
+ {{ $t('users.roles') }}
+
+
+
+
+ {{ rowField(row, 'email') }}
+
+
+ {{ rowField(row, 'name') }}
+
+
+ {{ rowField(row, 'phone') }}
+
+
+ {{ rowField(row, 'company_name') }}
+
+
+ {{ rowField(row, 'roles') }}
+
+
+
+
+
+
diff --git a/frontend/src/components/users/UsersTable.vue b/frontend/src/components/users/UsersTable.vue
index aa4eebe0f..7d10d773a 100644
--- a/frontend/src/components/users/UsersTable.vue
+++ b/frontend/src/components/users/UsersTable.vue
@@ -448,7 +448,7 @@ const onClosePasswordChangedModal = () => {
:sort-key="sortBy"
:sort-descending="sortDescending"
:aria-label="$t('users.title')"
- card-breakpoint="xl"
+ card-breakpoint="2xl"
:loading="state.status === 'pending'"
:skeleton-columns="5"
:skeleton-rows="7"
@@ -486,7 +486,7 @@ const onClosePasswordChangedModal = () => {
{{ item.email }}
@@ -561,7 +561,7 @@ const onClosePasswordChangedModal = () => {
-
+
diff --git a/frontend/src/i18n/en/translation.json b/frontend/src/i18n/en/translation.json
index 6e94058d0..1f319255d 100644
--- a/frontend/src/i18n/en/translation.json
+++ b/frontend/src/i18n/en/translation.json
@@ -50,7 +50,7 @@
"reactivate": "Reactivate",
"notes": "Notes",
"show_notes": "Show notes",
- "plus_n_more": "+{num} more",
+ "plus_n_more": "+{count} more | +{count} more",
"node_id": "Node {id}"
},
"shell": {
@@ -114,7 +114,9 @@
"export_distributors_to_pdf": "Export distributors to PDF",
"export_distributors_to_csv": "Export distributors to CSV",
"total_customers": "Total Customers",
- "total_systems": "Total Systems"
+ "total_systems": "Total Systems",
+ "import_distributors": "Import distributors",
+ "distributors_imported": "Distributors imported"
},
"distributor_detail": {
"title": "Distributor detail",
@@ -150,6 +152,8 @@
"no_reseller": "No resellers",
"export_resellers_to_pdf": "Export resellers to PDF",
"export_resellers_to_csv": "Export resellers to CSV",
+ "import_resellers": "Import resellers",
+ "resellers_imported": "Resellers imported",
"total_systems": "Total Systems"
},
"customers": {
@@ -173,7 +177,10 @@
"archive_customer_confirmation": "Are you sure you want to archive customer {name}? All users and systems associated with this company will also be archived.",
"no_customer": "No customers",
"export_customers_to_pdf": "Export customers to PDF",
- "export_customers_to_csv": "Export customers to CSV"
+ "export_customers_to_csv": "Export customers to CSV",
+ "import_customers": "Import customers",
+ "customers_imported": "Customers imported",
+ "total_systems": "Total Systems"
},
"organizations": {
"name": "Company name",
@@ -184,8 +191,11 @@
"main_contact": "Main contact",
"owner": "Owner",
"distributor": "Distributor",
+ "distributors_lc": "distributor | distributors",
"reseller": "Reseller",
+ "resellers_lc": "reseller | resellers",
"customer": "Customer",
+ "customers_lc": "customer | customers",
"name_cannot_be_empty": "Company name is required",
"custom_data_vat_cannot_be_empty": "VAT number is required",
"custom_data_vat_already_exists": "A company with this VAT number already exists",
@@ -215,6 +225,79 @@
"organization_restored_successfully": "{name} restored successfully",
"restore_organization_confirmation": "Are you sure you want to restore {name}?"
},
+ "import": {
+ "download_the_template": "Download the template",
+ "download_the_template_lc": "download the template",
+ "import_to_get_started": "{link} to get started",
+ "import_file_label": "CSV file",
+ "import_no_file_selected": "Please select a CSV file",
+ "import_file_must_be_csv": "The file must be a CSV",
+ "import_validation_failed": "Validation failed",
+ "import_confirm_failed": "Cannot complete import",
+ "import_result_created": "created: {count} | created: {count}",
+ "import_result_updated": "updated: {count} | updated: {count}",
+ "import_result_skipped": "skipped: {count} | skipped: {count}",
+ "import_result_failed": "failed: {count} | failed: {count}",
+ "import_summary": "Import summary",
+ "import_summary_valid": "{count} valid | {count} valid",
+ "import_summary_errors": "{count} with errors, will be skipped | {count} with errors, will be skipped",
+ "import_rows_with_errors": "{count} row has errors, will be skipped: | {count} rows have errors, will be skipped:",
+ "import_file_preview": "File preview",
+ "import_file_preview_description": "Only the first {count} rows are shown",
+ "import_validation_error_message": "Make sure the CSV file is correctly formatted or {link}.",
+ "import_row_and_name": "Row {row_number}: {name}",
+ "import_row_and_message": "Row {row_number} ({name}): {message}",
+ "import_error_email_required": "Email is required",
+ "import_error_email_invalid_format": "Invalid email: {0}",
+ "import_error_email_duplicate_in_csv": "Duplicate email in CSV: {0}",
+ "import_error_email_archived": "User exists but is archived. You can resume it if necessary.",
+ "import_error_phone_invalid_format": "Invalid phone number: {0}. Make sure it contains the country code, e.g. +39 for Italy",
+ "import_error_company_name_ambiguous": "Ambiguous company name: {0}",
+ "import_error_company_name_not_found": "Company not found: {0}",
+ "import_error_phone_already_used": "Phone number {0} already used by another user: {1}",
+ "import_error_roles_unknown": "Unknown roles: {0}",
+ "import_error_name_required": "Name is required",
+ "import_error_name_too_long": "Name is too long: {0}",
+ "import_error_company_name_required": "Company name is required",
+ "import_error_company_name_too_long": "Company name is too long: {0}",
+ "import_error_name_duplicate_in_csv": "Duplicate in CSV at line {1}",
+ "import_error_vat_number_required": "VAT number is required",
+ "import_error_vat_number_invalid_format": "Invalid VAT number: {0}",
+ "import_error_vat_number_too_long": "VAT number is too long: {0}",
+ "import_error_language_invalid_value": "Invalid language: {0}",
+ "import_error_language_invalid_format": "Invalid language: {0}",
+ "import_error_vat_number_archived": "Company exists but is archived. You can resume it if necessary.",
+ "users": {
+ "import_users": "Import users",
+ "import_description": "Upload a CSV file to create multiple users at once. Each row represents one user. Rows with errors will be skipped automatically.",
+ "import_confirm": "Import {count} user | Import {count} users",
+ "users_imported": "Users imported",
+ "import_summary_detected": "{count} user detected in the selected file: | {count} users detected in the selected file:",
+ "import_summary_warnings_skip": "{count} existing user — will be skipped | {count} existing users — will be skipped",
+ "import_summary_warnings_update": "{count} existing user —will be updated | {count} existing users — will be updated",
+ "import_type_overwrite": "Update existing users",
+ "import_type_skip": "Skip existing users",
+ "import_existing_users_label": "Existing users",
+ "import_existing_users_tooltip": "Existing users can be updated or skipped; users are matched by email",
+ "import_rows_with_warnings_skip": "{count} existing users — will be skipped",
+ "import_rows_with_warnings_update": "{count} existing user — will be updated | {count} existing users — will be updated"
+ },
+ "organizations": {
+ "import_description": "Upload a CSV file to create multiple {entities} at once. Each row represents one {entity}. Rows with errors will be skipped automatically.",
+ "import_num_distributors": "Import {count} distributor | Import {count} distributors",
+ "import_num_resellers": "Import {count} reseller | Import {count} resellers",
+ "import_num_customers": "Import {count} customer | Import {count} customers",
+ "import_type_skip": "Skip existing companies",
+ "import_type_overwrite": "Update existing companies",
+ "import_existing_label": "Existing companies",
+ "import_existing_tooltip": "Existing companies can be updated or skipped; companies are matched by VAT number",
+ "import_summary_detected": "{count} company detected in the selected file: | {count} companies detected in the selected file:",
+ "import_summary_warnings_skip": "{count} existing company — will be skipped | {count} existing companies — will be skipped",
+ "import_summary_warnings_update": "{count} existing company — will be updated | {count} existing companies — will be updated",
+ "import_rows_with_warnings_skip": "{count} existing company — will be skipped | {count} existing companies — will be skipped",
+ "import_rows_with_warnings_update": "{count} existing company — will be updated | {count} existing companies — will be updated"
+ }
+ },
"users": {
"title": "Users",
"page_description": "List of users authorized to access {productName}, with associated companies and assigned roles.",
@@ -276,6 +359,7 @@
"cannot_reset_password": "Cannot reset password",
"phone_invalid_format": "Invalid phone number",
"phone_already_exists": "A user with this phone number already exists",
+ "phone_plus_not_allowed": "Cannot contain '+'",
"the_password_has_been_reset": "The password has been reset",
"credentials_updated_description": "The credentials for user {name} have been updated",
"copy_credentials": "Copy credentials",
@@ -468,7 +552,7 @@
"cannot_create_system": "Cannot create system",
"cannot_save_system": "Cannot save system",
"save_system": "Save system",
- "no_organizations": "No organizations",
+ "no_organizations": "No companies",
"archive_system": "Archive system",
"archive_system_confirmation": "Are you sure you want to archive system {name}? It will no longer receive updates and you will not be able to request support for it.",
"system_archived": "System archived",
@@ -619,10 +703,10 @@
"application": "Application",
"organization_assigned": "Application assigned to company",
"organization_assigned_description": "The application {application} has been assigned to company {organization}",
- "cannot_assign_organization": "Cannot assign organization",
- "no_organization": "No organization",
- "num_unassigned": "{num} unassigned",
- "num_applications_not_assigned": "{num} application is not assigned to any organization. | {num} applications are not assigned to any organization.",
+ "cannot_assign_organization": "Cannot assign company",
+ "no_organization": "No company",
+ "num_unassigned": "{count} unassigned | {count} unassigned",
+ "num_applications_not_assigned": "{count} application is not assigned to any company. | {count} applications are not assigned to any company.",
"show_unassigned": "Show unassigned",
"dont_show_again": "Don't show again",
"add_notes": "Add notes",
@@ -729,7 +813,7 @@
"alpha_notice": "ALPHA \u2013 This section has no final design. UI may change.",
"cannot_retrieve_alerts": "Cannot retrieve alerts",
"no_alerts": "No alerts found",
- "no_alerts_description": "There are currently no active alerts for this organization",
+ "no_alerts_description": "There are currently no active alerts for this company",
"no_alerts_found": "No alerts match the current filters",
"active_alerts": "Active Alerts",
"alert_history": "Alert History",
@@ -758,7 +842,7 @@
"no_alert_history": "No alert history",
"no_alert_history_description": "No resolved alerts found for this system",
"no_alert_history_found": "No alert history matches the current filters",
- "config_page_description": "View and manage the alerting configuration for your organization",
+ "config_page_description": "View and manage the alerting configuration for your company",
"cannot_retrieve_config": "Cannot retrieve alerting configuration",
"config_json": "Configuration (JSON)",
"config_yaml": "Configuration (YAML)",
@@ -772,7 +856,7 @@
"per_severity_overrides": "Per-severity overrides",
"per_system_overrides": "Per-system overrides",
"no_config": "No configuration found",
- "no_config_description": "Alerting is not configured for this organization",
+ "no_config_description": "Alerting is not configured for this company",
"edit_config": "Edit configuration",
"save_config": "Save configuration",
"disable_alerts": "Disable all alerts",
@@ -818,17 +902,17 @@
"cannot_delete_silence": "Cannot delete silence",
"disable_silence": "Disable silence",
"disable_silence_confirmation": "Disable the silence for the alert {name}?",
- "disable_silence_notice": "This removes {count} currently suppressing this alert.",
+ "disable_silence_notice": "This removes {count} silence currently suppressing this alert. | This removes {count} silences currently suppressing this alert.",
"cannot_disable_silence": "Cannot disable silence",
"silence_disabled": "Silence disabled",
"silence_disabled_description": "The silence has been disabled for {name}.",
"history_tab_description": "History of resolved alerts for this system",
"cannot_retrieve_system_alerts": "Cannot retrieve active alerts for this system",
- "cannot_retrieve_organizations": "Cannot retrieve organizations",
+ "cannot_retrieve_organizations": "Cannot retrieve companies",
"alerting_title": "Alerting",
- "alerting_page_description": "View active alerts and manage alerting configuration for any organization",
+ "alerting_page_description": "View active alerts and manage alerting configuration for any company",
"view_alert_details": "View alert details",
- "select_organization": "Organization",
- "no_organizations_description": "There are no organizations available for alerting"
+ "select_organization": "Company",
+ "no_organizations_description": "There are no companies available for alerting"
}
}
diff --git a/frontend/src/lib/organizations/customers.ts b/frontend/src/lib/organizations/customers.ts
index 15b30d8e1..20219a324 100644
--- a/frontend/src/lib/organizations/customers.ts
+++ b/frontend/src/lib/organizations/customers.ts
@@ -6,6 +6,7 @@ import { API_URL } from '../config'
import { useLoginStore } from '@/stores/login'
import * as v from 'valibot'
import { type Pagination } from '../common'
+import type { ImportValidationResult, ImportConfirmResult } from './organizations'
export const CUSTOMERS_KEY = 'customers'
export const CUSTOMERS_TOTAL_KEY = 'customersTotal'
@@ -250,3 +251,48 @@ export const getExport = (
})
.then((res) => res.data)
}
+
+// ============================================================
+// Import API functions
+// ============================================================
+
+export const getImportTemplate = () => {
+ const loginStore = useLoginStore()
+ return axios
+ .get(`${API_URL}/customers/import/template`, {
+ headers: { Authorization: `Bearer ${loginStore.jwtToken}` },
+ responseType: 'blob',
+ })
+ .then((res) => res.data)
+}
+
+export const validateCustomersImport = (file: File) => {
+ const loginStore = useLoginStore()
+ const formData = new FormData()
+ formData.append('file', file)
+ return axios
+ .post<{ code: number; message: string; data: ImportValidationResult }>(
+ `${API_URL}/customers/import/validate`,
+ formData,
+ {
+ headers: {
+ Authorization: `Bearer ${loginStore.jwtToken}`,
+ 'Content-Type': null,
+ },
+ },
+ )
+ .then((res) => res.data.data)
+}
+
+export const confirmCustomersImport = (importId: string, override: boolean) => {
+ const loginStore = useLoginStore()
+ return axios
+ .post<{ code: number; message: string; data: ImportConfirmResult }>(
+ `${API_URL}/customers/import/confirm`,
+ { import_id: importId, override },
+ {
+ headers: { Authorization: `Bearer ${loginStore.jwtToken}` },
+ },
+ )
+ .then((res) => res.data.data)
+}
diff --git a/frontend/src/lib/organizations/distributors.ts b/frontend/src/lib/organizations/distributors.ts
index 43a89a111..2b34ab7d1 100644
--- a/frontend/src/lib/organizations/distributors.ts
+++ b/frontend/src/lib/organizations/distributors.ts
@@ -6,6 +6,7 @@ import { API_URL } from '../config'
import { useLoginStore } from '@/stores/login'
import * as v from 'valibot'
import { type Pagination } from '../common'
+import type { ImportValidationResult, ImportConfirmResult } from './organizations'
export const DISTRIBUTORS_KEY = 'distributors'
export const DISTRIBUTORS_TOTAL_KEY = 'distributorsTotal'
@@ -250,3 +251,48 @@ export const getExport = (
})
.then((res) => res.data)
}
+
+// ============================================================
+// Import API functions
+// ============================================================
+
+export const getImportTemplate = () => {
+ const loginStore = useLoginStore()
+ return axios
+ .get(`${API_URL}/distributors/import/template`, {
+ headers: { Authorization: `Bearer ${loginStore.jwtToken}` },
+ responseType: 'blob',
+ })
+ .then((res) => res.data)
+}
+
+export const validateDistributorsImport = (file: File) => {
+ const loginStore = useLoginStore()
+ const formData = new FormData()
+ formData.append('file', file)
+ return axios
+ .post<{ code: number; message: string; data: ImportValidationResult }>(
+ `${API_URL}/distributors/import/validate`,
+ formData,
+ {
+ headers: {
+ Authorization: `Bearer ${loginStore.jwtToken}`,
+ 'Content-Type': null,
+ },
+ },
+ )
+ .then((res) => res.data.data)
+}
+
+export const confirmDistributorsImport = (importId: string, override: boolean) => {
+ const loginStore = useLoginStore()
+ return axios
+ .post<{ code: number; message: string; data: ImportConfirmResult }>(
+ `${API_URL}/distributors/import/confirm`,
+ { import_id: importId, override },
+ {
+ headers: { Authorization: `Bearer ${loginStore.jwtToken}` },
+ },
+ )
+ .then((res) => res.data.data)
+}
diff --git a/frontend/src/lib/organizations/organizations.ts b/frontend/src/lib/organizations/organizations.ts
index 73e19d074..6c095f441 100644
--- a/frontend/src/lib/organizations/organizations.ts
+++ b/frontend/src/lib/organizations/organizations.ts
@@ -42,3 +42,53 @@ export function getOrganizationIcon(orgType: string) {
return faQuestion
}
}
+
+// ============================================================
+// Common Import Types (used across all entities)
+// ============================================================
+
+export interface ImportFieldWarning {
+ field: string
+ message: string
+ value: string
+}
+
+export interface ImportFieldError {
+ field: string
+ message: string
+ values: string[]
+}
+
+export interface ImportRow {
+ row_number: number
+ status: 'valid' | 'error' | 'warning'
+ data: Record
+ errors?: ImportFieldError[]
+ warnings?: ImportFieldWarning[]
+}
+
+export interface ImportValidationResult {
+ import_id: string
+ total_rows: number
+ valid_rows: number
+ error_rows: number
+ warning_rows: number
+ ambiguous_rows: number
+ rows: ImportRow[]
+}
+
+export interface ImportResultRow {
+ row_number: number
+ status: 'created' | 'updated' | 'skipped' | 'failed'
+ id?: string
+ reason?: string
+ error?: string
+}
+
+export interface ImportConfirmResult {
+ created: number
+ updated: number
+ skipped: number
+ failed: number
+ results: ImportResultRow[]
+}
diff --git a/frontend/src/lib/organizations/resellers.ts b/frontend/src/lib/organizations/resellers.ts
index 64f00a2cc..3d5f6a6fb 100644
--- a/frontend/src/lib/organizations/resellers.ts
+++ b/frontend/src/lib/organizations/resellers.ts
@@ -6,6 +6,7 @@ import { API_URL } from '../config'
import { useLoginStore } from '@/stores/login'
import * as v from 'valibot'
import { type Pagination } from '../common'
+import type { ImportValidationResult, ImportConfirmResult } from './organizations'
export const RESELLERS_KEY = 'resellers'
export const RESELLERS_TOTAL_KEY = 'resellersTotal'
@@ -250,3 +251,48 @@ export const getExport = (
})
.then((res) => res.data)
}
+
+// ============================================================
+// Import API functions
+// ============================================================
+
+export const getImportTemplate = () => {
+ const loginStore = useLoginStore()
+ return axios
+ .get(`${API_URL}/resellers/import/template`, {
+ headers: { Authorization: `Bearer ${loginStore.jwtToken}` },
+ responseType: 'blob',
+ })
+ .then((res) => res.data)
+}
+
+export const validateResellersImport = (file: File) => {
+ const loginStore = useLoginStore()
+ const formData = new FormData()
+ formData.append('file', file)
+ return axios
+ .post<{ code: number; message: string; data: ImportValidationResult }>(
+ `${API_URL}/resellers/import/validate`,
+ formData,
+ {
+ headers: {
+ Authorization: `Bearer ${loginStore.jwtToken}`,
+ 'Content-Type': null,
+ },
+ },
+ )
+ .then((res) => res.data.data)
+}
+
+export const confirmResellersImport = (importId: string, override: boolean) => {
+ const loginStore = useLoginStore()
+ return axios
+ .post<{ code: number; message: string; data: ImportConfirmResult }>(
+ `${API_URL}/resellers/import/confirm`,
+ { import_id: importId, override },
+ {
+ headers: { Authorization: `Bearer ${loginStore.jwtToken}` },
+ },
+ )
+ .then((res) => res.data.data)
+}
diff --git a/frontend/src/lib/phone.ts b/frontend/src/lib/phone.ts
new file mode 100644
index 000000000..006da0545
--- /dev/null
+++ b/frontend/src/lib/phone.ts
@@ -0,0 +1,357 @@
+// Copyright (C) 2026 Nethesis S.r.l.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+import { parsePhoneNumberFromString } from 'libphonenumber-js'
+import type { NeComboboxOption } from '@nethesis/vue-components'
+
+// The backend stores phone numbers as digits-only (E.164 without the leading
+// `+`), e.g. "393330001113". This helper renders a human-readable version for
+// display, e.g. "+39 333 000 1113".
+//
+// Inputs we need to handle:
+// - "" / null / undefined → return ""
+// - "393330001113" → "+39 333 000 1113"
+// - "+39 333 0001113" → "+39 333 000 1113" (already-formatted legacy values
+// still in the DB get re-parsed and re-formatted)
+// - non-parseable values → returned untouched as a fallback
+export function formatPhoneForDisplay(raw: string | null | undefined): string {
+ if (!raw) {
+ return ''
+ }
+ // libphonenumber-js needs a leading "+" to detect the country from the digits.
+ // If the raw value already has it, parse as-is; otherwise prepend.
+ const candidate = raw.startsWith('+') ? raw : `+${raw}`
+ const parsed = parsePhoneNumberFromString(candidate)
+ return parsed?.formatInternational() ?? raw
+}
+
+// Parse a phone number and extract the country code (iso2) and local part.
+// Returns { countryIso2, localPart } or null if parsing fails.
+// Inputs we handle:
+// - "" / null / undefined → return null
+// - "393330001113" → { countryIso2: "it", localPart: "333 000 1113" }
+// - "+39 333 0001113" → { countryIso2: "it", localPart: "333 000 1113" }
+export function parsePhoneNumber(
+ raw: string | null | undefined,
+): { countryIso2: string; localPart: string } | null {
+ if (!raw) {
+ return null
+ }
+
+ // libphonenumber-js needs a leading "+" to detect the country from the digits.
+ // If the raw value already has it, parse as-is; otherwise prepend.
+ const candidate = raw.startsWith('+') ? raw : `+${raw}`
+ const parsed = parsePhoneNumberFromString(candidate)
+
+ if (!parsed || !parsed.country) {
+ return null
+ }
+
+ // Get the country code in lowercase (e.g., "IT" → "it")
+ const countryIso2 = parsed.country.toLowerCase()
+ // Format the local part (e.g., "333 000 1113")
+ const localPart = parsed.formatNational()
+
+ return { countryIso2, localPart }
+}
+
+// Combine country code ISO2 and phone local part into a backend-compatible format.
+// Returns the phone number as digits-only (E.164 without the leading `+`),
+// e.g., "it" + "333 000 1113" → "393330001113"
+// If countryCode or localPart is empty, returns empty string.
+export function combinePhoneParts(countryIso2: string, localPart: string): string {
+ if (!countryIso2 || !localPart) {
+ return ''
+ }
+
+ // Find the country code from the countries array
+ const country = countries.find((c) => c.iso2 === countryIso2)
+ if (!country) {
+ return ''
+ }
+
+ // Remove all non-digit characters from the local part, but keep the leading "+"
+ // if present (which indicates the user incorrectly entered the country prefix).
+ // This lets us detect and warn about malformed input.
+ const digitsOnly = localPart.replace(/[^+\d]/g, '')
+
+ // Concatenate country code + local part digits
+ return `${country.country_code}${digitsOnly}`
+}
+
+export const countries = [
+ { iso2: 'af', country_name: 'Afghanistan', country_code: '93', flag: '🇦🇫' },
+ { iso2: 'ax', country_name: 'Åland Islands', country_code: '358', flag: '🇦🇽' },
+ { iso2: 'al', country_name: 'Albania', country_code: '355', flag: '🇦🇱' },
+ { iso2: 'dz', country_name: 'Algeria', country_code: '213', flag: '🇩🇿' },
+ { iso2: 'as', country_name: 'American Samoa', country_code: '1', flag: '🇦🇸' },
+ { iso2: 'ad', country_name: 'Andorra', country_code: '376', flag: '🇦🇩' },
+ { iso2: 'ao', country_name: 'Angola', country_code: '244', flag: '🇦🇴' },
+ { iso2: 'ai', country_name: 'Anguilla', country_code: '1', flag: '🇦🇮' },
+ { iso2: 'ag', country_name: 'Antigua and Barbuda', country_code: '1', flag: '🇦🇬' },
+ { iso2: 'ar', country_name: 'Argentina', country_code: '54', flag: '🇦🇷' },
+ { iso2: 'am', country_name: 'Armenia', country_code: '374', flag: '🇦🇲' },
+ { iso2: 'aw', country_name: 'Aruba', country_code: '297', flag: '🇦🇼' },
+ { iso2: 'ac', country_name: 'Ascension Island', country_code: '247', flag: '🇦🇨' },
+ { iso2: 'au', country_name: 'Australia', country_code: '61', flag: '🇦🇺' },
+ { iso2: 'at', country_name: 'Austria', country_code: '43', flag: '🇦🇹' },
+ { iso2: 'az', country_name: 'Azerbaijan', country_code: '994', flag: '🇦🇿' },
+ { iso2: 'bs', country_name: 'Bahamas', country_code: '1', flag: '🇧🇸' },
+ { iso2: 'bh', country_name: 'Bahrain', country_code: '973', flag: '🇧🇭' },
+ { iso2: 'bd', country_name: 'Bangladesh', country_code: '880', flag: '🇧🇩' },
+ { iso2: 'bb', country_name: 'Barbados', country_code: '1', flag: '🇧🇧' },
+ { iso2: 'by', country_name: 'Belarus', country_code: '375', flag: '🇧🇾' },
+ { iso2: 'be', country_name: 'Belgium', country_code: '32', flag: '🇧🇪' },
+ { iso2: 'bz', country_name: 'Belize', country_code: '501', flag: '🇧🇿' },
+ { iso2: 'bj', country_name: 'Benin', country_code: '229', flag: '🇧🇯' },
+ { iso2: 'bm', country_name: 'Bermuda', country_code: '1', flag: '🇧🇲' },
+ { iso2: 'bt', country_name: 'Bhutan', country_code: '975', flag: '🇧🇹' },
+ { iso2: 'bo', country_name: 'Bolivia', country_code: '591', flag: '🇧🇴' },
+ { iso2: 'ba', country_name: 'Bosnia and Herzegovina', country_code: '387', flag: '🇧🇦' },
+ { iso2: 'bw', country_name: 'Botswana', country_code: '267', flag: '🇧🇼' },
+ { iso2: 'br', country_name: 'Brazil', country_code: '55', flag: '🇧🇷' },
+ { iso2: 'io', country_name: 'British Indian Ocean Territory', country_code: '246', flag: '🇮🇴' },
+ { iso2: 'vg', country_name: 'British Virgin Islands', country_code: '1', flag: '🇻🇬' },
+ { iso2: 'bn', country_name: 'Brunei', country_code: '673', flag: '🇧🇳' },
+ { iso2: 'bg', country_name: 'Bulgaria', country_code: '359', flag: '🇧🇬' },
+ { iso2: 'bf', country_name: 'Burkina Faso', country_code: '226', flag: '🇧🇫' },
+ { iso2: 'bi', country_name: 'Burundi', country_code: '257', flag: '🇧🇮' },
+ { iso2: 'kh', country_name: 'Cambodia', country_code: '855', flag: '🇰🇭' },
+ { iso2: 'cm', country_name: 'Cameroon', country_code: '237', flag: '🇨🇲' },
+ { iso2: 'ca', country_name: 'Canada', country_code: '1', flag: '🇨🇦' },
+ { iso2: 'cv', country_name: 'Cape Verde', country_code: '238', flag: '🇨🇻' },
+ { iso2: 'bq', country_name: 'Caribbean Netherlands', country_code: '599', flag: '🇧🇶' },
+ { iso2: 'ky', country_name: 'Cayman Islands', country_code: '1', flag: '🇰🇾' },
+ { iso2: 'cf', country_name: 'Central African Republic', country_code: '236', flag: '🇨🇫' },
+ { iso2: 'td', country_name: 'Chad', country_code: '235', flag: '🇹🇩' },
+ { iso2: 'cl', country_name: 'Chile', country_code: '56', flag: '🇨🇱' },
+ { iso2: 'cn', country_name: 'China', country_code: '86', flag: '🇨🇳' },
+ { iso2: 'cx', country_name: 'Christmas Island', country_code: '61', flag: '🇨🇽' },
+ { iso2: 'cc', country_name: 'Cocos (Keeling) Islands', country_code: '61', flag: '🇨🇨' },
+ { iso2: 'co', country_name: 'Colombia', country_code: '57', flag: '🇨🇴' },
+ { iso2: 'km', country_name: 'Comoros', country_code: '269', flag: '🇰🇲' },
+ { iso2: 'cg', country_name: 'Congo (Brazzaville)', country_code: '242', flag: '🇨🇬' },
+ { iso2: 'cd', country_name: 'Congo (Kinshasa)', country_code: '243', flag: '🇨🇩' },
+ { iso2: 'ck', country_name: 'Cook Islands', country_code: '682', flag: '🇨🇰' },
+ { iso2: 'cr', country_name: 'Costa Rica', country_code: '506', flag: '🇨🇷' },
+ { iso2: 'ci', country_name: "Côte d'Ivoire", country_code: '225', flag: '🇨🇮' },
+ { iso2: 'hr', country_name: 'Croatia', country_code: '385', flag: '🇭🇷' },
+ { iso2: 'cu', country_name: 'Cuba', country_code: '53', flag: '🇨🇺' },
+ { iso2: 'cw', country_name: 'Curaçao', country_code: '599', flag: '🇨🇼' },
+ { iso2: 'cy', country_name: 'Cyprus', country_code: '357', flag: '🇨🇾' },
+ { iso2: 'cz', country_name: 'Czech Republic', country_code: '420', flag: '🇨🇿' },
+ { iso2: 'dk', country_name: 'Denmark', country_code: '45', flag: '🇩🇰' },
+ { iso2: 'dj', country_name: 'Djibouti', country_code: '253', flag: '🇩🇯' },
+ { iso2: 'dm', country_name: 'Dominica', country_code: '1', flag: '🇩🇲' },
+ { iso2: 'do', country_name: 'Dominican Republic', country_code: '1', flag: '🇩🇴' },
+ { iso2: 'ec', country_name: 'Ecuador', country_code: '593', flag: '🇪🇨' },
+ { iso2: 'eg', country_name: 'Egypt', country_code: '20', flag: '🇪🇬' },
+ { iso2: 'sv', country_name: 'El Salvador', country_code: '503', flag: '🇸🇻' },
+ { iso2: 'gq', country_name: 'Equatorial Guinea', country_code: '240', flag: '🇬🇶' },
+ { iso2: 'er', country_name: 'Eritrea', country_code: '291', flag: '🇪🇷' },
+ { iso2: 'ee', country_name: 'Estonia', country_code: '372', flag: '🇪🇪' },
+ { iso2: 'sz', country_name: 'Eswatini', country_code: '268', flag: '🇸🇿' },
+ { iso2: 'et', country_name: 'Ethiopia', country_code: '251', flag: '🇪🇹' },
+ { iso2: 'fk', country_name: 'Falkland Islands (Malvinas)', country_code: '500', flag: '🇫🇰' },
+ { iso2: 'fo', country_name: 'Faroe Islands', country_code: '298', flag: '🇫🇴' },
+ { iso2: 'fj', country_name: 'Fiji', country_code: '679', flag: '🇫🇯' },
+ { iso2: 'fi', country_name: 'Finland', country_code: '358', flag: '🇫🇮' },
+ { iso2: 'fr', country_name: 'France', country_code: '33', flag: '🇫🇷' },
+ { iso2: 'gf', country_name: 'French Guiana', country_code: '594', flag: '🇬🇫' },
+ { iso2: 'pf', country_name: 'French Polynesia', country_code: '689', flag: '🇵🇫' },
+ { iso2: 'ga', country_name: 'Gabon', country_code: '241', flag: '🇬🇦' },
+ { iso2: 'gm', country_name: 'Gambia', country_code: '220', flag: '🇬🇲' },
+ { iso2: 'ge', country_name: 'Georgia', country_code: '995', flag: '🇬🇪' },
+ { iso2: 'de', country_name: 'Germany', country_code: '49', flag: '🇩🇪' },
+ { iso2: 'gh', country_name: 'Ghana', country_code: '233', flag: '🇬🇭' },
+ { iso2: 'gi', country_name: 'Gibraltar', country_code: '350', flag: '🇬🇮' },
+ { iso2: 'gr', country_name: 'Greece', country_code: '30', flag: '🇬🇷' },
+ { iso2: 'gl', country_name: 'Greenland', country_code: '299', flag: '🇬🇱' },
+ { iso2: 'gd', country_name: 'Grenada', country_code: '1', flag: '🇬🇩' },
+ { iso2: 'gp', country_name: 'Guadeloupe', country_code: '590', flag: '🇬🇵' },
+ { iso2: 'gu', country_name: 'Guam', country_code: '1', flag: '🇬🇺' },
+ { iso2: 'gt', country_name: 'Guatemala', country_code: '502', flag: '🇬🇹' },
+ { iso2: 'gg', country_name: 'Guernsey', country_code: '44', flag: '🇬🇬' },
+ { iso2: 'gn', country_name: 'Guinea', country_code: '224', flag: '🇬🇳' },
+ { iso2: 'gw', country_name: 'Guinea-Bissau', country_code: '245', flag: '🇬🇼' },
+ { iso2: 'gy', country_name: 'Guyana', country_code: '592', flag: '🇬🇾' },
+ { iso2: 'ht', country_name: 'Haiti', country_code: '509', flag: '🇭🇹' },
+ { iso2: 'hn', country_name: 'Honduras', country_code: '504', flag: '🇭🇳' },
+ { iso2: 'hk', country_name: 'Hong Kong SAR China', country_code: '852', flag: '🇭🇰' },
+ { iso2: 'hu', country_name: 'Hungary', country_code: '36', flag: '🇭🇺' },
+ { iso2: 'is', country_name: 'Iceland', country_code: '354', flag: '🇮🇸' },
+ { iso2: 'in', country_name: 'India', country_code: '91', flag: '🇮🇳' },
+ { iso2: 'id', country_name: 'Indonesia', country_code: '62', flag: '🇮🇩' },
+ { iso2: 'ir', country_name: 'Iran', country_code: '98', flag: '🇮🇷' },
+ { iso2: 'iq', country_name: 'Iraq', country_code: '964', flag: '🇮🇶' },
+ { iso2: 'ie', country_name: 'Ireland', country_code: '353', flag: '🇮🇪' },
+ { iso2: 'im', country_name: 'Isle of Man', country_code: '44', flag: '🇮🇲' },
+ { iso2: 'il', country_name: 'Israel', country_code: '972', flag: '🇮🇱' },
+ { iso2: 'it', country_name: 'Italy', country_code: '39', flag: '🇮🇹' },
+ { iso2: 'jm', country_name: 'Jamaica', country_code: '1', flag: '🇯🇲' },
+ { iso2: 'jp', country_name: 'Japan', country_code: '81', flag: '🇯🇵' },
+ { iso2: 'je', country_name: 'Jersey', country_code: '44', flag: '🇯🇪' },
+ { iso2: 'jo', country_name: 'Jordan', country_code: '962', flag: '🇯🇴' },
+ { iso2: 'kz', country_name: 'Kazakhstan', country_code: '7', flag: '🇰🇿' },
+ { iso2: 'ke', country_name: 'Kenya', country_code: '254', flag: '🇰🇪' },
+ { iso2: 'ki', country_name: 'Kiribati', country_code: '686', flag: '🇰🇮' },
+ { iso2: 'xk', country_name: 'Kosovo', country_code: '383', flag: '🇽🇰' },
+ { iso2: 'kw', country_name: 'Kuwait', country_code: '965', flag: '🇰🇼' },
+ { iso2: 'kg', country_name: 'Kyrgyzstan', country_code: '996', flag: '🇰🇬' },
+ { iso2: 'la', country_name: 'Laos', country_code: '856', flag: '🇱🇦' },
+ { iso2: 'lv', country_name: 'Latvia', country_code: '371', flag: '🇱🇻' },
+ { iso2: 'lb', country_name: 'Lebanon', country_code: '961', flag: '🇱🇧' },
+ { iso2: 'ls', country_name: 'Lesotho', country_code: '266', flag: '🇱🇸' },
+ { iso2: 'lr', country_name: 'Liberia', country_code: '231', flag: '🇱🇷' },
+ { iso2: 'ly', country_name: 'Libya', country_code: '218', flag: '🇱🇾' },
+ { iso2: 'li', country_name: 'Liechtenstein', country_code: '423', flag: '🇱🇮' },
+ { iso2: 'lt', country_name: 'Lithuania', country_code: '370', flag: '🇱🇹' },
+ { iso2: 'lu', country_name: 'Luxembourg', country_code: '352', flag: '🇱🇺' },
+ { iso2: 'mo', country_name: 'Macao SAR China', country_code: '853', flag: '🇲🇴' },
+ { iso2: 'mg', country_name: 'Madagascar', country_code: '261', flag: '🇲🇬' },
+ { iso2: 'mw', country_name: 'Malawi', country_code: '265', flag: '🇲🇼' },
+ { iso2: 'my', country_name: 'Malaysia', country_code: '60', flag: '🇲🇾' },
+ { iso2: 'mv', country_name: 'Maldives', country_code: '960', flag: '🇲🇻' },
+ { iso2: 'ml', country_name: 'Mali', country_code: '223', flag: '🇲🇱' },
+ { iso2: 'mt', country_name: 'Malta', country_code: '356', flag: '🇲🇹' },
+ { iso2: 'mh', country_name: 'Marshall Islands', country_code: '692', flag: '🇲🇭' },
+ { iso2: 'mq', country_name: 'Martinique', country_code: '596', flag: '🇲🇶' },
+ { iso2: 'mr', country_name: 'Mauritania', country_code: '222', flag: '🇲🇷' },
+ { iso2: 'mu', country_name: 'Mauritius', country_code: '230', flag: '🇲🇺' },
+ { iso2: 'yt', country_name: 'Mayotte', country_code: '262', flag: '🇾🇹' },
+ { iso2: 'mx', country_name: 'Mexico', country_code: '52', flag: '🇲🇽' },
+ { iso2: 'fm', country_name: 'Micronesia', country_code: '691', flag: '🇫🇲' },
+ { iso2: 'md', country_name: 'Moldova', country_code: '373', flag: '🇲🇩' },
+ { iso2: 'mc', country_name: 'Monaco', country_code: '377', flag: '🇲🇨' },
+ { iso2: 'mn', country_name: 'Mongolia', country_code: '976', flag: '🇲🇳' },
+ { iso2: 'me', country_name: 'Montenegro', country_code: '382', flag: '🇲🇪' },
+ { iso2: 'ms', country_name: 'Montserrat', country_code: '1', flag: '🇲🇸' },
+ { iso2: 'ma', country_name: 'Morocco', country_code: '212', flag: '🇲🇦' },
+ { iso2: 'mz', country_name: 'Mozambique', country_code: '258', flag: '🇲🇿' },
+ { iso2: 'mm', country_name: 'Myanmar (Burma)', country_code: '95', flag: '🇲🇲' },
+ { iso2: 'na', country_name: 'Namibia', country_code: '264', flag: '🇳🇦' },
+ { iso2: 'nr', country_name: 'Nauru', country_code: '674', flag: '🇳🇷' },
+ { iso2: 'np', country_name: 'Nepal', country_code: '977', flag: '🇳🇵' },
+ { iso2: 'nl', country_name: 'Netherlands', country_code: '31', flag: '🇳🇱' },
+ { iso2: 'nc', country_name: 'New Caledonia', country_code: '687', flag: '🇳🇨' },
+ { iso2: 'nz', country_name: 'New Zealand', country_code: '64', flag: '🇳🇿' },
+ { iso2: 'ni', country_name: 'Nicaragua', country_code: '505', flag: '🇳🇮' },
+ { iso2: 'ne', country_name: 'Niger', country_code: '227', flag: '🇳🇪' },
+ { iso2: 'ng', country_name: 'Nigeria', country_code: '234', flag: '🇳🇬' },
+ { iso2: 'nu', country_name: 'Niue', country_code: '683', flag: '🇳🇺' },
+ { iso2: 'nf', country_name: 'Norfolk Island', country_code: '672', flag: '🇳🇫' },
+ { iso2: 'kp', country_name: 'North Korea', country_code: '850', flag: '🇰🇵' },
+ { iso2: 'mk', country_name: 'North Macedonia', country_code: '389', flag: '🇲🇰' },
+ { iso2: 'mp', country_name: 'Northern Mariana Islands', country_code: '1', flag: '🇲🇵' },
+ { iso2: 'no', country_name: 'Norway', country_code: '47', flag: '🇳🇴' },
+ { iso2: 'om', country_name: 'Oman', country_code: '968', flag: '🇴🇲' },
+ { iso2: 'pk', country_name: 'Pakistan', country_code: '92', flag: '🇵🇰' },
+ { iso2: 'pw', country_name: 'Palau', country_code: '680', flag: '🇵🇼' },
+ { iso2: 'ps', country_name: 'Palestinian Territories', country_code: '970', flag: '🇵🇸' },
+ { iso2: 'pa', country_name: 'Panama', country_code: '507', flag: '🇵🇦' },
+ { iso2: 'pg', country_name: 'Papua New Guinea', country_code: '675', flag: '🇵🇬' },
+ { iso2: 'py', country_name: 'Paraguay', country_code: '595', flag: '🇵🇾' },
+ { iso2: 'pe', country_name: 'Peru', country_code: '51', flag: '🇵🇪' },
+ { iso2: 'ph', country_name: 'Philippines', country_code: '63', flag: '🇵🇭' },
+ { iso2: 'pl', country_name: 'Poland', country_code: '48', flag: '🇵🇱' },
+ { iso2: 'pt', country_name: 'Portugal', country_code: '351', flag: '🇵🇹' },
+ { iso2: 'pr', country_name: 'Puerto Rico', country_code: '1', flag: '🇵🇷' },
+ { iso2: 'qa', country_name: 'Qatar', country_code: '974', flag: '🇶🇦' },
+ { iso2: 're', country_name: 'Réunion', country_code: '262', flag: '🇷🇪' },
+ { iso2: 'ro', country_name: 'Romania', country_code: '40', flag: '🇷🇴' },
+ { iso2: 'ru', country_name: 'Russia', country_code: '7', flag: '🇷🇺' },
+ { iso2: 'rw', country_name: 'Rwanda', country_code: '250', flag: '🇷🇼' },
+ { iso2: 'ws', country_name: 'Samoa', country_code: '685', flag: '🇼🇸' },
+ { iso2: 'sm', country_name: 'San Marino', country_code: '378', flag: '🇸🇲' },
+ { iso2: 'st', country_name: 'São Tomé & Príncipe', country_code: '239', flag: '🇸🇹' },
+ { iso2: 'sa', country_name: 'Saudi Arabia', country_code: '966', flag: '🇸🇦' },
+ { iso2: 'sn', country_name: 'Senegal', country_code: '221', flag: '🇸🇳' },
+ { iso2: 'rs', country_name: 'Serbia', country_code: '381', flag: '🇷🇸' },
+ { iso2: 'sc', country_name: 'Seychelles', country_code: '248', flag: '🇸🇨' },
+ { iso2: 'sl', country_name: 'Sierra Leone', country_code: '232', flag: '🇸🇱' },
+ { iso2: 'sg', country_name: 'Singapore', country_code: '65', flag: '🇸🇬' },
+ { iso2: 'sx', country_name: 'Sint Maarten', country_code: '1', flag: '🇸🇽' },
+ { iso2: 'sk', country_name: 'Slovakia', country_code: '421', flag: '🇸🇰' },
+ { iso2: 'si', country_name: 'Slovenia', country_code: '386', flag: '🇸🇮' },
+ { iso2: 'sb', country_name: 'Solomon Islands', country_code: '677', flag: '🇸🇧' },
+ { iso2: 'so', country_name: 'Somalia', country_code: '252', flag: '🇸🇴' },
+ { iso2: 'za', country_name: 'South Africa', country_code: '27', flag: '🇿🇦' },
+ { iso2: 'kr', country_name: 'South Korea', country_code: '82', flag: '🇰🇷' },
+ { iso2: 'ss', country_name: 'South Sudan', country_code: '211', flag: '🇸🇸' },
+ { iso2: 'es', country_name: 'Spain', country_code: '34', flag: '🇪🇸' },
+ { iso2: 'lk', country_name: 'Sri Lanka', country_code: '94', flag: '🇱🇰' },
+ { iso2: 'bl', country_name: 'St. Barthélemy', country_code: '590', flag: '🇧🇱' },
+ { iso2: 'sh', country_name: 'St. Helena', country_code: '290', flag: '🇸🇭' },
+ { iso2: 'kn', country_name: 'St. Kitts & Nevis', country_code: '1', flag: '🇰🇳' },
+ { iso2: 'lc', country_name: 'St. Lucia', country_code: '1', flag: '🇱🇨' },
+ { iso2: 'mf', country_name: 'St. Martin', country_code: '590', flag: '🇲🇫' },
+ { iso2: 'pm', country_name: 'St. Pierre & Miquelon', country_code: '508', flag: '🇵🇲' },
+ { iso2: 'vc', country_name: 'St. Vincent & Grenadines', country_code: '1', flag: '🇻🇨' },
+ { iso2: 'sd', country_name: 'Sudan', country_code: '249', flag: '🇸🇩' },
+ { iso2: 'sr', country_name: 'Suriname', country_code: '597', flag: '🇸🇷' },
+ { iso2: 'sj', country_name: 'Svalbard & Jan Mayen', country_code: '47', flag: '🇸🇯' },
+ { iso2: 'se', country_name: 'Sweden', country_code: '46', flag: '🇸🇪' },
+ { iso2: 'ch', country_name: 'Switzerland', country_code: '41', flag: '🇨🇭' },
+ { iso2: 'sy', country_name: 'Syria', country_code: '963', flag: '🇸🇾' },
+ { iso2: 'tw', country_name: 'Taiwan', country_code: '886', flag: '🇹🇼' },
+ { iso2: 'tj', country_name: 'Tajikistan', country_code: '992', flag: '🇹🇯' },
+ { iso2: 'tz', country_name: 'Tanzania', country_code: '255', flag: '🇹🇿' },
+ { iso2: 'th', country_name: 'Thailand', country_code: '66', flag: '🇹🇭' },
+ { iso2: 'tl', country_name: 'Timor-Leste', country_code: '670', flag: '🇹🇱' },
+ { iso2: 'tg', country_name: 'Togo', country_code: '228', flag: '🇹🇬' },
+ { iso2: 'tk', country_name: 'Tokelau', country_code: '690', flag: '🇹🇰' },
+ { iso2: 'to', country_name: 'Tonga', country_code: '676', flag: '🇹🇴' },
+ { iso2: 'tt', country_name: 'Trinidad & Tobago', country_code: '1', flag: '🇹🇹' },
+ { iso2: 'tn', country_name: 'Tunisia', country_code: '216', flag: '🇹🇳' },
+ { iso2: 'tr', country_name: 'Turkey', country_code: '90', flag: '🇹🇷' },
+ { iso2: 'tm', country_name: 'Turkmenistan', country_code: '993', flag: '🇹🇲' },
+ { iso2: 'tc', country_name: 'Turks & Caicos Islands', country_code: '1', flag: '🇹🇨' },
+ { iso2: 'tv', country_name: 'Tuvalu', country_code: '688', flag: '🇹🇻' },
+ { iso2: 'vi', country_name: 'U.S. Virgin Islands', country_code: '1', flag: '🇻🇮' },
+ { iso2: 'ug', country_name: 'Uganda', country_code: '256', flag: '🇺🇬' },
+ { iso2: 'ua', country_name: 'Ukraine', country_code: '380', flag: '🇺🇦' },
+ { iso2: 'ae', country_name: 'United Arab Emirates', country_code: '971', flag: '🇦🇪' },
+ { iso2: 'gb', country_name: 'United Kingdom', country_code: '44', flag: '🇬🇧' },
+ { iso2: 'us', country_name: 'United States', country_code: '1', flag: '🇺🇸' },
+ { iso2: 'uy', country_name: 'Uruguay', country_code: '598', flag: '🇺🇾' },
+ { iso2: 'uz', country_name: 'Uzbekistan', country_code: '998', flag: '🇺🇿' },
+ { iso2: 'vu', country_name: 'Vanuatu', country_code: '678', flag: '🇻🇺' },
+ { iso2: 'va', country_name: 'Vatican City', country_code: '39', flag: '🇻🇦' },
+ { iso2: 've', country_name: 'Venezuela', country_code: '58', flag: '🇻🇪' },
+ { iso2: 'vn', country_name: 'Vietnam', country_code: '84', flag: '🇻🇳' },
+ { iso2: 'wf', country_name: 'Wallis & Futuna', country_code: '681', flag: '🇼🇫' },
+ { iso2: 'eh', country_name: 'Western Sahara', country_code: '212', flag: '🇪🇭' },
+ { iso2: 'ye', country_name: 'Yemen', country_code: '967', flag: '🇾🇪' },
+ { iso2: 'zm', country_name: 'Zambia', country_code: '260', flag: '🇿🇲' },
+ { iso2: 'zw', country_name: 'Zimbabwe', country_code: '263', flag: '🇿🇼' },
+]
+
+// Pre-formatted options for NeCombobox component showing country name, code, and flag
+export const countryCodeComboOptions: NeComboboxOption[] = countries.map((c) => ({
+ id: `${c.iso2}`,
+ label: `${c.country_name} (+${c.country_code})`,
+ description: c.flag,
+}))
+
+// Parse a raw phone number string and return parts suitable for form fields.
+// Used by all phone input components to split phone into country code + local part.
+// Returns { countryCode, phone } where:
+// - countryCode is ISO2 code (e.g., "it")
+// - phone is formatted local part (e.g., "333 000 1113")
+// If parsing fails or input is empty, defaults to "it" + original value (or empty).
+export function parsePhoneForForm(raw: string | null | undefined): {
+ countryCode: string
+ phone: string
+} {
+ if (!raw) {
+ return { countryCode: 'it', phone: '' }
+ }
+
+ const parsed = parsePhoneNumber(raw)
+ if (parsed) {
+ return { countryCode: parsed.countryIso2, phone: parsed.localPart }
+ } else {
+ // Fallback if parsing fails
+ return { countryCode: 'it', phone: raw }
+ }
+}
diff --git a/frontend/src/lib/users/users.ts b/frontend/src/lib/users/users.ts
index 840c9418f..70f3b38d8 100644
--- a/frontend/src/lib/users/users.ts
+++ b/frontend/src/lib/users/users.ts
@@ -318,3 +318,109 @@ export async function exportUser(user: User, format: 'pdf' | 'csv') {
throw error
}
}
+
+// ============================================================
+// Import types
+// ============================================================
+
+export interface ImportFieldWarning {
+ field: string
+ message: string
+ value: string
+}
+
+export interface ImportFieldError {
+ field: string
+ message: string
+ values: string[]
+ candidates?: ImportOrgCandidate[]
+}
+
+export interface ImportOrgCandidate {
+ logto_id: string
+ name: string
+ type: string
+}
+
+export interface ImportRow {
+ row_number: number
+ status: 'valid' | 'error' | 'warning' | 'ambiguous'
+ data: Record
+ errors?: ImportFieldError[]
+ warnings?: ImportFieldWarning[]
+}
+
+export interface ImportValidationResult {
+ import_id: string
+ total_rows: number
+ valid_rows: number
+ error_rows: number
+ warning_rows: number
+ ambiguous_rows: number
+ rows: ImportRow[]
+}
+
+export interface ImportResultRow {
+ row_number: number
+ status: 'created' | 'updated' | 'skipped' | 'failed'
+ id?: string
+ reason?: string
+ error?: string
+}
+
+export interface ImportConfirmResult {
+ created: number
+ updated: number
+ skipped: number
+ failed: number
+ results: ImportResultRow[]
+}
+
+// ============================================================
+// Import API functions
+// ============================================================
+
+export const getImportTemplate = () => {
+ const loginStore = useLoginStore()
+ return axios
+ .get(`${API_URL}/users/import/template`, {
+ headers: { Authorization: `Bearer ${loginStore.jwtToken}` },
+ responseType: 'blob',
+ })
+ .then((res) => res.data)
+}
+
+export const validateUsersImport = (file: File) => {
+ const loginStore = useLoginStore()
+ const formData = new FormData()
+ formData.append('file', file)
+ return axios
+ .post<{ code: number; message: string; data: ImportValidationResult }>(
+ `${API_URL}/users/import/validate`,
+ formData,
+ {
+ headers: {
+ Authorization: `Bearer ${loginStore.jwtToken}`,
+ 'Content-Type': null,
+ },
+ },
+ )
+ .then((res) => res.data.data)
+}
+
+export const confirmUsersImport = (
+ importId: string,
+ override: boolean,
+ resolutions?: Record,
+) => {
+ const loginStore = useLoginStore()
+ return axios
+ .post<{ code: number; message: string; data: ImportConfirmResult }>(
+ `${API_URL}/users/import/confirm`,
+ { import_id: importId, override, resolutions },
+ {
+ headers: { Authorization: `Bearer ${loginStore.jwtToken}` },
+ },
+ )
+ .then((res) => res.data.data)
+}
diff --git a/frontend/src/views/ApplicationsView.vue b/frontend/src/views/ApplicationsView.vue
index 68eceee7b..8472b43d2 100644
--- a/frontend/src/views/ApplicationsView.vue
+++ b/frontend/src/views/ApplicationsView.vue
@@ -62,13 +62,9 @@ const dontShowUnassignedAppsNotificationAgain = () => {
v-if="showUnassignedAppsNotification"
kind="info"
:description="
- $t(
- 'applications.num_applications_not_assigned',
- {
- num: applicationsTotal.data?.unassigned,
- },
- applicationsTotal.data?.unassigned || 0,
- )
+ $t('applications.num_applications_not_assigned', {
+ count: applicationsTotal.data?.unassigned,
+ })
"
:primary-button-label="t('applications.show_unassigned')"
:secondary-button-label="t('applications.dont_show_again')"
diff --git a/frontend/src/views/CustomersView.vue b/frontend/src/views/CustomersView.vue
index 8cc07b4bb..7fe73d2a3 100644
--- a/frontend/src/views/CustomersView.vue
+++ b/frontend/src/views/CustomersView.vue
@@ -6,9 +6,11 @@