@dilipod/ui 0.4.3 → 0.4.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dilipod/ui",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "Dilipod Design System - Shared UI components and styles",
5
5
  "author": "Dilipod <hello@dilipod.com>",
6
6
  "license": "MIT",
@@ -0,0 +1,314 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { Card, CardContent, CardHeader, CardTitle } from './card'
5
+ import { Button } from './button'
6
+ import { IconBox } from './icon-box'
7
+ import { toast } from './use-toast'
8
+ import { cn } from '../lib/utils'
9
+
10
+ // Working hours per FTE per year
11
+ // 40 hrs/week × 47 weeks (52 - 4 vacation - 1 holidays) = 1,880 hours
12
+ const HOURS_PER_FTE_YEAR = 1880
13
+
14
+ // Price per worker by tier (euros/month)
15
+ const TIER_PRICING: Record<string, number> = {
16
+ starter: 29,
17
+ growth: 25,
18
+ scale: 21,
19
+ enterprise: 21,
20
+ }
21
+
22
+ export interface ImpactMetrics {
23
+ time_saved_minutes_per_run: number
24
+ hourly_rate_euros: number
25
+ fte_equivalent: number
26
+ estimated_annual_savings_euros: number
27
+ }
28
+
29
+ export interface ImpactMetricsFormProps {
30
+ /** The worker/agent ID */
31
+ workerId: string
32
+ /** Initial metrics values */
33
+ initialMetrics: ImpactMetrics
34
+ /** Total executions for this worker */
35
+ totalExecutions?: number
36
+ /** Customer's pricing plan */
37
+ customerPlan?: string
38
+ /** API endpoint to save metrics (e.g., "/api/workers" or "/api/agents") */
39
+ apiBasePath?: string
40
+ /** Optional custom save handler - if provided, will be called instead of default API */
41
+ onSave?: (workerId: string, metrics: ImpactMetrics) => Promise<boolean>
42
+ /** Whether to show toast notifications */
43
+ showToasts?: boolean
44
+ /** Custom class name */
45
+ className?: string
46
+ }
47
+
48
+ export function ImpactMetricsForm({
49
+ workerId,
50
+ initialMetrics,
51
+ totalExecutions = 0,
52
+ customerPlan = 'starter',
53
+ apiBasePath = '/api/workers',
54
+ onSave,
55
+ showToasts = true,
56
+ className,
57
+ }: ImpactMetricsFormProps) {
58
+ const [metrics, setMetrics] = useState(initialMetrics)
59
+ const [savedMetrics, setSavedMetrics] = useState(initialMetrics)
60
+ const [isSaving, setIsSaving] = useState(false)
61
+
62
+ // Check if metrics have been saved before (non-default values in DB)
63
+ const isInitiallySaved =
64
+ initialMetrics.time_saved_minutes_per_run !== 30 ||
65
+ initialMetrics.hourly_rate_euros !== 20 ||
66
+ initialMetrics.fte_equivalent !== 0.1
67
+
68
+ const [isEditing, setIsEditing] = useState(!isInitiallySaved)
69
+
70
+ // Worker cost based on customer's plan
71
+ const workerCostPerMonth = TIER_PRICING[customerPlan] || TIER_PRICING.starter
72
+ const workerCostPerYear = workerCostPerMonth * 12
73
+
74
+ // Calculate estimated annual savings based on FTE equivalent minus worker cost
75
+ const calculateAnnualSavings = (fteEquivalent: number, hourlyRate: number) => {
76
+ const laborSavings = fteEquivalent * HOURS_PER_FTE_YEAR * hourlyRate
77
+ return laborSavings - workerCostPerYear
78
+ }
79
+
80
+ const handleSave = async () => {
81
+ setIsSaving(true)
82
+ try {
83
+ const annualSavings = calculateAnnualSavings(
84
+ metrics.fte_equivalent,
85
+ metrics.hourly_rate_euros
86
+ )
87
+
88
+ const updatedMetrics = { ...metrics, estimated_annual_savings_euros: annualSavings }
89
+
90
+ let success = false
91
+
92
+ if (onSave) {
93
+ // Use custom save handler
94
+ success = await onSave(workerId, updatedMetrics)
95
+ } else {
96
+ // Use default API
97
+ const response = await fetch(`${apiBasePath}/${workerId}/impact-metrics`, {
98
+ method: 'PATCH',
99
+ headers: { 'Content-Type': 'application/json' },
100
+ body: JSON.stringify(updatedMetrics),
101
+ })
102
+ success = response.ok
103
+ }
104
+
105
+ if (success) {
106
+ setMetrics(updatedMetrics)
107
+ setSavedMetrics(updatedMetrics)
108
+ setIsEditing(false)
109
+ if (showToasts) {
110
+ toast({
111
+ title: 'Metrics saved',
112
+ description: 'Impact metrics have been updated.',
113
+ variant: 'success',
114
+ })
115
+ }
116
+ } else {
117
+ throw new Error('Failed to save')
118
+ }
119
+ } catch (error) {
120
+ console.error('Failed to save metrics:', error)
121
+ if (showToasts) {
122
+ toast({
123
+ title: 'Failed to save',
124
+ description: 'Could not save metrics. Please try again.',
125
+ variant: 'error',
126
+ })
127
+ }
128
+ } finally {
129
+ setIsSaving(false)
130
+ }
131
+ }
132
+
133
+ const handleCancel = () => {
134
+ setMetrics(savedMetrics)
135
+ setIsEditing(false)
136
+ }
137
+
138
+ // Calculate hours saved per year based on FTE
139
+ const hoursSavedPerYear = Math.round(metrics.fte_equivalent * HOURS_PER_FTE_YEAR)
140
+
141
+ // Calculate labor savings (before worker cost)
142
+ const laborSavingsPerYear = metrics.fte_equivalent * HOURS_PER_FTE_YEAR * metrics.hourly_rate_euros
143
+
144
+ // Net annual savings
145
+ const netAnnualSavings = laborSavingsPerYear - workerCostPerYear
146
+
147
+ return (
148
+ <Card className={cn("border-[var(--cyan)]/20 bg-gradient-to-br from-white to-[var(--cyan)]/5", className)}>
149
+ <CardHeader className="pb-3">
150
+ <div className="flex items-center justify-between">
151
+ <CardTitle className="flex items-center gap-2">
152
+ <IconBox size="sm">
153
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 256 256">
154
+ <path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-88a16,16,0,1,1-16-16A16,16,0,0,1,144,128Zm-56,0a16,16,0,1,1-16-16A16,16,0,0,1,88,128Zm112,0a16,16,0,1,1-16-16A16,16,0,0,1,200,128Z"/>
155
+ </svg>
156
+ </IconBox>
157
+ Impact Metrics (ROI)
158
+ </CardTitle>
159
+ <div className="flex items-center gap-2">
160
+ {isEditing ? (
161
+ <>
162
+ {isInitiallySaved && (
163
+ <Button
164
+ onClick={handleCancel}
165
+ size="sm"
166
+ variant="ghost"
167
+ >
168
+ Cancel
169
+ </Button>
170
+ )}
171
+ <Button
172
+ onClick={handleSave}
173
+ disabled={isSaving}
174
+ size="sm"
175
+ >
176
+ {isSaving ? 'Saving...' : 'Save'}
177
+ </Button>
178
+ </>
179
+ ) : (
180
+ <Button
181
+ onClick={() => setIsEditing(true)}
182
+ size="sm"
183
+ variant="outline"
184
+ >
185
+ Edit
186
+ </Button>
187
+ )}
188
+ </div>
189
+ </div>
190
+ </CardHeader>
191
+ <CardContent>
192
+ <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
193
+ {/* Time per task */}
194
+ <div className="flex items-start gap-3">
195
+ <div className="p-2 rounded-sm bg-[var(--cyan)]/10 shrink-0">
196
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256" className="text-[var(--cyan)]">
197
+ <path d="M128,40a96,96,0,1,0,96,96A96.11,96.11,0,0,0,128,40Zm0,176a80,80,0,1,1,80-80A80.09,80.09,0,0,1,128,216ZM173.66,90.34a8,8,0,0,1,0,11.32l-40,40a8,8,0,0,1-11.32-11.32l40-40A8,8,0,0,1,173.66,90.34ZM96,16a8,8,0,0,1,8-8h48a8,8,0,0,1,0,16H104A8,8,0,0,1,96,16Z"/>
198
+ </svg>
199
+ </div>
200
+ <div className="flex-1">
201
+ <label className="text-sm text-muted-foreground block mb-1">Time per Task</label>
202
+ {isEditing ? (
203
+ <div className="flex items-center gap-2">
204
+ <input
205
+ type="number"
206
+ value={metrics.time_saved_minutes_per_run}
207
+ onChange={(e) => setMetrics(prev => ({
208
+ ...prev,
209
+ time_saved_minutes_per_run: parseInt(e.target.value) || 0
210
+ }))}
211
+ className="w-16 px-2 py-1 text-lg font-bold border border-border rounded-sm focus:outline-none focus:ring-2 focus:ring-[var(--cyan)] bg-background"
212
+ min="0"
213
+ />
214
+ <span className="text-muted-foreground">min</span>
215
+ </div>
216
+ ) : (
217
+ <p className="text-2xl font-bold">
218
+ {metrics.time_saved_minutes_per_run} <span className="text-base font-normal text-muted-foreground">min</span>
219
+ </p>
220
+ )}
221
+ <p className="text-xs text-muted-foreground mt-1">How long manually</p>
222
+ </div>
223
+ </div>
224
+
225
+ {/* Manual cost */}
226
+ <div className="flex items-start gap-3">
227
+ <div className="p-2 rounded-sm bg-[var(--cyan)]/10 shrink-0">
228
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256" className="text-[var(--cyan)]">
229
+ <path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm0-144a8,8,0,0,1,8,8v4.4c14.25,3.14,24,14.43,24,30.6,0,4.42-3.58,8-8,8s-8-3.58-8-8c0-8.64-7.18-13-16-13s-16,4.36-16,13,7.18,13,16,13c17.64,0,32,11.35,32,29,0,16.17-9.75,27.46-24,30.6V192a8,8,0,0,1-16,0v-4.4c-14.25-3.14-24-14.43-24-30.6a8,8,0,0,1,16,0c0,8.64,7.18,13,16,13s16-4.36,16-13-7.18-13-16-13c-17.64,0-32-11.35-32-29,0-16.17,9.75-27.46,24-30.6V80A8,8,0,0,1,128,72Z"/>
230
+ </svg>
231
+ </div>
232
+ <div className="flex-1">
233
+ <label className="text-sm text-muted-foreground block mb-1">Manual Cost</label>
234
+ {isEditing ? (
235
+ <div className="flex items-center gap-2">
236
+ <span className="text-muted-foreground">€</span>
237
+ <input
238
+ type="number"
239
+ value={metrics.hourly_rate_euros}
240
+ onChange={(e) => setMetrics(prev => ({
241
+ ...prev,
242
+ hourly_rate_euros: parseFloat(e.target.value) || 0
243
+ }))}
244
+ className="w-16 px-2 py-1 text-lg font-bold border border-border rounded-sm focus:outline-none focus:ring-2 focus:ring-[var(--cyan)] bg-background"
245
+ min="0"
246
+ step="0.5"
247
+ />
248
+ <span className="text-muted-foreground">/hr</span>
249
+ </div>
250
+ ) : (
251
+ <p className="text-2xl font-bold">
252
+ €{metrics.hourly_rate_euros} <span className="text-base font-normal text-muted-foreground">/hr</span>
253
+ </p>
254
+ )}
255
+ <p className="text-xs text-muted-foreground mt-1">Employee hourly cost</p>
256
+ </div>
257
+ </div>
258
+
259
+ {/* Job portion */}
260
+ <div className="flex items-start gap-3">
261
+ <div className="p-2 rounded-sm bg-[var(--cyan)]/10 shrink-0">
262
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256" className="text-[var(--cyan)]">
263
+ <path d="M230.92,212c-15.23-26.33-38.7-45.21-66.09-54.16a72,72,0,1,0-73.66,0C63.78,166.78,40.31,185.66,25.08,212a8,8,0,1,0,13.85,8c18.84-32.56,52.14-52,89.07-52s70.23,19.44,89.07,52a8,8,0,1,0,13.85-8ZM72,96a56,56,0,1,1,56,56A56.06,56.06,0,0,1,72,96Z"/>
264
+ </svg>
265
+ </div>
266
+ <div className="flex-1">
267
+ <label className="text-sm text-muted-foreground block mb-1">Job Portion</label>
268
+ {isEditing ? (
269
+ <div className="flex items-center gap-2">
270
+ <input
271
+ type="number"
272
+ value={Math.round(metrics.fte_equivalent * 100)}
273
+ onChange={(e) => setMetrics(prev => ({
274
+ ...prev,
275
+ fte_equivalent: (parseFloat(e.target.value) || 0) / 100
276
+ }))}
277
+ className="w-16 px-2 py-1 text-lg font-bold border border-border rounded-sm focus:outline-none focus:ring-2 focus:ring-[var(--cyan)] bg-background"
278
+ min="0"
279
+ max="1000"
280
+ step="5"
281
+ />
282
+ <span className="text-muted-foreground">%</span>
283
+ </div>
284
+ ) : (
285
+ <p className="text-2xl font-bold">
286
+ {Math.round(metrics.fte_equivalent * 100)} <span className="text-base font-normal text-muted-foreground">%</span>
287
+ </p>
288
+ )}
289
+ <p className="text-xs text-muted-foreground mt-1">% of FTE ({hoursSavedPerYear}h/year)</p>
290
+ </div>
291
+ </div>
292
+
293
+ {/* Net Annual Savings */}
294
+ <div className="flex items-start gap-3">
295
+ <div className="p-2 rounded-sm bg-[var(--cyan)]/10 shrink-0">
296
+ <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256" className="text-[var(--cyan)]">
297
+ <path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm0-144a8,8,0,0,1,8,8v4.4c14.25,3.14,24,14.43,24,30.6,0,4.42-3.58,8-8,8s-8-3.58-8-8c0-8.64-7.18-13-16-13s-16,4.36-16,13,7.18,13,16,13c17.64,0,32,11.35,32,29,0,16.17-9.75,27.46-24,30.6V192a8,8,0,0,1-16,0v-4.4c-14.25-3.14-24-14.43-24-30.6a8,8,0,0,1,16,0c0,8.64,7.18,13,16,13s16-4.36,16-13-7.18-13-16-13c-17.64,0-32-11.35-32-29,0-16.17,9.75-27.46,24-30.6V80A8,8,0,0,1,128,72Z"/>
298
+ </svg>
299
+ </div>
300
+ <div className="flex-1">
301
+ <label className="text-sm text-muted-foreground block mb-1">Net Annual Savings</label>
302
+ <p className={cn("text-2xl font-bold", netAnnualSavings >= 0 ? "text-[var(--cyan)]" : "text-red-500")}>
303
+ €{netAnnualSavings.toLocaleString(undefined, { maximumFractionDigits: 0 })}
304
+ </p>
305
+ <p className="text-xs text-muted-foreground mt-1">
306
+ €{laborSavingsPerYear.toLocaleString(undefined, { maximumFractionDigits: 0 })} labor − €{workerCostPerYear} worker
307
+ </p>
308
+ </div>
309
+ </div>
310
+ </div>
311
+ </CardContent>
312
+ </Card>
313
+ )
314
+ }
package/src/index.ts CHANGED
@@ -270,6 +270,10 @@ export type { SettingsNavItem, SettingsNavGroup, SettingsNavProps, SettingsNavLi
270
270
  export { ScenariosManager } from './components/scenarios-manager'
271
271
  export type { Scenario, ScenarioType, ScenarioSuggestion, ScenariosManagerProps } from './components/scenarios-manager'
272
272
 
273
+ // Impact Metrics Components
274
+ export { ImpactMetricsForm } from './components/impact-metrics-form'
275
+ export type { ImpactMetrics, ImpactMetricsFormProps } from './components/impact-metrics-form'
276
+
273
277
  // Utilities
274
278
  export { cn } from './lib/utils'
275
279