1175 lines
36 KiB
TypeScript
1175 lines
36 KiB
TypeScript
import { format } from 'date-fns';
|
|
import { redirect } from 'next/navigation';
|
|
import {
|
|
DateFormatOptions,
|
|
DateFormatPattern,
|
|
} from './types/date-format.interface';
|
|
import { toast } from 'sonner';
|
|
import { IUserSchema } from '@/src/entities/models/users/users.model';
|
|
import db from '../../prisma/db';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import * as crypto from 'crypto';
|
|
import { CRIME_RATE_COLORS } from './const/map';
|
|
import { districtsGeoJson } from '../../prisma/data/geojson/jember/districts';
|
|
|
|
// 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: IUserSchema[] | 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();
|
|
// }
|
|
|
|
/**
|
|
* Gets the last ID from a specified table and column.
|
|
* @param tableName - The name of the table to query.
|
|
* @param columnName - The column containing the IDs.
|
|
* @returns The last ID as a string, or null if no records exist.
|
|
*/
|
|
export async function getLastId(
|
|
tableName: string,
|
|
columnName: string
|
|
): Promise<string | null> {
|
|
try {
|
|
const result = await db.$queryRawUnsafe(
|
|
`SELECT ${columnName} FROM ${tableName} ORDER BY ${columnName} DESC LIMIT 1`
|
|
);
|
|
|
|
if (Array.isArray(result) && result.length > 0) {
|
|
return result[0][columnName];
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching last ID:', error);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
|
|
export function getIncidentSeverity(
|
|
incident: any
|
|
): 'Low' | 'Medium' | 'High' | 'Critical' {
|
|
if (!incident) return 'Low';
|
|
|
|
const category = incident.category || 'Unknown';
|
|
|
|
const highSeverityCategories = [
|
|
'Pembunuhan',
|
|
'Perkosaan',
|
|
'Penculikan',
|
|
'Lahgun Senpi/Handak/Sajam',
|
|
'PTPPO',
|
|
'Trafficking In Person',
|
|
];
|
|
|
|
const mediumSeverityCategories = [
|
|
'Penganiayaan Berat',
|
|
'Penganiayaan Ringan',
|
|
'Pencurian Biasa',
|
|
'Curat',
|
|
'Curas',
|
|
'Curanmor',
|
|
'Pengeroyokan',
|
|
'PKDRT',
|
|
'Penggelapan',
|
|
'Pengrusakan',
|
|
];
|
|
|
|
if (highSeverityCategories.includes(category)) return 'High';
|
|
if (mediumSeverityCategories.includes(category)) return 'Medium';
|
|
|
|
if (incident.type === 'Pidana Tertentu') return 'Medium';
|
|
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';
|
|
}
|
|
|
|
/**
|
|
* Helper function to extract numeric counter from an ID string
|
|
* @param id The ID to extract counter from
|
|
* @param pattern The pattern to identify the counter portion
|
|
* @returns The numeric counter value
|
|
*/
|
|
function extractCounterFromId(id: string, pattern: RegExp): number {
|
|
const match = id.match(pattern);
|
|
if (match && match[1]) {
|
|
return parseInt(match[1], 10);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Retrieves the last ID from a specific table and extracts its counter
|
|
* @param tableName The table to query
|
|
* @param counterPattern RegExp pattern to extract counter (with capture group)
|
|
* @param orderByField Field to order by (usually 'id' or 'createdAt')
|
|
* @returns The last used counter number
|
|
*/
|
|
export async function getLastIdCounter(
|
|
tableName: string,
|
|
counterPattern: RegExp,
|
|
orderByField: string = 'id'
|
|
): Promise<number> {
|
|
try {
|
|
// Dynamic query to get the last record from the specified table
|
|
const result = await db.$queryRawUnsafe(
|
|
`SELECT ${orderByField} FROM "${tableName}" ORDER BY "${orderByField}" DESC LIMIT 1`
|
|
);
|
|
|
|
// Extract the ID from the result
|
|
if (result && Array.isArray(result) && result.length > 0) {
|
|
const lastId = result[0][orderByField];
|
|
if (lastId) {
|
|
return extractCounterFromId(lastId, counterPattern);
|
|
}
|
|
}
|
|
|
|
return 0; // No records found, start from 0
|
|
} catch (error) {
|
|
console.error(`Error fetching last ID from ${tableName}:`, error);
|
|
return 0; // Return 0 on error (will start new sequence)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Generate an ID with counter continuation from database
|
|
* @param tableName Prisma table name to check for last ID
|
|
* @param options ID generation options
|
|
* @param counterPattern RegExp pattern to extract counter (with capture group)
|
|
* @returns Generated ID string
|
|
*/
|
|
export async function generateIdWithDbCounter(
|
|
tableName: string,
|
|
options: {
|
|
prefix?: string;
|
|
segments?: {
|
|
codes?: string[];
|
|
year?: number | boolean;
|
|
sequentialDigits?: number;
|
|
includeDate?: boolean;
|
|
dateFormat?: string;
|
|
includeTime?: boolean;
|
|
includeMilliseconds?: boolean;
|
|
};
|
|
format?: string | null;
|
|
separator?: string;
|
|
upperCase?: boolean;
|
|
uniquenessStrategy?: 'counter';
|
|
retryOnCollision?: boolean;
|
|
maxRetries?: number;
|
|
} = {},
|
|
counterPattern: RegExp = /(\d+)$/
|
|
): Promise<string> {
|
|
// Override uniquenessStrategy to ensure we use counter
|
|
options.uniquenessStrategy = 'counter';
|
|
// Initialize the counter registry if it doesn't exist
|
|
if (!globalThis.__idCounterRegistry) {
|
|
globalThis.__idCounterRegistry = {} as Record<string, number>;
|
|
}
|
|
|
|
|
|
// Get the last counter from the database
|
|
let lastCounter;
|
|
if (tableName === 'units') {
|
|
lastCounter = await getLastIdCounter(
|
|
tableName,
|
|
counterPattern,
|
|
'code_unit'
|
|
);
|
|
} else {
|
|
lastCounter = await getLastIdCounter(tableName, counterPattern);
|
|
}
|
|
|
|
// Initialize or update the counter for this specific prefix
|
|
if (globalThis.__idCounterRegistry[tableName] === undefined) {
|
|
globalThis.__idCounterRegistry[tableName] = lastCounter;
|
|
} else {
|
|
// Ensure the counter is at least as large as the last DB counter
|
|
globalThis.__idCounterRegistry[tableName] = Math.max(
|
|
globalThis.__idCounterRegistry[tableName],
|
|
lastCounter
|
|
);
|
|
}
|
|
|
|
// Store the prefix-specific counter value
|
|
const currentCounter = globalThis.__idCounterRegistry[tableName];
|
|
|
|
// Set the global counter to this prefix's counter for the generateId function
|
|
globalThis.__idCounter = currentCounter;
|
|
|
|
// Generate the ID using the existing function
|
|
const generatedId = generateId(options);
|
|
|
|
// Update the prefix's counter after generation
|
|
globalThis.__idCounterRegistry[tableName] = globalThis.__idCounter;
|
|
|
|
return generatedId;
|
|
}
|
|
|
|
export function generateId(
|
|
options: {
|
|
prefix?: string;
|
|
segments?: {
|
|
codes?: string[];
|
|
year?: 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 = crypto.randomUUID().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();
|
|
}
|
|
|