tasks-budget.js 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
  1. import { View, Row } from 'app/design/view'
  2. import { Text } from 'app/design/typography'
  3. import { BlockWrapper } from 'app/components/block-wrapper'
  4. import { Icon } from 'app/ui/atoms/icon'
  5. import { cn } from 'app/lib/util'
  6. import { useTranslation } from 'react-i18next'
  7. const EMPTY = {}
  8. const NUMBER_OPTIONS = { minimumFractionDigits: 0, maximumFractionDigits: 2 }
  9. const PERCENT_OPTIONS = { minimumFractionDigits: 0, maximumFractionDigits: 1 }
  10. const numberFormatters = new Map()
  11. const percentFormatters = new Map()
  12. function toNumber(value) {
  13. const amount = Number(value)
  14. return Number.isFinite(amount) ? amount : 0
  15. }
  16. function getFormatter(cache, locale, options, currency) {
  17. const key = currency ? `${locale}|${currency}` : String(locale || '')
  18. let formatter = cache.get(key)
  19. if (formatter) return formatter
  20. try {
  21. formatter = new Intl.NumberFormat(
  22. locale,
  23. currency ? { style: 'currency', currency, ...options } : options
  24. )
  25. } catch {
  26. formatter = new Intl.NumberFormat(undefined, options)
  27. }
  28. cache.set(key, formatter)
  29. return formatter
  30. }
  31. function formatAmount(value, locale, currency) {
  32. return getFormatter(numberFormatters, locale, NUMBER_OPTIONS, currency).format(toNumber(value))
  33. }
  34. function formatPercent(value, locale) {
  35. return getFormatter(percentFormatters, locale, PERCENT_OPTIONS).format(toNumber(value)) + '%'
  36. }
  37. function barFillClass(spentPercent, overBudget) {
  38. if (overBudget || spentPercent >= 90) return 'bg-destructive'
  39. if (spentPercent >= 70) return 'bg-primary/70'
  40. return 'bg-primary'
  41. }
  42. function Stat({ label, value }) {
  43. return (
  44. <View className="flex-1 min-w-0 gap-0.5">
  45. <Text className="text-xs text-muted-foreground">{label}</Text>
  46. <Text className="text-base font-semibold tabular-nums text-foreground">{value}</Text>
  47. </View>
  48. )
  49. }
  50. export default function TasksBudget({ data, blockWrapperProps }) {
  51. const { t, i18n } = useTranslation()
  52. const locale = i18n.language
  53. const budget = data && typeof data === 'object' && !Array.isArray(data) ? data : EMPTY
  54. const total = toNumber(budget.total)
  55. const spent = toNumber(budget.spent)
  56. const remains = budget.remains == null ? total - spent : toNumber(budget.remains)
  57. const remainsPercent = budget.remains_percent == null
  58. ? (total > 0 ? (remains / total) * 100 : 0)
  59. : toNumber(budget.remains_percent)
  60. const currency = budget.currency || budget.currency_code
  61. const spentPercent = total > 0 ? Math.min(100, Math.max(0, (spent / total) * 100)) : 0
  62. const overBudget = remains < 0 || spent > total
  63. const remainsClass = overBudget ? 'text-destructive' : 'text-foreground'
  64. return (
  65. <BlockWrapper {...blockWrapperProps}>
  66. <View className="w-full gap-4">
  67. <Row className="items-start justify-between gap-3">
  68. <View className="flex-auto min-w-0 gap-1">
  69. <Row className="items-center gap-1.5">
  70. <Icon icon="Wallet" size={16} className="text-muted-foreground" />
  71. <Text className="text-sm text-muted-foreground">
  72. {t('tasks_budget_remaining')}
  73. </Text>
  74. </Row>
  75. <Text className={cn('text-2xl font-bold tabular-nums', remainsClass)}>
  76. {formatAmount(remains, locale, currency)}
  77. </Text>
  78. </View>
  79. <View className="bg-muted rounded-full px-2 py-1">
  80. <Text className={cn('text-xs font-medium tabular-nums', remainsClass)}>
  81. {formatPercent(remainsPercent, locale)}
  82. </Text>
  83. </View>
  84. </Row>
  85. <View
  86. className="h-2 w-full overflow-hidden rounded-full bg-muted"
  87. accessibilityRole="progressbar"
  88. accessibilityLabel={t('tasks_budget_spent')}
  89. accessibilityValue={{ min: 0, max: 100, now: Math.round(spentPercent) }}
  90. >
  91. <View
  92. className={cn('h-full rounded-full', barFillClass(spentPercent, overBudget))}
  93. style={{ width: `${spentPercent}%` }}
  94. />
  95. </View>
  96. <Row className="gap-4">
  97. <Stat label={t('tasks_budget_total')} value={formatAmount(total, locale, currency)} />
  98. <Stat label={t('tasks_budget_spent')} value={formatAmount(spent, locale, currency)} />
  99. </Row>
  100. </View>
  101. </BlockWrapper>
  102. )
  103. }