1037 lines
30 KiB
TypeScript
1037 lines
30 KiB
TypeScript
import { format } from "date-fns"
|
||
import { redirect } from "next/navigation"
|
||
import { toast } from "sonner"
|
||
import { v4 as uuidv4 } from "uuid"
|
||
import * as crypto from "crypto"
|
||
|
||
// import { districtsGeoJson } from "../../prisma/data/geojson/jember/districts-geojson"
|
||
import { CRegex } from "../constants/regex"
|
||
// import { getNavData } from "../../prisma/data/jsons/nav"
|
||
import {
|
||
DateFormatOptions,
|
||
DateFormatPattern,
|
||
} from "../types/raws/date-format.interface"
|
||
|
||
import { customAlphabet } from "nanoid"
|
||
import { IUserModel } from "@/types/models"
|
||
|
||
const prefixes: Record<string, unknown> = {}
|
||
|
||
interface IGenerateFilterIdOptions {
|
||
length?: number
|
||
separator?: string
|
||
}
|
||
|
||
// Used to track generated IDs
|
||
const usedIdRegistry = new Set<string>()
|
||
|
||
// Add type definitions for global counters
|
||
declare global {
|
||
var __idCounter: number
|
||
var __idCounterRegistry: Record<string, number>
|
||
}
|
||
|
||
/**
|
||
* Redirects to a specified path with an encoded message as a query parameter.
|
||
* @param {('error' | 'success')} type - The type of message, either 'error' or 'success'.
|
||
* @param {string} path - The path to redirect to.
|
||
* @param {string} message - The message to be encoded and added as a query parameter.
|
||
* @returns {never} This function doesn't return as it triggers a redirect.
|
||
*/
|
||
export function encodedRedirect(
|
||
type: "error" | "success",
|
||
path: string,
|
||
message: string
|
||
) {
|
||
return redirect(`${path}?${type}=${encodeURIComponent(message)}`)
|
||
}
|
||
|
||
/**
|
||
* Formats a URL by removing any trailing slashes.
|
||
* @param {string} url - The URL to format.
|
||
* @returns {string} The formatted URL.
|
||
*/
|
||
// Helper function to ensure URLs are properly formatted
|
||
export function formatUrl(url: string): string {
|
||
// If URL starts with a slash, it's already absolute
|
||
if (url.startsWith("/")) {
|
||
return url
|
||
}
|
||
|
||
// Otherwise, ensure it's properly formatted relative to root
|
||
// Remove any potential duplicated '/dashboard' prefixes
|
||
if (url.startsWith("dashboard/")) {
|
||
return "/" + url
|
||
}
|
||
|
||
return "/" + url
|
||
}
|
||
|
||
/**
|
||
* Creates a FormData object from the FormData object.
|
||
* @returns {FormData} The FormData object.
|
||
*/
|
||
export function createFormData(): FormData {
|
||
const data = new FormData()
|
||
Object.entries(FormData).forEach(([key, value]) => {
|
||
if (value) {
|
||
data.append(key, value)
|
||
}
|
||
})
|
||
return data
|
||
}
|
||
|
||
/**
|
||
* Generates a unique username based on the provided email address.
|
||
*
|
||
* The username is created by combining the local part of the email (before the '@' symbol)
|
||
* with a randomly generated alphanumeric suffix.
|
||
*
|
||
* @param email - The email address to generate the username from.
|
||
* @returns A string representing the generated username.
|
||
*
|
||
* @example
|
||
* ```typescript
|
||
* const username = generateUsername("example@gmail.com");
|
||
* console.log(username); // Output: "example.abc123" (random suffix will vary)
|
||
* ```
|
||
*/
|
||
export function generateUsername(email: string): string {
|
||
const [localPart] = email.split("@")
|
||
const randomSuffix = Math.random().toString(36).substring(2, 8) // Generate a random alphanumeric string
|
||
return `${localPart}.${randomSuffix}`
|
||
}
|
||
|
||
/**
|
||
* Formats a date string to a human-readable format with type safety.
|
||
* @param date - The date string to format.
|
||
* @param options - Formatting options or a format string.
|
||
* @returns The formatted date string.
|
||
* @example
|
||
* // Using default format
|
||
* formatDate("2025-03-23")
|
||
*
|
||
* // Using a custom format string
|
||
* formatDate("2025-03-23", "yyyy-MM-dd")
|
||
*
|
||
* // Using formatting options
|
||
* formatDate("2025-03-23", { format: "MMMM do, yyyy", fallback: "Not available" })
|
||
*/
|
||
export const formatDate = (
|
||
date: string | Date | undefined | null,
|
||
options: DateFormatOptions | DateFormatPattern = { format: "PPpp" }
|
||
): string => {
|
||
if (!date) {
|
||
return typeof options === "string" ? "-" : options.fallback || "-"
|
||
}
|
||
|
||
const dateObj = date instanceof Date ? date : new Date(date)
|
||
|
||
// Handle invalid dates
|
||
if (isNaN(dateObj.getTime())) {
|
||
return typeof options === "string" ? "-" : options.fallback || "-"
|
||
}
|
||
|
||
if (typeof options === "string") {
|
||
return format(dateObj, options)
|
||
}
|
||
|
||
const { format: formatPattern = "PPpp", locale } = options
|
||
|
||
return locale
|
||
? format(dateObj, formatPattern, { locale })
|
||
: format(dateObj, formatPattern)
|
||
}
|
||
|
||
export const copyItem = (
|
||
item: string,
|
||
options?: {
|
||
label?: string
|
||
onSuccess?: () => void
|
||
onError?: (error: unknown) => void
|
||
}
|
||
) => {
|
||
if (!navigator.clipboard) {
|
||
const error = new Error("Clipboard not supported")
|
||
toast.error("Clipboard not supported")
|
||
options?.onError?.(error)
|
||
return
|
||
}
|
||
|
||
if (!item) {
|
||
const error = new Error("Nothing to copy")
|
||
toast.error("Nothing to copy")
|
||
options?.onError?.(error)
|
||
return
|
||
}
|
||
|
||
navigator.clipboard
|
||
.writeText(item)
|
||
.then(() => {
|
||
const label = options?.label || item
|
||
toast.success(`${label} copied to clipboard`)
|
||
options?.onSuccess?.()
|
||
})
|
||
.catch((error) => {
|
||
toast.error("Failed to copy to clipboard")
|
||
options?.onError?.(error)
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Formats a date string to a human-readable format with type safety.
|
||
* @param date - The date string to format.
|
||
* @param options - Formatting options or a format string.
|
||
* @returns The formatted date string.
|
||
* @example
|
||
* // Using default format
|
||
* formatDate("2025-03-23")
|
||
*
|
||
* // Using a custom format string
|
||
* formatDate("2025-03-23", "yyyy-MM-dd")
|
||
*
|
||
* // Using formatting options
|
||
* formatDate("2025-03-23", { format: "MMMM do, yyyy", fallback: "Not available" })
|
||
*/
|
||
export const formatDateWithFallback = (
|
||
date: string | Date | undefined | null,
|
||
options: DateFormatOptions | DateFormatPattern = { format: "PPpp" }
|
||
): string => {
|
||
if (!date) {
|
||
return typeof options === "string" ? "-" : options.fallback || "-"
|
||
}
|
||
|
||
const dateObj = date instanceof Date ? date : new Date(date)
|
||
|
||
// Handle invalid dates
|
||
if (isNaN(dateObj.getTime())) {
|
||
return typeof options === "string" ? "-" : options.fallback || "-"
|
||
}
|
||
|
||
if (typeof options === "string") {
|
||
return format(dateObj, options)
|
||
}
|
||
|
||
const { format: formatPattern = "PPpp", locale } = options
|
||
|
||
return locale
|
||
? format(dateObj, formatPattern, { locale })
|
||
: format(dateObj, formatPattern)
|
||
}
|
||
|
||
export const formatDateWithLocale = (
|
||
date: string | Date | undefined | null,
|
||
options: DateFormatOptions | DateFormatPattern = { format: "PPpp" }
|
||
): string => {
|
||
if (!date) {
|
||
return typeof options === "string" ? "-" : options.fallback || "-"
|
||
}
|
||
|
||
const dateObj = date instanceof Date ? date : new Date(date)
|
||
|
||
// Handle invalid dates
|
||
if (isNaN(dateObj.getTime())) {
|
||
return typeof options === "string" ? "-" : options.fallback || "-"
|
||
}
|
||
|
||
if (typeof options === "string") {
|
||
return format(dateObj, options)
|
||
}
|
||
|
||
const { format: formatPattern = "PPpp", locale } = options
|
||
|
||
return locale
|
||
? format(dateObj, formatPattern, { locale })
|
||
: format(dateObj, formatPattern)
|
||
}
|
||
|
||
/**
|
||
* Formats a date string to a human-readable format with type safety.
|
||
* @param date - The date string to format.
|
||
* @param options - Formatting options or a format string.
|
||
* @returns The formatted date string.
|
||
* @example
|
||
* // Using default format
|
||
* formatDate("2025-03-23")
|
||
*
|
||
* // Using a custom format string
|
||
* formatDate("2025-03-23", "yyyy-MM-dd")
|
||
*
|
||
* // Using formatting options
|
||
* formatDate("2025-03-23", { format: "MMMM do, yyyy", fallback: "Not available" })
|
||
*/
|
||
export const formatDateWithLocaleAndFallback = (
|
||
date: string | Date | undefined | null,
|
||
options: DateFormatOptions | DateFormatPattern = { format: "PPpp" }
|
||
): string => {
|
||
if (!date) {
|
||
return typeof options === "string" ? "-" : options.fallback || "-"
|
||
}
|
||
|
||
const dateObj = date instanceof Date ? date : new Date(date)
|
||
|
||
// Handle invalid dates
|
||
if (isNaN(dateObj.getTime())) {
|
||
return typeof options === "string" ? "-" : options.fallback || "-"
|
||
}
|
||
|
||
if (typeof options === "string") {
|
||
return format(dateObj, options)
|
||
}
|
||
|
||
const { format: formatPattern = "PPpp", locale } = options
|
||
|
||
return locale
|
||
? format(dateObj, formatPattern, { locale })
|
||
: format(dateObj, formatPattern)
|
||
}
|
||
|
||
/**
|
||
* Generates a full name from first and last names.
|
||
* @param firstName - The first name.
|
||
* @param lastName - The last name.
|
||
* @returns The full name or "User" if both names are empty.
|
||
*/
|
||
export const getFullName = (
|
||
firstName: string | null | undefined,
|
||
lastName: string | null | undefined
|
||
): string => {
|
||
return `${firstName || ""} ${lastName || ""}`.trim() || "User"
|
||
}
|
||
|
||
/**
|
||
* Generates initials for a user based on their first and last names.
|
||
* @param firstName - The first name.
|
||
* @param lastName - The last name.
|
||
* @param email - The email address.
|
||
* @returns The initials or "U" if both names are empty.
|
||
*/
|
||
export const getInitials = (
|
||
firstName: string,
|
||
lastName: string,
|
||
email: string
|
||
): string => {
|
||
if (firstName && lastName) {
|
||
return `${firstName[0]}${lastName[0]}`.toUpperCase()
|
||
}
|
||
if (firstName) {
|
||
return firstName[0].toUpperCase()
|
||
}
|
||
if (email) {
|
||
return email[0].toUpperCase()
|
||
}
|
||
return "U"
|
||
}
|
||
|
||
export function calculateUserStats(users: IUserModel[] | undefined) {
|
||
if (!users || !Array.isArray(users)) {
|
||
return {
|
||
totalUsers: 0,
|
||
activeUsers: 0,
|
||
inactiveUsers: 0,
|
||
activePercentage: "0.0",
|
||
inactivePercentage: "0.0",
|
||
}
|
||
}
|
||
|
||
const totalUsers = users.length
|
||
const activeUsers = users.filter(
|
||
(user) => !user.banned_until && user.email_confirmed_at
|
||
).length
|
||
const inactiveUsers = totalUsers - activeUsers
|
||
|
||
return {
|
||
totalUsers,
|
||
activeUsers,
|
||
inactiveUsers,
|
||
activePercentage:
|
||
totalUsers > 0 ? ((activeUsers / totalUsers) * 100).toFixed(1) : "0.0",
|
||
inactivePercentage:
|
||
totalUsers > 0 ? ((inactiveUsers / totalUsers) * 100).toFixed(1) : "0.0",
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Generate route with query parameters
|
||
* @param baseRoute - The base route path
|
||
* @param params - Object containing query parameters
|
||
* @returns Formatted route with query parameters
|
||
*/
|
||
export const createRoute = (
|
||
baseRoute: string,
|
||
params?: Record<string, string>
|
||
): string => {
|
||
if (!params || Object.keys(params).length === 0) {
|
||
return baseRoute
|
||
}
|
||
|
||
const queryString = Object.entries(params)
|
||
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
|
||
.join("&")
|
||
|
||
return `${baseRoute}?${queryString}`
|
||
}
|
||
|
||
// Format date helper function
|
||
function formatDateV2(date: Date, formatStr: string): string {
|
||
const pad = (num: number) => String(num).padStart(2, "0")
|
||
|
||
return formatStr
|
||
.replace("yyyy", String(date.getFullYear()))
|
||
.replace("MM", pad(date.getMonth() + 1))
|
||
.replace("dd", pad(date.getDate()))
|
||
.replace("HH", pad(date.getHours()))
|
||
.replace("mm", pad(date.getMinutes()))
|
||
.replace("ss", pad(date.getSeconds()))
|
||
}
|
||
|
||
// /**
|
||
// * Universal Custom ID Generator
|
||
// * Creates structured, readable IDs for any system or entity
|
||
// *
|
||
// * @param {Object} options - Configuration options
|
||
// * @param {string} options.prefix - Primary identifier prefix (e.g., "CRIME", "USER", "INVOICE")
|
||
// * @param {Object} options.segments - Collection of ID segments to include
|
||
// * @param {string[]} options.segments.codes - Array of short codes (e.g., region codes, department codes)
|
||
// * @param {number} options.segments.year - Year to include in the ID
|
||
// * @param {number} options.segments.sequentialDigits - Number of digits for sequential number
|
||
// * @param {boolean} options.segments.includeDate - Whether to include current date
|
||
// * @param {string} options.segments.dateFormat - Format for date (e.g., "yyyy-MM-dd", "dd/MM/yyyy")
|
||
// * @param {boolean} options.segments.includeTime - Whether to include timestamp
|
||
// * @param {string} options.format - Custom format string for ID structure
|
||
// * @param {string} options.separator - Character to separate ID components
|
||
// * @param {boolean} options.upperCase - Convert result to uppercase
|
||
// * @returns {string} - Generated custom ID
|
||
// */
|
||
// export function generateId(
|
||
// options: {
|
||
// prefix?: string;
|
||
// segments?: {
|
||
// codes?: string[];
|
||
// year?: number | boolean; // Year diubah menjadi number | boolean
|
||
// sequentialDigits?: number;
|
||
// includeDate?: boolean;
|
||
// dateFormat?: string;
|
||
// includeTime?: boolean;
|
||
// includeMilliseconds?: boolean;
|
||
// };
|
||
// format?: string | null;
|
||
// separator?: string;
|
||
// upperCase?: boolean;
|
||
// randomSequence?: boolean;
|
||
// uniquenessStrategy?: 'uuid' | 'timestamp' | 'counter' | 'hash';
|
||
// retryOnCollision?: boolean;
|
||
// maxRetries?: number;
|
||
// } = {}
|
||
// ): string {
|
||
// // Jika uniquenessStrategy tidak diatur dan randomSequence = false,
|
||
// // gunakan counter sebagai strategi default
|
||
// if (!options.uniquenessStrategy && options.randomSequence === false) {
|
||
// options.uniquenessStrategy = 'counter';
|
||
// }
|
||
|
||
// const config = {
|
||
// prefix: options.prefix || 'ID',
|
||
// segments: {
|
||
// codes: options.segments?.codes || [],
|
||
// year: options.segments?.year, // Akan diproses secara kondisional nanti
|
||
// sequentialDigits: options.segments?.sequentialDigits || 6,
|
||
// includeDate: options.segments?.includeDate ?? false,
|
||
// dateFormat: options.segments?.dateFormat || 'yyyyMMdd',
|
||
// includeTime: options.segments?.includeTime ?? false,
|
||
// includeMilliseconds: options.segments?.includeMilliseconds ?? false,
|
||
// },
|
||
// format: options.format || null,
|
||
// separator: options.separator || '-',
|
||
// upperCase: options.upperCase ?? false,
|
||
// randomSequence: options.randomSequence ?? true,
|
||
// uniquenessStrategy: options.uniquenessStrategy || 'timestamp',
|
||
// retryOnCollision: options.retryOnCollision ?? true,
|
||
// maxRetries: options.maxRetries || 10,
|
||
// };
|
||
|
||
// // Initialize global counter if not exists
|
||
// if (typeof globalThis.__idCounter === 'undefined') {
|
||
// globalThis.__idCounter = 0;
|
||
// }
|
||
|
||
// const now = new Date();
|
||
|
||
// // Generate date string if needed
|
||
// let dateString = '';
|
||
// if (config.segments.includeDate) {
|
||
// dateString = format(now, config.segments.dateFormat);
|
||
// }
|
||
|
||
// // Generate time string if needed
|
||
// let timeString = '';
|
||
// if (config.segments.includeTime) {
|
||
// timeString = format(now, 'HHmmss');
|
||
// if (config.segments.includeMilliseconds) {
|
||
// timeString += now.getMilliseconds().toString().padStart(3, '0');
|
||
// }
|
||
// }
|
||
|
||
// // Generate sequential number based on uniqueness strategy
|
||
// let sequentialNum: string;
|
||
// try {
|
||
// switch (config.uniquenessStrategy) {
|
||
// case 'uuid':
|
||
// sequentialNum = uuidv4().split('-')[0];
|
||
// break;
|
||
// case 'timestamp':
|
||
// sequentialNum = `${now.getTime()}${Math.floor(Math.random() * 1000)}`;
|
||
// sequentialNum = sequentialNum.slice(-config.segments.sequentialDigits);
|
||
// break;
|
||
// case 'counter':
|
||
// sequentialNum = (++globalThis.__idCounter)
|
||
// .toString()
|
||
// .padStart(config.segments.sequentialDigits, '0');
|
||
// break;
|
||
// case 'hash':
|
||
// const hashSource = `${now.getTime()}-${JSON.stringify(options)}-${Math.random()}`;
|
||
// const hash = crypto
|
||
// .createHash('sha256')
|
||
// .update(hashSource)
|
||
// .digest('hex');
|
||
// sequentialNum = hash.substring(0, config.segments.sequentialDigits);
|
||
// break;
|
||
// default:
|
||
// if (config.randomSequence) {
|
||
// const randomBytes = crypto.randomBytes(4);
|
||
// const randomNum = parseInt(randomBytes.toString('hex'), 16);
|
||
// sequentialNum = (
|
||
// randomNum % Math.pow(10, config.segments.sequentialDigits)
|
||
// )
|
||
// .toString()
|
||
// .padStart(config.segments.sequentialDigits, '0');
|
||
// } else {
|
||
// sequentialNum = (++globalThis.__idCounter)
|
||
// .toString()
|
||
// .padStart(config.segments.sequentialDigits, '0');
|
||
// }
|
||
// }
|
||
// } catch (error) {
|
||
// console.error('Error generating sequential number:', error);
|
||
// // Fallback to timestamp strategy if other methods fail
|
||
// sequentialNum = `${now.getTime()}`.slice(-config.segments.sequentialDigits);
|
||
// }
|
||
|
||
// // Determine if year should be included and what value to use
|
||
// let yearValue = null;
|
||
// if (config.segments.year !== undefined || config.segments.year != false) {
|
||
// if (typeof config.segments.year === 'number') {
|
||
// yearValue = String(config.segments.year);
|
||
// } else if (config.segments.year === true) {
|
||
// yearValue = format(now, 'yyyy');
|
||
// }
|
||
// // if year is false, yearValue remains null and won't be included
|
||
// } else {
|
||
// // Default behavior (backward compatibility)
|
||
// yearValue = format(now, 'yyyy');
|
||
// }
|
||
|
||
// // Prepare components for ID assembly
|
||
// const components = {
|
||
// prefix: config.prefix,
|
||
// codes:
|
||
// config.segments.codes.length > 0
|
||
// ? config.segments.codes.join(config.separator)
|
||
// : '',
|
||
// year: yearValue, // Added the year value to components
|
||
// sequence: sequentialNum,
|
||
// date: dateString,
|
||
// time: timeString,
|
||
// };
|
||
|
||
// let result: string;
|
||
|
||
// // Use custom format if provided
|
||
// if (config.format) {
|
||
// let customID = config.format;
|
||
// for (const [key, value] of Object.entries(components)) {
|
||
// if (value) {
|
||
// const placeholder = `{${key}}`;
|
||
// customID = customID.replace(
|
||
// new RegExp(placeholder, 'g'),
|
||
// String(value)
|
||
// );
|
||
// }
|
||
// }
|
||
// // Remove unused placeholders
|
||
// customID = customID.replace(/{[^}]+}/g, '');
|
||
|
||
// // Clean up separators
|
||
// const escapedSeparator = config.separator.replace(
|
||
// /[-\/\\^$*+?.()|[\]{}]/g,
|
||
// '\\$&'
|
||
// );
|
||
// const separatorRegex = new RegExp(`${escapedSeparator}+`, 'g');
|
||
// customID = customID.replace(separatorRegex, config.separator);
|
||
// customID = customID.replace(
|
||
// new RegExp(`^${escapedSeparator}|${escapedSeparator}$`, 'g'),
|
||
// ''
|
||
// );
|
||
|
||
// result = config.upperCase ? customID.toUpperCase() : customID;
|
||
// } else {
|
||
// // Assemble ID from parts
|
||
// const parts = [];
|
||
// if (components.prefix) parts.push(components.prefix);
|
||
// if (components.codes) parts.push(components.codes);
|
||
// if (components.year) parts.push(components.year);
|
||
// if (components.date) parts.push(components.date);
|
||
// if (components.time) parts.push(components.time);
|
||
// if (components.sequence) parts.push(components.sequence);
|
||
|
||
// result = parts.join(config.separator);
|
||
// if (config.upperCase) result = result.toUpperCase();
|
||
// }
|
||
|
||
// // Handle collisions if required
|
||
// if (config.retryOnCollision) {
|
||
// let retryCount = 0;
|
||
// let originalResult = result;
|
||
|
||
// while (usedIdRegistry.has(result) && retryCount < config.maxRetries) {
|
||
// retryCount++;
|
||
// try {
|
||
// const suffix = crypto.randomBytes(2).toString('hex');
|
||
// result = `${originalResult}${config.separator}${suffix}`;
|
||
// } catch (error) {
|
||
// console.error('Error generating collision suffix:', error);
|
||
// // Simple fallback if crypto fails
|
||
// result = `${originalResult}${config.separator}${Date.now().toString(36)}`;
|
||
// }
|
||
// }
|
||
|
||
// if (retryCount >= config.maxRetries) {
|
||
// console.warn(
|
||
// `Warning: Max ID generation retries (${config.maxRetries}) reached for prefix ${config.prefix}`
|
||
// );
|
||
// }
|
||
// }
|
||
|
||
// // Register the ID and maintain registry size
|
||
// usedIdRegistry.add(result);
|
||
// if (usedIdRegistry.size > 10000) {
|
||
// const entriesToKeep = Array.from(usedIdRegistry).slice(-5000);
|
||
// usedIdRegistry.clear();
|
||
// entriesToKeep.forEach((id) => usedIdRegistry.add(id));
|
||
// }
|
||
|
||
// return result.trim();
|
||
// }
|
||
|
||
/**
|
||
* Generates a unique code based on the provided name.
|
||
* @param name - The name to generate the code from.
|
||
* @returns The generated code.
|
||
*/
|
||
export function generateCode(name: string): string {
|
||
const words = name.toUpperCase().split(" ")
|
||
let code = ""
|
||
|
||
if (name.length <= 3) {
|
||
code = name.toUpperCase()
|
||
} else if (words.length > 1) {
|
||
code = words
|
||
.map((w) => w[0])
|
||
.join("")
|
||
.padEnd(3, "X")
|
||
.slice(0, 3)
|
||
} else {
|
||
const nameClean = name.replace(/[aeiou]/gi, "")
|
||
code = (nameClean.slice(0, 3) || name.slice(0, 3)).toUpperCase()
|
||
}
|
||
|
||
return code
|
||
}
|
||
|
||
/**
|
||
* Generates a unique sequential ID based on a base string and existing codes.
|
||
* @param base - The base string to generate the ID from.
|
||
* @param existingCodes - A set of existing codes to check against.
|
||
* @returns The generated unique sequential ID.
|
||
*/
|
||
export function getLatestSequentialId(
|
||
base: string,
|
||
existingCodes: Set<string>
|
||
): string {
|
||
let attempt = 1
|
||
let newCode = base
|
||
|
||
while (existingCodes.has(newCode)) {
|
||
newCode = base.slice(0, 2) + attempt
|
||
attempt++
|
||
}
|
||
return newCode
|
||
}
|
||
|
||
/**
|
||
* Get color and text for a crime rate level
|
||
*/
|
||
export function getCrimeRateInfo(
|
||
rate?: "low" | "medium" | "high" | "critical"
|
||
) {
|
||
switch (rate) {
|
||
case "low":
|
||
return { color: "bg-green-100 text-green-800", text: "Low" }
|
||
case "medium":
|
||
return { color: "bg-yellow-100 text-yellow-800", text: "Medium" }
|
||
case "high":
|
||
return { color: "bg-orange-100 text-orange-800", text: "High" }
|
||
case "critical":
|
||
return { color: "bg-red-100 text-red-800", text: "Critical" }
|
||
default:
|
||
return { color: "bg-gray-100 text-gray-800", text: "Unknown" }
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Get month name from month number (1-12)
|
||
*/
|
||
export function getMonthName(month: string | number): string {
|
||
const months = [
|
||
"January",
|
||
"February",
|
||
"March",
|
||
"April",
|
||
"May",
|
||
"June",
|
||
"July",
|
||
"August",
|
||
"September",
|
||
"October",
|
||
"November",
|
||
"December",
|
||
]
|
||
|
||
const monthNum = parseInt(month.toString())
|
||
if (isNaN(monthNum) || monthNum < 1 || monthNum > 12) {
|
||
return "Invalid Month"
|
||
}
|
||
|
||
return months[monthNum - 1]
|
||
}
|
||
|
||
/**
|
||
* Format a date into a readable string
|
||
*/
|
||
export function formatDateString(date: Date | string): string {
|
||
if (!date) return "Unknown Date"
|
||
const d = typeof date === "string" ? new Date(date) : date
|
||
return d.toLocaleDateString("en-US", {
|
||
year: "numeric",
|
||
month: "long",
|
||
day: "numeric",
|
||
})
|
||
}
|
||
|
||
// Helper function to get district name from district ID
|
||
// export const getDistrictName = (districtId: string): string => {
|
||
// const feature = districtsGeoJson.features.find(
|
||
// (f) => f.properties?.kode_kec === districtId
|
||
// )
|
||
// return (
|
||
// feature?.properties?.nama ||
|
||
// feature?.properties?.kecamatan ||
|
||
// "Unknown District"
|
||
// )
|
||
// }
|
||
|
||
/**
|
||
* Format number with commas or abbreviate large numbers
|
||
*/
|
||
export function formatNumber(num?: number): string {
|
||
if (num === undefined || num === null) return "N/A"
|
||
|
||
// If number is in the thousands, abbreviate
|
||
if (num >= 1_000_000) {
|
||
return (num / 1_000_000).toFixed(1) + "M"
|
||
}
|
||
|
||
if (num >= 1_000) {
|
||
return (num / 1_000).toFixed(1) + "K"
|
||
}
|
||
|
||
// Otherwise, format with commas
|
||
return num.toLocaleString()
|
||
}
|
||
|
||
const CRIME_SEVERITY_MAP: Record<
|
||
string,
|
||
"Critical" | "High" | "Medium" | "Low"
|
||
> = {
|
||
// Critical - Highest risk of death
|
||
Pembunuhan: "Critical",
|
||
"Kelalaian Akibatkan Orang Mati": "Critical",
|
||
"Lahgun Senpi/Handak/Sajam": "Critical", // Firearms/explosives/weapons misuse
|
||
Penculikan: "Critical",
|
||
Separatisme: "Critical",
|
||
"Selundup Senpi": "Critical", // Firearms smuggling
|
||
"Kebakaran / Meletus": "Critical",
|
||
Pembakaran: "Critical",
|
||
|
||
// High - Significant risk of death or serious harm
|
||
Perkosaan: "High",
|
||
PTPPO: "High", // Human trafficking
|
||
"Trafficking In Person": "High",
|
||
"Penganiayaan Berat": "High",
|
||
Curas: "High", // Violent theft
|
||
"Pemerasan Dan Pengancaman": "High",
|
||
Premanisme: "High",
|
||
"Membahayakan Kam Umum": "High", // Endangering public safety
|
||
Pengeroyokan: "High", // Group assault
|
||
"Konflik Etnis": "High",
|
||
|
||
// Medium - Moderate risk, potential for physical harm
|
||
PKDRT: "Medium", // Domestic violence
|
||
"Penganiayaan Ringan": "Medium",
|
||
"Kelalaian Akibatkan Orang Luka": "Medium",
|
||
Curanmor: "Medium", // Vehicle theft
|
||
Curat: "Medium", // Theft with aggravating circumstances
|
||
"Pencurian Biasa": "Medium",
|
||
"Perlindungan Anak": "Medium",
|
||
"Kenakalan Remaja": "Medium",
|
||
Pengrusakan: "Medium",
|
||
"Perbuatan Tidak Menyenangkan": "Medium",
|
||
"BBM Illegal": "Medium",
|
||
"Illegal Mining": "Medium",
|
||
"Illegal Logging": "Medium",
|
||
"Illegal Fishing": "Medium",
|
||
|
||
// Low - Little to no direct risk of physical harm/death
|
||
"Pemalsuan Materai": "Low",
|
||
"Pemalsuan Surat": "Low",
|
||
Perzinahan: "Low",
|
||
"Member Suap": "Low",
|
||
Penipuan: "Low",
|
||
Agraria: "Low",
|
||
"Peradilan Anak": "Low",
|
||
Upal: "Low",
|
||
"Terhadap Ketertiban Umum": "Low",
|
||
Penghinaan: "Low",
|
||
"Sumpah Palsu": "Low",
|
||
Perjudian: "Low",
|
||
"Menerima Suap": "Low",
|
||
"Pekerjakan Anak": "Low",
|
||
Penggelapan: "Low",
|
||
"Perlindungan Saksi – Korban": "Low",
|
||
"Perlindungan TKI": "Low",
|
||
Pornografi: "Low",
|
||
Keimigrasian: "Low",
|
||
Satwa: "Low",
|
||
"Money Loudering": "Low",
|
||
"Trans Ekonomi Crime": "Low",
|
||
ITE: "Low",
|
||
"Pemerintah Daerah": "Low",
|
||
"Sistem Peradilan Anak": "Low",
|
||
"Pidum Lainnya": "Low",
|
||
"Penyelenggaraan Pemilu": "Low",
|
||
"Niaga Pupuk": "Low",
|
||
Ekstradisi: "Low",
|
||
Fidusia: "Low",
|
||
"Perlindungan Konsumen": "Low",
|
||
Korupsi: "Low",
|
||
"Pidter Lainnya": "Low",
|
||
}
|
||
|
||
export function getIncidentSeverity(
|
||
incident: any
|
||
): "Low" | "Medium" | "High" | "Critical" {
|
||
if (!incident) return "Low"
|
||
|
||
const category = incident.category || "Unknown"
|
||
|
||
// console.log(incident.category);
|
||
|
||
const criticalSeverityCategories = [
|
||
"Pembunuhan",
|
||
"Kelalaian Akibatkan Orang Mati",
|
||
"Lahgun Senpi/Handak/Sajam",
|
||
"Penculikan",
|
||
"Separatisme",
|
||
"Selundup Senpi",
|
||
"Kebakaran / Meletus",
|
||
"Pembakaran",
|
||
"Perkosaan",
|
||
]
|
||
|
||
const highSeverityCategories = [
|
||
"PTPPO",
|
||
"Trafficking In Person",
|
||
"Penganiayaan Berat",
|
||
"Curas",
|
||
"Pemerasan Dan Pengancaman",
|
||
"Premanisme",
|
||
"Membahayakan Kam Umum",
|
||
"Pengeroyokan",
|
||
"Konflik Etnis",
|
||
]
|
||
|
||
const mediumSeverityCategories = [
|
||
"PKDRT",
|
||
"Penganiayaan Ringan",
|
||
"Kelalaian Akibatkan Orang Luka",
|
||
"Curanmor",
|
||
"Curat",
|
||
"Pencurian Biasa",
|
||
"Perlindungan Anak",
|
||
"Kenakalan Remaja",
|
||
"Pengrusakan",
|
||
"Perbuatan Tidak Menyenangkan",
|
||
"BBM Illegal",
|
||
"Illegal Mining",
|
||
"Illegal Logging",
|
||
"Illegal Fishing",
|
||
"Penadahan",
|
||
"Curingan",
|
||
]
|
||
|
||
const lowSeverityCategories = [
|
||
"Pemalsuan Materai",
|
||
"Pemalsuan Surat",
|
||
"Perzinahan",
|
||
"Member Suap",
|
||
"Penipuan",
|
||
"Agraria",
|
||
"Peradilan Anak",
|
||
"Upal",
|
||
"Terhadap Ketertiban Umum",
|
||
"Penghinaan",
|
||
"Sumpah Palsu",
|
||
"Perjudian",
|
||
"Menerima Suap",
|
||
"Pekerjakan Anak",
|
||
"Penggelapan",
|
||
"Perlindungan Saksi – Korban",
|
||
"Perlindungan TKI",
|
||
"Pornografi",
|
||
"Keimigrasian",
|
||
"Satwa",
|
||
"Money Loudering",
|
||
"Trans Ekonomi Crime",
|
||
"ITE",
|
||
"Pemerintah Daerah",
|
||
"Sistem Peradilan Anak",
|
||
"Pidum Lainnya",
|
||
"Penyelenggaraan Pemilu",
|
||
"Niaga Pupuk",
|
||
"Ekstradisi",
|
||
"Fidusia",
|
||
"Perlindungan Konsumen",
|
||
"Korupsi",
|
||
"Pidter Lainnya",
|
||
]
|
||
|
||
if (criticalSeverityCategories.includes(category)) return "Critical"
|
||
if (highSeverityCategories.includes(category)) return "High"
|
||
if (mediumSeverityCategories.includes(category)) return "Medium"
|
||
if (lowSeverityCategories.includes(category)) return "Low"
|
||
|
||
return "Low"
|
||
}
|
||
|
||
export function formatMonthKey(monthKey: string): string {
|
||
const [year, month] = monthKey.split("-").map(Number)
|
||
return `${getMonthName(month)} ${year}`
|
||
}
|
||
|
||
export function getTimeAgo(timestamp: string | Date) {
|
||
const now = new Date()
|
||
const eventTime = new Date(timestamp)
|
||
const diffMs = now.getTime() - eventTime.getTime()
|
||
|
||
const diffMins = Math.floor(diffMs / 60000)
|
||
const diffHours = Math.floor(diffMins / 60)
|
||
const diffDays = Math.floor(diffHours / 24)
|
||
|
||
if (diffDays > 0) return `${diffDays} day${diffDays > 1 ? "s" : ""} ago`
|
||
if (diffHours > 0) return `${diffHours} hour${diffHours > 1 ? "s" : ""} ago`
|
||
if (diffMins > 0) return `${diffMins} minute${diffMins > 1 ? "s" : ""} ago`
|
||
return "just now"
|
||
}
|
||
|
||
// export const getBreadcrumbItems = (
|
||
// pathname: string
|
||
// ): { label: string; href: string; isLast: boolean }[] => {
|
||
// // Split the path and filter empty
|
||
// const segments = pathname.split("/").filter(Boolean)
|
||
// // Build up the path for each segment
|
||
// let path = ""
|
||
// return segments.map((seg: string, idx: number) => {
|
||
// path += "/" + seg
|
||
// // Try to find a matching nav item
|
||
// let label = seg
|
||
// // Search in navPreMain, navMain, and subItems recursively
|
||
// const findLabel = (items: any[]): string | null => {
|
||
// for (const item of items) {
|
||
// if (item.url === path) return item.title
|
||
// if (item.subItems) {
|
||
// const found = findLabel(item.subItems)
|
||
// if (found) return found
|
||
// }
|
||
// if (item.subSubItems) {
|
||
// const found = findLabel(item.subSubItems)
|
||
// if (found) return found
|
||
// }
|
||
// }
|
||
// return null
|
||
// }
|
||
// label =
|
||
// findLabel(
|
||
// getNavData({
|
||
// id: "0",
|
||
// name: "John Doe",
|
||
// email: "john.doe@example.com",
|
||
// avatar: "https://avatars.githubusercontent.com/u/1486366",
|
||
// }).NavPreMain
|
||
// ) ||
|
||
// findLabel(
|
||
// getNavData({
|
||
// id: "0",
|
||
// name: "John Doe",
|
||
// email: "john.doe@example.com",
|
||
// avatar: "https://avatars.githubusercontent.com/u/1486366",
|
||
// }).reports
|
||
// ) ||
|
||
// seg.charAt(0).toUpperCase() + seg.slice(1)
|
||
// return {
|
||
// label,
|
||
// href: path,
|
||
// isLast: idx === segments.length - 1,
|
||
// }
|
||
// })
|
||
// }
|
||
|
||
export function generateFilterId(
|
||
prefixOrOptions?: keyof typeof prefixes | IGenerateFilterIdOptions,
|
||
inputOptions: IGenerateFilterIdOptions = {}
|
||
) {
|
||
const finalOptions =
|
||
typeof prefixOrOptions === "object" ? prefixOrOptions : inputOptions
|
||
|
||
const prefix =
|
||
typeof prefixOrOptions === "object" ? undefined : prefixOrOptions
|
||
|
||
const { length = 12, separator = "_" } = finalOptions
|
||
const id = customAlphabet(
|
||
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz",
|
||
length
|
||
)()
|
||
|
||
return prefix ? `${prefixes[prefix]}${separator}${id}` : id
|
||
}
|
||
|
||
export function extractTimeRangeFilter(
|
||
filters: any[]
|
||
): [number, number] | null {
|
||
if (!Array.isArray(filters)) return null
|
||
const timeRangeFilter = filters.find(
|
||
(f) =>
|
||
f.id === "timeRange" && Array.isArray(f.value) && f.value.length === 2
|
||
)
|
||
return timeRangeFilter ? timeRangeFilter.value : null
|
||
}
|