@goplusvn/core 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -1
- package/src/assets/erp_wallpaper.png +0 -0
- package/src/assets/goeat_logo.png +0 -0
- package/src/audit/audit-manager.ts +139 -0
- package/src/audit/index.ts +11 -0
- package/src/audit/memory-audit-logger.ts +86 -0
- package/src/audit/types.ts +50 -0
- package/src/auth/auth-service.ts +97 -0
- package/src/auth/index.ts +266 -0
- package/src/code-generation/index.ts +69 -0
- package/src/configs/auth-routes.ts +17 -0
- package/src/configs/crud.ts +136 -0
- package/src/configs/data/navigations.ts +781 -0
- package/src/configs/data/oauth-links.ts +10 -0
- package/src/configs/entities/material-categories.config.ts +125 -0
- package/src/configs/i18n.ts +12 -0
- package/src/configs/index.ts +26 -0
- package/src/configs/status.ts +25 -0
- package/src/configs/themes.ts +100 -0
- package/src/crud/components/crud-bulk-actions.tsx +91 -0
- package/src/crud/components/crud-card-view.tsx +241 -0
- package/src/crud/components/crud-context.tsx +122 -0
- package/src/crud/components/crud-delete-dialog.tsx +145 -0
- package/src/crud/components/crud-dialog.tsx +406 -0
- package/src/crud/components/crud-empty-state.tsx +104 -0
- package/src/crud/components/crud-export-button.tsx +170 -0
- package/src/crud/components/crud-field-renderer.tsx +653 -0
- package/src/crud/components/crud-filter-chips.tsx +102 -0
- package/src/crud/components/crud-filters/checkbox-filter.tsx +97 -0
- package/src/crud/components/crud-filters/datetime-filter.tsx +83 -0
- package/src/crud/components/crud-filters/filter-builder.tsx +66 -0
- package/src/crud/components/crud-filters/index.tsx +76 -0
- package/src/crud/components/crud-filters/radio-filter.tsx +86 -0
- package/src/crud/components/crud-filters/select-filter.tsx +141 -0
- package/src/crud/components/crud-filters/text-filter.tsx +86 -0
- package/src/crud/components/crud-form.tsx +642 -0
- package/src/crud/components/crud-import-dialog.tsx +440 -0
- package/src/crud/components/crud-infinite-scroll.tsx +116 -0
- package/src/crud/components/crud-page.tsx +1017 -0
- package/src/crud/components/crud-provider.tsx +277 -0
- package/src/crud/components/crud-row-actions.tsx +189 -0
- package/src/crud/components/crud-search.tsx +82 -0
- package/src/crud/components/crud-sheet.tsx +336 -0
- package/src/crud/components/crud-table-skeleton.tsx +26 -0
- package/src/crud/components/crud-table-toolbar.tsx +91 -0
- package/src/crud/components/crud-table.tsx +352 -0
- package/src/crud/components/crud-virtual-table.tsx +55 -0
- package/src/crud/components/index.tsx +20 -0
- package/src/crud/crud-filters/checkbox-filter.tsx +87 -0
- package/src/crud/crud-filters/datetime-filter.tsx +82 -0
- package/src/crud/crud-filters/filter-builder.tsx +64 -0
- package/src/crud/crud-filters/index.tsx +78 -0
- package/src/crud/crud-filters/radio-filter.tsx +79 -0
- package/src/crud/crud-filters/select-filter.tsx +148 -0
- package/src/crud/crud-filters/text-filter.tsx +81 -0
- package/src/crud/index.ts +43 -0
- package/src/crud/lib/crud-service.test.ts +334 -0
- package/src/crud/lib/crud-service.ts +358 -0
- package/src/crud/lib/crud-utils.test.ts +354 -0
- package/src/crud/lib/crud-utils.ts +299 -0
- package/src/crud/lib/crud-validator.ts +247 -0
- package/src/crud/lib/data-loader.ts +234 -0
- package/src/crud/lib/field-calculator.ts +241 -0
- package/src/crud/lib/field-formatter.ts +240 -0
- package/src/crud/lib/import-export-service.test.ts +290 -0
- package/src/crud/lib/import-export-service.ts +352 -0
- package/src/crud/lib/import-server-utils.ts +109 -0
- package/src/crud/lib/lazy-loader.ts +241 -0
- package/src/crud/lib/parse-filters.ts +85 -0
- package/src/crud/lib/permissions.ts +52 -0
- package/src/crud/lib/serialize-config.ts +60 -0
- package/src/crud/lib/stream-loader.ts +145 -0
- package/src/crud/lib/translate-config.ts +335 -0
- package/src/crud/lib/types.ts +11 -0
- package/src/crud/pages/entity-crud-page.tsx +144 -0
- package/src/crud/server.ts +8 -0
- package/src/home/constants.tsx +142 -0
- package/src/home/feature-showcase.tsx +171 -0
- package/src/home/home-page.tsx +191 -0
- package/src/home/hooks/index.ts +1 -0
- package/src/home/hooks/useWidgetPreferences.ts +167 -0
- package/src/home/index.ts +33 -0
- package/src/home/quick-access-dialog.tsx +271 -0
- package/src/home/quick-access-menu.tsx +267 -0
- package/src/home/types.ts +140 -0
- package/src/home/welcome-card.tsx +92 -0
- package/src/home/widget-container.tsx +258 -0
- package/src/home/widgets/base-widget.tsx +200 -0
- package/src/home/widgets/customers-widget.tsx +74 -0
- package/src/home/widgets/index.ts +6 -0
- package/src/home/widgets/orders-widget.tsx +87 -0
- package/src/home/widgets/revenue-widget.tsx +71 -0
- package/src/home/widgets/stock-widget.tsx +109 -0
- package/src/hooks/index.tsx +598 -0
- package/src/hooks/use-tenant.test.tsx +30 -0
- package/src/hooks/use-tenant.ts +5 -0
- package/src/index.ts +17 -0
- package/src/infrastructure/__tests__/architecture-verification.spec.ts +103 -0
- package/src/infrastructure/api-service.ts +317 -0
- package/src/infrastructure/cache/cache-manager.ts +107 -0
- package/src/infrastructure/cache/cache.ts +120 -0
- package/src/infrastructure/cache/index.ts +8 -0
- package/src/infrastructure/cache/types.ts +48 -0
- package/src/infrastructure/cron/cron-manager.ts +239 -0
- package/src/infrastructure/cron/index.ts +6 -0
- package/src/infrastructure/cron/types.ts +41 -0
- package/src/infrastructure/event-bus/event-bus.ts +145 -0
- package/src/infrastructure/event-bus/index.ts +2 -0
- package/src/infrastructure/event-bus/types.ts +22 -0
- package/src/infrastructure/index.ts +32 -0
- package/src/infrastructure/lock/decorators.ts +67 -0
- package/src/infrastructure/lock/index.ts +2 -0
- package/src/infrastructure/lock/lock-manager.ts +33 -0
- package/src/infrastructure/logger/index.ts +2 -0
- package/src/infrastructure/logger/logger.ts +96 -0
- package/src/infrastructure/logger/types.ts +25 -0
- package/src/layout/index.tsx +185 -0
- package/src/navigation/index.ts +91 -0
- package/src/notification/index.ts +14 -0
- package/src/notification/notification-service.ts +120 -0
- package/src/notification/storage/in-memory.ts +56 -0
- package/src/notification/storage/index.ts +1 -0
- package/src/notification/types.ts +51 -0
- package/src/organization/branch-service.ts +299 -0
- package/src/organization/branches.config.ts +154 -0
- package/src/organization/index.ts +5 -0
- package/src/plugin/apps-registry.ts +97 -0
- package/src/plugin/index.ts +5 -0
- package/src/plugin/types.ts +41 -0
- package/src/providers/index.tsx +109 -0
- package/src/providers/tenant-provider.tsx +45 -0
- package/src/rbac/components/roles/role-card.tsx +158 -0
- package/src/rbac/components/roles/role-stats-cards.tsx +29 -0
- package/src/rbac/components/roles/role-toolbar.tsx +123 -0
- package/src/rbac/hooks/use-role-operations.ts +159 -0
- package/src/rbac/hooks/use-roles-data.ts +59 -0
- package/src/rbac/index.ts +297 -0
- package/src/rbac/lib/permission-helpers.ts +63 -0
- package/src/rbac/pages/action-list-page.tsx +25 -0
- package/src/rbac/pages/resource-list-page.tsx +25 -0
- package/src/rbac/pages/role-list-page.tsx +378 -0
- package/src/rbac/permission-service.ts +140 -0
- package/src/rbac/permissions.ts +135 -0
- package/src/rbac/resource-service.ts +115 -0
- package/src/rbac/resource-validator.ts +119 -0
- package/src/rbac/role-service.ts +165 -0
- package/src/rbac/server.ts +16 -0
- package/src/rbac/types.ts +38 -0
- package/src/schemas/action.schema.ts +66 -0
- package/src/schemas/branch.schema.ts +52 -0
- package/src/schemas/coming-soon-schema.ts +9 -0
- package/src/schemas/company.schema.ts +44 -0
- package/src/schemas/forgot-passward-schema.ts +9 -0
- package/src/schemas/index.ts +30 -0
- package/src/schemas/material-category.schema.ts +43 -0
- package/src/schemas/material-pricing.schema.ts +74 -0
- package/src/schemas/material.schema.ts +76 -0
- package/src/schemas/materials.ts +52 -0
- package/src/schemas/new-passward-schema.ts +15 -0
- package/src/schemas/partner-company.schema.ts +149 -0
- package/src/schemas/register-schema.ts +36 -0
- package/src/schemas/resource.schema.ts +133 -0
- package/src/schemas/role.schema.ts +11 -0
- package/src/schemas/sign-in-schema.ts +24 -0
- package/src/schemas/supplier-pricing.schema.ts +15 -0
- package/src/schemas/supplier.schema.ts +120 -0
- package/src/schemas/system-category-group.schema.ts +67 -0
- package/src/schemas/system-category.schema.ts +77 -0
- package/src/schemas/system-config.schema.ts +118 -0
- package/src/schemas/uom.schema.ts +75 -0
- package/src/schemas/user-supplier.schema.ts +179 -0
- package/src/schemas/user.schema.ts +18 -0
- package/src/schemas/verify-email-schema.ts +9 -0
- package/src/schemas/warehouse.schema.ts +49 -0
- package/src/system/components/categories/category-list.tsx +529 -0
- package/src/system/components/categories/category-manager.tsx +89 -0
- package/src/system/components/categories/group-sidebar.tsx +308 -0
- package/src/system/components/settings/setting-dialogs.tsx +197 -0
- package/src/system/components/settings/setting-field.tsx +291 -0
- package/src/system/components/settings/setting-form-dialog.tsx +308 -0
- package/src/system/components/settings/settings-groups.ts +80 -0
- package/src/system/components/settings/settings-search.tsx +71 -0
- package/src/system/components/settings/settings-section.tsx +74 -0
- package/src/system/components/settings/settings-sidebar.tsx +81 -0
- package/src/system/constants.ts +3 -0
- package/src/system/index.ts +150 -0
- package/src/system/job-manager.ts +176 -0
- package/src/system/pages/components/categories/category-list.tsx +537 -0
- package/src/system/pages/components/categories/category-manager.tsx +90 -0
- package/src/system/pages/components/categories/group-sidebar.tsx +311 -0
- package/src/system/pages/components/settings/sales-rules-settings.tsx +222 -0
- package/src/system/pages/components/settings/setting-dialogs.tsx +197 -0
- package/src/system/pages/components/settings/setting-field.tsx +292 -0
- package/src/system/pages/components/settings/setting-form-dialog.tsx +308 -0
- package/src/system/pages/components/settings/settings-groups.ts +87 -0
- package/src/system/pages/components/settings/settings-page.tsx +372 -0
- package/src/system/pages/components/settings/settings-search.tsx +71 -0
- package/src/system/pages/components/settings/settings-section.tsx +74 -0
- package/src/system/pages/components/settings/settings-sidebar.tsx +81 -0
- package/src/system/pages/components/settings/system-settings.tsx +244 -0
- package/src/system/pages/system-category-page.tsx +15 -0
- package/src/system/pages/system-settings-page.tsx +380 -0
- package/src/system/schemas/system-category-group.schema.ts +46 -0
- package/src/system/schemas/system-category.schema.ts +56 -0
- package/src/system/services/settings-service.ts +127 -0
- package/src/system/services/system-category-service.ts +63 -0
- package/src/system/types.ts +45 -0
- package/src/types/index.ts +703 -0
- package/src/ui/auth/auth-layout.tsx +135 -0
- package/src/ui/auth/forgot-password-form.tsx +98 -0
- package/src/ui/auth/index.tsx +7 -0
- package/src/ui/auth/new-password-form.tsx +107 -0
- package/src/ui/auth/oauth-links.tsx +30 -0
- package/src/ui/auth/register-form.tsx +202 -0
- package/src/ui/auth/sign-in-form.tsx +238 -0
- package/src/ui/auth/verify-email-form.tsx +104 -0
- package/src/ui/crud/index.tsx +10 -0
- package/src/ui/data-display/accordion.tsx +65 -0
- package/src/ui/data-display/aspect-ratio.tsx +11 -0
- package/src/ui/data-display/avatar.tsx +163 -0
- package/src/ui/data-display/bento-grid.tsx +77 -0
- package/src/ui/data-display/carousel.tsx +249 -0
- package/src/ui/data-display/chart.tsx +363 -0
- package/src/ui/data-display/code-block-highlight.tsx +54 -0
- package/src/ui/data-display/collapsible.tsx +42 -0
- package/src/ui/data-display/compact-stat-bar.tsx +149 -0
- package/src/ui/data-display/data-table/data-table-context.tsx +255 -0
- package/src/ui/data-display/data-table/data-table-empty-state.tsx +133 -0
- package/src/ui/data-display/data-table/data-table-skeleton.tsx +145 -0
- package/src/ui/data-display/data-table/data-table-toolbar.tsx +353 -0
- package/src/ui/data-display/data-table/data-table.tsx +597 -0
- package/src/ui/data-display/data-table/index.ts +44 -0
- package/src/ui/data-display/data-table-column-header.tsx +75 -0
- package/src/ui/data-display/data-table-pagination.tsx +130 -0
- package/src/ui/data-display/data-table-view-options.tsx +59 -0
- package/src/ui/data-display/formatted-number-input.tsx +210 -0
- package/src/ui/data-display/highlight.tsx +20 -0
- package/src/ui/data-display/hover-card.tsx +48 -0
- package/src/ui/data-display/index.tsx +50 -0
- package/src/ui/data-display/iphone-15-pro.tsx +114 -0
- package/src/ui/data-display/kanban/index.ts +4 -0
- package/src/ui/data-display/kanban/kanban-board.tsx +192 -0
- package/src/ui/data-display/kanban/kanban-column.tsx +74 -0
- package/src/ui/data-display/kanban/kanban-item.tsx +50 -0
- package/src/ui/data-display/kanban/kanban-types.ts +21 -0
- package/src/ui/data-display/kpi-card.tsx +68 -0
- package/src/ui/data-display/media-grid.tsx +110 -0
- package/src/ui/data-display/safari.tsx +175 -0
- package/src/ui/data-display/show-more-text.tsx +55 -0
- package/src/ui/data-display/tabs.tsx +68 -0
- package/src/ui/data-display/timeline.tsx +256 -0
- package/src/ui/feedback/alert.tsx +60 -0
- package/src/ui/feedback/context-menu.tsx +245 -0
- package/src/ui/feedback/drawer.tsx +132 -0
- package/src/ui/feedback/error-dialog.tsx +273 -0
- package/src/ui/feedback/index.tsx +183 -0
- package/src/ui/feedback/progress.tsx +32 -0
- package/src/ui/feedback/sheet.tsx +148 -0
- package/src/ui/feedback/sonner.tsx +36 -0
- package/src/ui/forms/command.tsx +157 -0
- package/src/ui/forms/date-picker.tsx +73 -0
- package/src/ui/forms/date-range-picker.tsx +76 -0
- package/src/ui/forms/date-time-picker.tsx +109 -0
- package/src/ui/forms/editor/editor-menu-bar.tsx +394 -0
- package/src/ui/forms/editor/index.tsx +130 -0
- package/src/ui/forms/editor/multi-select-example.tsx +1234 -0
- package/src/ui/forms/emoji-picker.tsx +109 -0
- package/src/ui/forms/file-dropzone.tsx +169 -0
- package/src/ui/forms/file-thumbnail.tsx +29 -0
- package/src/ui/forms/index.tsx +201 -0
- package/src/ui/forms/input-file.tsx +99 -0
- package/src/ui/forms/input-group.tsx +46 -0
- package/src/ui/forms/input-otp.tsx +81 -0
- package/src/ui/forms/input-phone.tsx +172 -0
- package/src/ui/forms/input-spin.tsx +116 -0
- package/src/ui/forms/input-tags.tsx +219 -0
- package/src/ui/forms/input-time.tsx +42 -0
- package/src/ui/forms/multi-select.tsx +629 -0
- package/src/ui/forms/multiple-date-picker.tsx +74 -0
- package/src/ui/forms/radio-group.tsx +42 -0
- package/src/ui/forms/rating.tsx +158 -0
- package/src/ui/forms/time-picker.tsx +57 -0
- package/src/ui/index.tsx +17 -0
- package/src/ui/layout/animated-list.tsx +77 -0
- package/src/ui/layout/animated-sidebar.tsx +294 -0
- package/src/ui/layout/command-menu.tsx +355 -0
- package/src/ui/layout/customizer.tsx +324 -0
- package/src/ui/layout/footer.tsx +43 -0
- package/src/ui/layout/full-screen-toggle.tsx +52 -0
- package/src/ui/layout/header-breadcrumb.tsx +77 -0
- package/src/ui/layout/horizontal-layout-header.tsx +83 -0
- package/src/ui/layout/horizontal-layout.tsx +50 -0
- package/src/ui/layout/index.tsx +25 -0
- package/src/ui/layout/language-dropdown.tsx +103 -0
- package/src/ui/layout/logo.tsx +63 -0
- package/src/ui/layout/main-layout.tsx +57 -0
- package/src/ui/layout/mode-dropdown.tsx +58 -0
- package/src/ui/layout/notification-dropdown.tsx +127 -0
- package/src/ui/layout/page-tabs.tsx +306 -0
- package/src/ui/layout/route-cache.tsx +214 -0
- package/src/ui/layout/sidebar-group-icon-menu.tsx +195 -0
- package/src/ui/layout/sidebar.tsx +279 -0
- package/src/ui/layout/tab-content-cache.tsx +201 -0
- package/src/ui/layout/tab-navigation-provider.tsx +536 -0
- package/src/ui/layout/toggle-mobile-sidebar.tsx +33 -0
- package/src/ui/layout/top-bar-header-menubar.tsx +412 -0
- package/src/ui/layout/user-dropdown.tsx +188 -0
- package/src/ui/layout/vertical-layout-header.tsx +65 -0
- package/src/ui/layout/vertical-layout.tsx +47 -0
- package/src/ui/management/audit-log-page.tsx +209 -0
- package/src/ui/management/cache-management.tsx +349 -0
- package/src/ui/management/index.ts +3 -0
- package/src/ui/management/job-management.tsx +308 -0
- package/src/ui/pages/not-found.tsx +30 -0
- package/src/ui/primitives/badge.tsx +66 -0
- package/src/ui/primitives/breadcrumb.tsx +103 -0
- package/src/ui/primitives/button.tsx +129 -0
- package/src/ui/primitives/calendar.tsx +74 -0
- package/src/ui/primitives/card.tsx +86 -0
- package/src/ui/primitives/checkbox.tsx +31 -0
- package/src/ui/primitives/client.ts +30 -0
- package/src/ui/primitives/combobox.tsx +290 -0
- package/src/ui/primitives/dialog.tsx +121 -0
- package/src/ui/primitives/dropdown-menu.tsx +239 -0
- package/src/ui/primitives/dynamic-icon.tsx +24 -0
- package/src/ui/primitives/index.tsx +134 -0
- package/src/ui/primitives/input-number.tsx +131 -0
- package/src/ui/primitives/input.tsx +22 -0
- package/src/ui/primitives/keyboard.tsx +23 -0
- package/src/ui/primitives/label.tsx +24 -0
- package/src/ui/primitives/menubar.tsx +262 -0
- package/src/ui/primitives/navigation-menu.tsx +157 -0
- package/src/ui/primitives/pagination.tsx +118 -0
- package/src/ui/primitives/popover.tsx +56 -0
- package/src/ui/primitives/prefetch-link.tsx +60 -0
- package/src/ui/primitives/resizable.tsx +59 -0
- package/src/ui/primitives/scroll-area.tsx +63 -0
- package/src/ui/primitives/select.tsx +172 -0
- package/src/ui/primitives/separator.tsx +51 -0
- package/src/ui/primitives/sidebar.tsx +844 -0
- package/src/ui/primitives/slider.tsx +27 -0
- package/src/ui/primitives/status-badge.tsx +47 -0
- package/src/ui/primitives/sticky-layout.tsx +50 -0
- package/src/ui/primitives/switch.tsx +29 -0
- package/src/ui/primitives/table.tsx +116 -0
- package/src/ui/primitives/tabs.tsx +55 -0
- package/src/ui/primitives/toggle-group.tsx +70 -0
- package/src/ui/primitives/toggle.tsx +47 -0
- package/src/ui/primitives/tooltip.tsx +59 -0
- package/src/user/components/dangerous-zone.tsx +34 -0
- package/src/user/components/delete-account-form.tsx +40 -0
- package/src/user/components/index.ts +4 -0
- package/src/user/components/profile-info-form.tsx +390 -0
- package/src/user/components/profile-info.tsx +32 -0
- package/src/user/components/unified-profile-dialog.tsx +1019 -0
- package/src/user/components/user-stats.tsx +27 -0
- package/src/user/components/user-toolbar.tsx +137 -0
- package/src/user/components/users-card-view.tsx +253 -0
- package/src/user/index.ts +11 -0
- package/src/user/pages/user-list-page.tsx +234 -0
- package/src/user/pages/users-client-page.tsx +385 -0
- package/src/user/profile-page.tsx +19 -0
- package/src/user/schemas.ts +68 -0
- package/src/user/types.ts +34 -0
- package/src/user/user-service.ts +538 -0
- package/src/utils/index.ts +906 -0
- package/src/workflow/activity-timeline.tsx +412 -0
- package/src/workflow/approval-workflow.tsx +31 -0
- package/src/workflow/index.ts +2 -0
|
@@ -0,0 +1,906 @@
|
|
|
1
|
+
// @goerp/core/utils
|
|
2
|
+
// Utility functions for GoERP platform
|
|
3
|
+
|
|
4
|
+
import { clsx } from "clsx";
|
|
5
|
+
import type { ClassValue } from "clsx";
|
|
6
|
+
import { twMerge } from "tailwind-merge";
|
|
7
|
+
|
|
8
|
+
// Import types from core/types
|
|
9
|
+
import type { LocaleType, FormatStyleType } from "../types";
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// Class Names
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Merge Tailwind CSS classes with clsx
|
|
17
|
+
*/
|
|
18
|
+
export function cn(...inputs: ClassValue[]) {
|
|
19
|
+
return twMerge(clsx(inputs));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ============================================================================
|
|
23
|
+
// String Utilities
|
|
24
|
+
// ============================================================================
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get initials from full name
|
|
28
|
+
*/
|
|
29
|
+
export function getInitials(fullName: string): string {
|
|
30
|
+
if (fullName.length === 0) return "";
|
|
31
|
+
const names = fullName.split(" ");
|
|
32
|
+
const initials = names.map((name) => name.charAt(0).toUpperCase()).join("");
|
|
33
|
+
return initials;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Slugify string
|
|
38
|
+
*/
|
|
39
|
+
export function slugify(text: string): string {
|
|
40
|
+
return text
|
|
41
|
+
.toLowerCase()
|
|
42
|
+
.normalize("NFD")
|
|
43
|
+
.replace(/[\u0300-\u036f]/g, "")
|
|
44
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
45
|
+
.replace(/(^-|-$)/g, "");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Convert camelCase to Title Case
|
|
50
|
+
*/
|
|
51
|
+
export function camelCaseToTitleCase(camelCaseStr: string): string {
|
|
52
|
+
return camelCaseStr
|
|
53
|
+
.replace(/([A-Z])/g, " $1")
|
|
54
|
+
.replace(/^./, (char) => char.toUpperCase());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Convert Title Case to camelCase
|
|
59
|
+
*/
|
|
60
|
+
export function titleCaseToCamelCase(titleCaseStr: string): string {
|
|
61
|
+
return titleCaseStr
|
|
62
|
+
.toLowerCase()
|
|
63
|
+
.replace(/[-_\s]+(.)/g, (_, char) => char.toUpperCase())
|
|
64
|
+
.replace(/[-_]/g, "");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ============================================================================
|
|
68
|
+
// Number Utilities
|
|
69
|
+
// ============================================================================
|
|
70
|
+
|
|
71
|
+
export const isEven = (num: number) => num % 2 === 0;
|
|
72
|
+
export const isNonNegative = (num: number) => num >= 0;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Đọc số tiền thành chữ tiếng Việt
|
|
76
|
+
* Dùng chung cho tất cả các phiếu in (hợp đồng, phiếu thu, phiếu chi, phiếu nhập/xuất kho, v.v.)
|
|
77
|
+
* @param amount - Số tiền (VND)
|
|
78
|
+
* @returns Chuỗi đọc bằng chữ, VD: "Bảy mươi hai triệu một trăm năm mươi chín nghìn tám trăm hai mươi đồng"
|
|
79
|
+
*/
|
|
80
|
+
export function readMoney(amount: number): string {
|
|
81
|
+
if (!Number.isFinite(amount)) return "Không đồng"
|
|
82
|
+
|
|
83
|
+
const isNegative = amount < 0
|
|
84
|
+
|
|
85
|
+
// Bỏ phần thập phân - VND không có đơn vị lẻ
|
|
86
|
+
amount = Math.floor(Math.abs(amount))
|
|
87
|
+
|
|
88
|
+
if (amount === 0) return "Không đồng"
|
|
89
|
+
|
|
90
|
+
const unit = ["", "nghìn", "triệu", "tỷ", "nghìn tỷ", "triệu tỷ"]
|
|
91
|
+
const digit = [
|
|
92
|
+
"không",
|
|
93
|
+
"một",
|
|
94
|
+
"hai",
|
|
95
|
+
"ba",
|
|
96
|
+
"bốn",
|
|
97
|
+
"năm",
|
|
98
|
+
"sáu",
|
|
99
|
+
"bảy",
|
|
100
|
+
"tám",
|
|
101
|
+
"chín",
|
|
102
|
+
]
|
|
103
|
+
|
|
104
|
+
let str = amount.toString()
|
|
105
|
+
const groups: string[] = []
|
|
106
|
+
while (str.length > 0) {
|
|
107
|
+
groups.push(str.slice(-3))
|
|
108
|
+
str = str.slice(0, -3)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let result = ""
|
|
112
|
+
for (let i = 0; i < groups.length; i++) {
|
|
113
|
+
const group = groups[i]
|
|
114
|
+
if (group === "000") continue
|
|
115
|
+
|
|
116
|
+
const [a, b, c] = group.padStart(3, "0").split("").map(Number)
|
|
117
|
+
let groupResult = ""
|
|
118
|
+
|
|
119
|
+
const hasHundreds = a !== 0 || groups.length > 1
|
|
120
|
+
|
|
121
|
+
if (hasHundreds) {
|
|
122
|
+
groupResult += `${digit[a]} trăm `
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (b === 0 && c !== 0) {
|
|
126
|
+
if (hasHundreds) groupResult += "lẻ "
|
|
127
|
+
} else if (b === 1) {
|
|
128
|
+
groupResult += "mười "
|
|
129
|
+
} else if (b > 1) {
|
|
130
|
+
groupResult += `${digit[b]} mươi `
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (c === 1 && b > 1) {
|
|
134
|
+
groupResult += "mốt "
|
|
135
|
+
} else if (c === 5 && b > 0) {
|
|
136
|
+
groupResult += "lăm "
|
|
137
|
+
} else if (c !== 0) {
|
|
138
|
+
groupResult += `${digit[c]} `
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (i === groups.length - 1 && a === 0) {
|
|
142
|
+
groupResult = groupResult.replace("không trăm ", "")
|
|
143
|
+
if (b === 0) groupResult = groupResult.replace("lẻ ", "")
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (groupResult.trim() !== "") {
|
|
147
|
+
result = `${groupResult.trim()} ${unit[i]} ${result}`
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
result = result.trim() + " đồng"
|
|
152
|
+
|
|
153
|
+
if (isNegative) {
|
|
154
|
+
return "Âm " + result
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return result.charAt(0).toUpperCase() + result.slice(1)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Format currency with locale
|
|
162
|
+
*/
|
|
163
|
+
export function formatCurrency(
|
|
164
|
+
value: number,
|
|
165
|
+
locales: LocaleType = "vi",
|
|
166
|
+
currency: string = "VND",
|
|
167
|
+
): string {
|
|
168
|
+
return new Intl.NumberFormat(locales === "vi" ? "vi-VN" : locales, {
|
|
169
|
+
style: "decimal",
|
|
170
|
+
maximumFractionDigits: 0,
|
|
171
|
+
}).format(value);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Format number with locale
|
|
176
|
+
*/
|
|
177
|
+
export function formatNumber(
|
|
178
|
+
value: number,
|
|
179
|
+
options?: {
|
|
180
|
+
locale?: string;
|
|
181
|
+
minimumFractionDigits?: number;
|
|
182
|
+
maximumFractionDigits?: number;
|
|
183
|
+
},
|
|
184
|
+
): string {
|
|
185
|
+
const {
|
|
186
|
+
locale = "vi-VN",
|
|
187
|
+
minimumFractionDigits = 0,
|
|
188
|
+
maximumFractionDigits = 2,
|
|
189
|
+
} = options || {};
|
|
190
|
+
|
|
191
|
+
return new Intl.NumberFormat(locale, {
|
|
192
|
+
minimumFractionDigits,
|
|
193
|
+
maximumFractionDigits,
|
|
194
|
+
}).format(value);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Format percent
|
|
199
|
+
*/
|
|
200
|
+
export function formatPercent(
|
|
201
|
+
value: number,
|
|
202
|
+
locales: LocaleType = "vi",
|
|
203
|
+
): string {
|
|
204
|
+
return new Intl.NumberFormat(locales === "vi" ? "vi-VN" : locales, {
|
|
205
|
+
style: "percent",
|
|
206
|
+
maximumFractionDigits: 0,
|
|
207
|
+
}).format(value);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Format number to compact (e.g., 1K, 1M)
|
|
212
|
+
*/
|
|
213
|
+
export function formatNumberToCompact(
|
|
214
|
+
value: number,
|
|
215
|
+
locales: LocaleType = "vi",
|
|
216
|
+
): string {
|
|
217
|
+
return new Intl.NumberFormat(locales === "vi" ? "vi-VN" : locales, {
|
|
218
|
+
notation: "compact",
|
|
219
|
+
compactDisplay: "short",
|
|
220
|
+
}).format(value);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Format file size
|
|
225
|
+
*/
|
|
226
|
+
export function formatFileSize(bytes: number, decimals: number = 2): string {
|
|
227
|
+
if (bytes === 0) return "0 Bytes";
|
|
228
|
+
|
|
229
|
+
const k = 1000;
|
|
230
|
+
const dm = decimals < 0 ? 0 : decimals;
|
|
231
|
+
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB"];
|
|
232
|
+
|
|
233
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
234
|
+
|
|
235
|
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Format unread count
|
|
240
|
+
*/
|
|
241
|
+
export function formatUnreadCount(unreadCount: number): string | number {
|
|
242
|
+
return unreadCount >= 100 ? "+99" : unreadCount;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ============================================================================
|
|
246
|
+
// Date Utilities
|
|
247
|
+
// ============================================================================
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Format date with Vietnamese locale (Asia/Ho_Chi_Minh timezone)
|
|
251
|
+
*/
|
|
252
|
+
export function formatDate(
|
|
253
|
+
date: Date | string,
|
|
254
|
+
options?: {
|
|
255
|
+
locale?: string;
|
|
256
|
+
format?: "short" | "medium" | "long" | "full";
|
|
257
|
+
},
|
|
258
|
+
): string {
|
|
259
|
+
const { locale = "vi-VN", format = "medium" } = options || {};
|
|
260
|
+
const dateObj = typeof date === "string" ? new Date(date) : date;
|
|
261
|
+
|
|
262
|
+
const formatOptions: Record<string, Intl.DateTimeFormatOptions> = {
|
|
263
|
+
short: { day: "2-digit", month: "2-digit", year: "numeric", timeZone: "Asia/Ho_Chi_Minh" },
|
|
264
|
+
medium: { day: "2-digit", month: "short", year: "numeric", timeZone: "Asia/Ho_Chi_Minh" },
|
|
265
|
+
long: { day: "numeric", month: "long", year: "numeric", timeZone: "Asia/Ho_Chi_Minh" },
|
|
266
|
+
full: { weekday: "long", day: "numeric", month: "long", year: "numeric", timeZone: "Asia/Ho_Chi_Minh" },
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
return new Intl.DateTimeFormat(locale, formatOptions[format]).format(dateObj);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Format datetime with Vietnamese locale (Asia/Ho_Chi_Minh timezone)
|
|
274
|
+
*/
|
|
275
|
+
export function formatDateTime(
|
|
276
|
+
date: Date | string,
|
|
277
|
+
options?: {
|
|
278
|
+
locale?: string;
|
|
279
|
+
},
|
|
280
|
+
): string {
|
|
281
|
+
const { locale = "vi-VN" } = options || {};
|
|
282
|
+
const dateObj = typeof date === "string" ? new Date(date) : date;
|
|
283
|
+
|
|
284
|
+
return new Intl.DateTimeFormat(locale, {
|
|
285
|
+
day: "2-digit",
|
|
286
|
+
month: "2-digit",
|
|
287
|
+
year: "numeric",
|
|
288
|
+
hour: "2-digit",
|
|
289
|
+
minute: "2-digit",
|
|
290
|
+
timeZone: "Asia/Ho_Chi_Minh",
|
|
291
|
+
}).format(dateObj);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Format relative date (Today, Yesterday, or date) - Asia/Ho_Chi_Minh timezone
|
|
296
|
+
*/
|
|
297
|
+
export function formatRelativeDate(value?: string | number | Date): string {
|
|
298
|
+
if (!value) return "No Date";
|
|
299
|
+
|
|
300
|
+
const date = new Date(value);
|
|
301
|
+
const today = new Date();
|
|
302
|
+
const yesterday = new Date();
|
|
303
|
+
yesterday.setDate(today.getDate() - 1);
|
|
304
|
+
|
|
305
|
+
// Compare dates in Vietnam timezone
|
|
306
|
+
const vnDateStr = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Ho_Chi_Minh", year: "numeric", month: "2-digit", day: "2-digit" }).format(date);
|
|
307
|
+
const vnTodayStr = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Ho_Chi_Minh", year: "numeric", month: "2-digit", day: "2-digit" }).format(today);
|
|
308
|
+
const vnYesterdayStr = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Ho_Chi_Minh", year: "numeric", month: "2-digit", day: "2-digit" }).format(yesterday);
|
|
309
|
+
|
|
310
|
+
if (vnDateStr === vnTodayStr) return "Today";
|
|
311
|
+
if (vnDateStr === vnYesterdayStr) return "Yesterday";
|
|
312
|
+
|
|
313
|
+
return formatDate(date);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Check if date is before today (Asia/Ho_Chi_Minh timezone)
|
|
318
|
+
*/
|
|
319
|
+
export function isBeforeToday(date: Date): boolean {
|
|
320
|
+
const vnTodayStr = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Ho_Chi_Minh", year: "numeric", month: "2-digit", day: "2-digit" }).format(new Date());
|
|
321
|
+
const vnDateStr = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Ho_Chi_Minh", year: "numeric", month: "2-digit", day: "2-digit" }).format(date);
|
|
322
|
+
return vnDateStr < vnTodayStr;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ============================================================================
|
|
326
|
+
// Path Utilities
|
|
327
|
+
// ============================================================================
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Ensure path has prefix
|
|
331
|
+
*/
|
|
332
|
+
export function ensureWithPrefix(value: string, prefix: string): string {
|
|
333
|
+
return value.startsWith(prefix) ? value : `${prefix}${value}`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Ensure path has suffix
|
|
338
|
+
*/
|
|
339
|
+
export function ensureWithSuffix(value: string, suffix: string): string {
|
|
340
|
+
return value.endsWith(suffix) ? value : `${value}${suffix}`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Ensure path without prefix
|
|
345
|
+
*/
|
|
346
|
+
export function ensureWithoutPrefix(value: string, prefix: string): string {
|
|
347
|
+
return value.startsWith(prefix) ? value.slice(prefix.length) : value;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Ensure path without suffix
|
|
352
|
+
*/
|
|
353
|
+
export function ensureWithoutSuffix(value: string, suffix: string): string {
|
|
354
|
+
return value.endsWith(suffix) ? value.slice(0, -suffix.length) : value;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Check if pathname is active
|
|
359
|
+
*/
|
|
360
|
+
export function isActivePathname(
|
|
361
|
+
basePathname: string,
|
|
362
|
+
currentPathname: string,
|
|
363
|
+
exactMatch: boolean = false,
|
|
364
|
+
): boolean {
|
|
365
|
+
if (typeof basePathname !== "string" || typeof currentPathname !== "string") {
|
|
366
|
+
throw new Error("Both basePathname and currentPathname must be strings");
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (exactMatch) {
|
|
370
|
+
return basePathname === currentPathname;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return (
|
|
374
|
+
currentPathname.startsWith(basePathname) &&
|
|
375
|
+
(currentPathname.length === basePathname.length ||
|
|
376
|
+
currentPathname[basePathname.length] === "/")
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ============================================================================
|
|
381
|
+
// General Utilities
|
|
382
|
+
// ============================================================================
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Wait/sleep function
|
|
386
|
+
*/
|
|
387
|
+
export function wait(ms: number = 250): Promise<void> {
|
|
388
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Debounce function
|
|
393
|
+
*/
|
|
394
|
+
export function debounce<T extends (...args: unknown[]) => unknown>(
|
|
395
|
+
func: T,
|
|
396
|
+
wait: number,
|
|
397
|
+
): (...args: Parameters<T>) => void {
|
|
398
|
+
let timeout: ReturnType<typeof setTimeout> | null = null;
|
|
399
|
+
|
|
400
|
+
return (...args: Parameters<T>) => {
|
|
401
|
+
if (timeout) {
|
|
402
|
+
clearTimeout(timeout);
|
|
403
|
+
}
|
|
404
|
+
timeout = setTimeout(() => func(...args), wait);
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Deep clone object
|
|
410
|
+
*/
|
|
411
|
+
export function deepClone<T>(obj: T): T {
|
|
412
|
+
return JSON.parse(JSON.stringify(obj));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Check if value is empty
|
|
417
|
+
*/
|
|
418
|
+
export function isEmpty(value: unknown): boolean {
|
|
419
|
+
if (value === null || value === undefined) return true;
|
|
420
|
+
if (typeof value === "string") return value.trim() === "";
|
|
421
|
+
if (Array.isArray(value)) return value.length === 0;
|
|
422
|
+
if (typeof value === "object") return Object.keys(value).length === 0;
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Generate a random ID
|
|
428
|
+
*/
|
|
429
|
+
export function generateId(prefix?: string): string {
|
|
430
|
+
const id = Math.random().toString(36).substring(2, 11);
|
|
431
|
+
return prefix ? `${prefix}_${id}` : id;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Get dictionary value safely
|
|
436
|
+
*/
|
|
437
|
+
export function getDictionaryValue(
|
|
438
|
+
key: string,
|
|
439
|
+
section: Record<string, unknown>,
|
|
440
|
+
fallback?: string,
|
|
441
|
+
): string {
|
|
442
|
+
const value = section[key];
|
|
443
|
+
|
|
444
|
+
if (typeof value !== "string") {
|
|
445
|
+
if (fallback !== undefined) {
|
|
446
|
+
return fallback;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const normalizedKey = key.replace(/[-_]/g, "");
|
|
450
|
+
const normalizedValue = section[normalizedKey];
|
|
451
|
+
|
|
452
|
+
if (typeof normalizedValue === "string") {
|
|
453
|
+
return normalizedValue;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
return key;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return value;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Format overview card value based on style
|
|
464
|
+
*/
|
|
465
|
+
export function formatOverviewCardValue(
|
|
466
|
+
value: number,
|
|
467
|
+
formatStyle: FormatStyleType,
|
|
468
|
+
): string | number {
|
|
469
|
+
switch (formatStyle) {
|
|
470
|
+
case "percent":
|
|
471
|
+
return formatPercent(value);
|
|
472
|
+
case "currency":
|
|
473
|
+
return formatCurrency(value);
|
|
474
|
+
default:
|
|
475
|
+
return value.toLocaleString("vi-VN", {
|
|
476
|
+
maximumFractionDigits: 0,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ============================================================================
|
|
482
|
+
// Additional Utilities (migrated from shared-utils)
|
|
483
|
+
// ============================================================================
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Get credit card brand name from number
|
|
487
|
+
*/
|
|
488
|
+
export function getCreditCardBrandName(number: string): string {
|
|
489
|
+
const re = {
|
|
490
|
+
visa: /^4/,
|
|
491
|
+
mastercard: /^5[1-5]/,
|
|
492
|
+
amex: /^3[47]/,
|
|
493
|
+
discover: /^6(?:011|5)/,
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
for (const [type, regex] of Object.entries(re)) {
|
|
497
|
+
if (regex.test(number)) return type;
|
|
498
|
+
}
|
|
499
|
+
return "unknown";
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Convert rem to pixels
|
|
504
|
+
*/
|
|
505
|
+
export function remToPx(rem: number): number {
|
|
506
|
+
if (typeof document === "undefined") return rem * 16;
|
|
507
|
+
const rootFontSize = parseFloat(
|
|
508
|
+
getComputedStyle(document.documentElement).fontSize,
|
|
509
|
+
);
|
|
510
|
+
return rem * rootFontSize;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Check if string is a valid URL
|
|
515
|
+
*/
|
|
516
|
+
export function isUrl(text: string): boolean {
|
|
517
|
+
try {
|
|
518
|
+
new URL(text);
|
|
519
|
+
return true;
|
|
520
|
+
} catch {
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Rating to percentage string
|
|
527
|
+
*/
|
|
528
|
+
export function ratingToPercentage(
|
|
529
|
+
rating: number,
|
|
530
|
+
maxRating: number,
|
|
531
|
+
fractionDigits: number = 0,
|
|
532
|
+
): string {
|
|
533
|
+
const value = ((rating / maxRating) * 100).toFixed(fractionDigits);
|
|
534
|
+
return value + "%";
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Ensure redirect pathname with query params
|
|
539
|
+
*/
|
|
540
|
+
export function ensureRedirectPathname(
|
|
541
|
+
basePathname: string,
|
|
542
|
+
redirectPathname: string,
|
|
543
|
+
): string {
|
|
544
|
+
const searchParams = new URLSearchParams({
|
|
545
|
+
redirectTo: ensureWithoutSuffix(redirectPathname, "/"),
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
return ensureWithSuffix(basePathname, "?" + searchParams.toString());
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Get discounted price
|
|
553
|
+
*/
|
|
554
|
+
export function getDiscountedPrice(
|
|
555
|
+
price: number,
|
|
556
|
+
discountRate: number,
|
|
557
|
+
isAnnual: boolean = false,
|
|
558
|
+
): number {
|
|
559
|
+
if (isAnnual) {
|
|
560
|
+
const annualPrice = price * 12;
|
|
561
|
+
const discountedAnnualPrice = annualPrice * (1 - discountRate);
|
|
562
|
+
return discountedAnnualPrice / 12;
|
|
563
|
+
} else {
|
|
564
|
+
return price * (1 - discountRate);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Convert time string to Date
|
|
570
|
+
*/
|
|
571
|
+
export function timeToDate(timeString: string, baseDate = new Date()): Date {
|
|
572
|
+
if (!/^\d{2}:\d{2}$/.test(timeString)) {
|
|
573
|
+
throw new Error("Invalid time format. Use 'HH:mm'.");
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
const [hours, minutes] = timeString.split(":").map(Number);
|
|
577
|
+
const date = new Date(baseDate);
|
|
578
|
+
|
|
579
|
+
date.setHours(hours, minutes, 0, 0);
|
|
580
|
+
|
|
581
|
+
return date;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/**
|
|
585
|
+
* Format file type
|
|
586
|
+
*/
|
|
587
|
+
export function formatFileType(type: string): string {
|
|
588
|
+
return type.slice(0, type.lastIndexOf("/"));
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* Format date with time (Asia/Ho_Chi_Minh timezone)
|
|
593
|
+
*/
|
|
594
|
+
export function formatDateWithTime(value: string | number | Date): string {
|
|
595
|
+
const date = new Date(value);
|
|
596
|
+
return new Intl.DateTimeFormat("vi-VN", {
|
|
597
|
+
day: "2-digit",
|
|
598
|
+
month: "2-digit",
|
|
599
|
+
year: "numeric",
|
|
600
|
+
hour: "2-digit",
|
|
601
|
+
minute: "2-digit",
|
|
602
|
+
timeZone: "Asia/Ho_Chi_Minh",
|
|
603
|
+
}).format(date);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Format date short (MMM dd) - Asia/Ho_Chi_Minh timezone
|
|
608
|
+
*/
|
|
609
|
+
export function formatDateShort(value: string | number | Date): string {
|
|
610
|
+
const date = new Date(value);
|
|
611
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
612
|
+
month: "short",
|
|
613
|
+
day: "2-digit",
|
|
614
|
+
timeZone: "Asia/Ho_Chi_Minh",
|
|
615
|
+
}).format(date);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Format time (Asia/Ho_Chi_Minh timezone)
|
|
620
|
+
*/
|
|
621
|
+
export function formatTime(value: string | number | Date): string {
|
|
622
|
+
const date = new Date(value);
|
|
623
|
+
return new Intl.DateTimeFormat("en-US", {
|
|
624
|
+
hour: "numeric",
|
|
625
|
+
minute: "2-digit",
|
|
626
|
+
hour12: true,
|
|
627
|
+
timeZone: "Asia/Ho_Chi_Minh",
|
|
628
|
+
}).format(date);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Format duration from milliseconds
|
|
633
|
+
*/
|
|
634
|
+
export function formatDuration(value: string | number | Date): string {
|
|
635
|
+
const numberValue = Number(value);
|
|
636
|
+
const isNegative = numberValue < 0;
|
|
637
|
+
const absoluteValue = Math.abs(numberValue);
|
|
638
|
+
|
|
639
|
+
const hours = Math.floor(absoluteValue / 3600000);
|
|
640
|
+
const minutes = Math.floor((absoluteValue % 3600000) / 60000);
|
|
641
|
+
const seconds = Math.floor((absoluteValue % 60000) / 1000);
|
|
642
|
+
|
|
643
|
+
const parts = [];
|
|
644
|
+
if (hours) parts.push(`${hours}h`);
|
|
645
|
+
if (minutes) parts.push(`${minutes}m`);
|
|
646
|
+
if (seconds) parts.push(`${seconds}s`);
|
|
647
|
+
|
|
648
|
+
const formattedDuration = parts.join(" ") || "0s";
|
|
649
|
+
|
|
650
|
+
return isNegative ? `-${formattedDuration}` : formattedDuration;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Format distance to now
|
|
655
|
+
*/
|
|
656
|
+
export function formatDistance(value: string | number | Date): string {
|
|
657
|
+
const date = new Date(value);
|
|
658
|
+
const now = new Date();
|
|
659
|
+
const diffMs = now.getTime() - date.getTime();
|
|
660
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
661
|
+
const diffHours = Math.floor(diffMs / 3600000);
|
|
662
|
+
const diffDays = Math.floor(diffMs / 86400000);
|
|
663
|
+
|
|
664
|
+
if (diffMins < 1) return "just now";
|
|
665
|
+
if (diffMins < 60) return `${diffMins} mins ago`;
|
|
666
|
+
if (diffHours < 24) return `${diffHours} hrs ago`;
|
|
667
|
+
if (diffDays < 30) return `${diffDays} days ago`;
|
|
668
|
+
if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`;
|
|
669
|
+
return `${Math.floor(diffDays / 365)} years ago`;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Re-export status constants from configs
|
|
673
|
+
export {
|
|
674
|
+
STATUS_COLORS,
|
|
675
|
+
STATUS_ACTIVE,
|
|
676
|
+
STATUS_INACTIVE,
|
|
677
|
+
STATUS_OPTIONS,
|
|
678
|
+
STATUS_VALUES,
|
|
679
|
+
booleanToStatus,
|
|
680
|
+
statusToBoolean,
|
|
681
|
+
} from "../configs/status";
|
|
682
|
+
|
|
683
|
+
// ============================================================================
|
|
684
|
+
// Localization Utilities
|
|
685
|
+
// ============================================================================
|
|
686
|
+
|
|
687
|
+
const LOCALE_LIST = ["vi", "en"];
|
|
688
|
+
|
|
689
|
+
export function isPathnameMissingLocale(pathname: string) {
|
|
690
|
+
return !LOCALE_LIST.some((locale) => pathname.startsWith(`/${locale}`));
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
export function getLocaleFromPathname(pathname: string) {
|
|
694
|
+
return LOCALE_LIST.find((locale) => pathname.startsWith(`/${locale}`));
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
export function ensureLocalizedPathname(pathname: string, locale: string) {
|
|
698
|
+
if (!pathname || !locale)
|
|
699
|
+
throw new Error("Pathname or Locale cannot be empty");
|
|
700
|
+
return isPathnameMissingLocale(pathname)
|
|
701
|
+
? `${ensureWithPrefix(locale, "/")}${ensureWithPrefix(pathname, "/")}`
|
|
702
|
+
: pathname;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
export function relocalizePathname(pathname: string, locale: string) {
|
|
706
|
+
if (!pathname || !locale)
|
|
707
|
+
throw new Error("Pathname or Locale cannot be empty");
|
|
708
|
+
const segments = pathname.split("/");
|
|
709
|
+
segments[1] = locale;
|
|
710
|
+
return segments.join("/");
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// ============================================================================
|
|
714
|
+
// Logger
|
|
715
|
+
// ============================================================================
|
|
716
|
+
|
|
717
|
+
type LogLevel = "info" | "warn" | "error" | "debug";
|
|
718
|
+
|
|
719
|
+
interface LogEntry {
|
|
720
|
+
timestamp: string;
|
|
721
|
+
level: LogLevel;
|
|
722
|
+
message: string;
|
|
723
|
+
context?: Record<string, unknown>;
|
|
724
|
+
error?: Error | unknown;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
class Logger {
|
|
728
|
+
private log(
|
|
729
|
+
level: LogLevel,
|
|
730
|
+
message: string,
|
|
731
|
+
context?: Record<string, unknown>,
|
|
732
|
+
error?: unknown,
|
|
733
|
+
) {
|
|
734
|
+
const entry: LogEntry = {
|
|
735
|
+
timestamp: new Date().toISOString(),
|
|
736
|
+
level,
|
|
737
|
+
message,
|
|
738
|
+
context,
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
if (error instanceof Error) {
|
|
742
|
+
entry.error = {
|
|
743
|
+
name: error.name,
|
|
744
|
+
message: error.message,
|
|
745
|
+
stack: error.stack,
|
|
746
|
+
};
|
|
747
|
+
} else if (error) {
|
|
748
|
+
entry.error = error;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const logString = JSON.stringify(entry);
|
|
752
|
+
switch (level) {
|
|
753
|
+
case "error":
|
|
754
|
+
console.error(logString);
|
|
755
|
+
break;
|
|
756
|
+
case "warn":
|
|
757
|
+
console.warn(logString);
|
|
758
|
+
break;
|
|
759
|
+
case "debug":
|
|
760
|
+
if (process.env.NODE_ENV === "development") console.debug(logString);
|
|
761
|
+
break;
|
|
762
|
+
default:
|
|
763
|
+
console.log(logString);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
info(message: string, context?: Record<string, unknown>) {
|
|
768
|
+
this.log("info", message, context);
|
|
769
|
+
}
|
|
770
|
+
warn(message: string, context?: Record<string, unknown>) {
|
|
771
|
+
this.log("warn", message, context);
|
|
772
|
+
}
|
|
773
|
+
error(message: string, error?: unknown, context?: Record<string, unknown>) {
|
|
774
|
+
this.log("error", message, context, error);
|
|
775
|
+
}
|
|
776
|
+
debug(message: string, context?: Record<string, unknown>) {
|
|
777
|
+
this.log("debug", message, context);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
export const logger = new Logger();
|
|
782
|
+
|
|
783
|
+
// ============================================================================
|
|
784
|
+
// Tab Navigation Utilities
|
|
785
|
+
// ============================================================================
|
|
786
|
+
|
|
787
|
+
import type {
|
|
788
|
+
NavigationType,
|
|
789
|
+
NavigationNestedItemWithHrefType,
|
|
790
|
+
NavigationNestedItemWithItemsType,
|
|
791
|
+
DynamicIconNameType,
|
|
792
|
+
} from "../types";
|
|
793
|
+
|
|
794
|
+
export function shouldExcludeFromTabs(pathname: string): boolean {
|
|
795
|
+
const excludePatterns = [
|
|
796
|
+
"/sign-in",
|
|
797
|
+
"/sign-out",
|
|
798
|
+
"/forgot-password",
|
|
799
|
+
"/new-password",
|
|
800
|
+
"/verify-email",
|
|
801
|
+
"/register",
|
|
802
|
+
"/unauthorized",
|
|
803
|
+
"/not-found",
|
|
804
|
+
"/maintenance",
|
|
805
|
+
"/coming-soon",
|
|
806
|
+
];
|
|
807
|
+
if (
|
|
808
|
+
pathname.includes("/ui/") ||
|
|
809
|
+
pathname.includes("/colors") ||
|
|
810
|
+
pathname.includes("/typography")
|
|
811
|
+
)
|
|
812
|
+
return true;
|
|
813
|
+
return excludePatterns.some((pattern) => pathname.includes(pattern));
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
export function normalizePathname(pathname: string): string {
|
|
817
|
+
return pathname.replace(/^\/[a-z]{2}(\/|$)/, "/") || "/";
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function formatSegmentLabel(segment: string): string {
|
|
821
|
+
return segment
|
|
822
|
+
.replace(/[-_]+/g, " ")
|
|
823
|
+
.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
export function findRouteTitle(
|
|
827
|
+
pathname: string,
|
|
828
|
+
navigations: NavigationType[],
|
|
829
|
+
): string | null {
|
|
830
|
+
const result = findRouteInfo(pathname, navigations);
|
|
831
|
+
return result?.title || null;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
export function findRouteIcon(
|
|
835
|
+
pathname: string,
|
|
836
|
+
navigations: NavigationType[],
|
|
837
|
+
): DynamicIconNameType | null {
|
|
838
|
+
const result = findRouteInfo(pathname, navigations);
|
|
839
|
+
return (result?.iconName as DynamicIconNameType) || null;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
interface RouteInfo {
|
|
843
|
+
title: string;
|
|
844
|
+
iconName?: DynamicIconNameType;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
function findRouteInfo(
|
|
848
|
+
pathname: string,
|
|
849
|
+
navigations: NavigationType[],
|
|
850
|
+
): RouteInfo | null {
|
|
851
|
+
const normalizedPath = normalizePathname(pathname);
|
|
852
|
+
let exactMatch: RouteInfo | null = null;
|
|
853
|
+
const prefixMatches: Array<{ itemPath: string; routeInfo: RouteInfo }> = [];
|
|
854
|
+
|
|
855
|
+
function searchItems(
|
|
856
|
+
items:
|
|
857
|
+
| NavigationType["items"]
|
|
858
|
+
| Array<
|
|
859
|
+
NavigationNestedItemWithHrefType | NavigationNestedItemWithItemsType
|
|
860
|
+
>
|
|
861
|
+
| undefined,
|
|
862
|
+
): void {
|
|
863
|
+
if (!items) return;
|
|
864
|
+
for (const item of items) {
|
|
865
|
+
if ("href" in item && item.href) {
|
|
866
|
+
const itemPath = normalizePathname(item.href);
|
|
867
|
+
if (normalizedPath === itemPath) {
|
|
868
|
+
exactMatch = {
|
|
869
|
+
title: item.title,
|
|
870
|
+
iconName:
|
|
871
|
+
"iconName" in item
|
|
872
|
+
? (item.iconName as DynamicIconNameType | undefined)
|
|
873
|
+
: undefined,
|
|
874
|
+
};
|
|
875
|
+
} else if (normalizedPath.startsWith(itemPath + "/")) {
|
|
876
|
+
prefixMatches.push({
|
|
877
|
+
itemPath,
|
|
878
|
+
routeInfo: {
|
|
879
|
+
title: item.title,
|
|
880
|
+
iconName:
|
|
881
|
+
"iconName" in item
|
|
882
|
+
? (item.iconName as DynamicIconNameType | undefined)
|
|
883
|
+
: undefined,
|
|
884
|
+
},
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
if ("items" in item && item.items) searchItems(item.items);
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
for (const nav of navigations) {
|
|
893
|
+
searchItems(nav.items);
|
|
894
|
+
if (exactMatch) return exactMatch;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
if (prefixMatches.length > 0) {
|
|
898
|
+
prefixMatches.sort((a, b) => b.itemPath.length - a.itemPath.length);
|
|
899
|
+
return prefixMatches[0].routeInfo;
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
const segments = normalizedPath.split("/").filter(Boolean);
|
|
903
|
+
return segments.length > 0
|
|
904
|
+
? { title: formatSegmentLabel(segments[segments.length - 1]) }
|
|
905
|
+
: { title: "Home" };
|
|
906
|
+
}
|