@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/dist/components/impact-metrics-form.d.ts +26 -0
- package/dist/components/impact-metrics-form.d.ts.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +229 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +229 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/impact-metrics-form.tsx +314 -0
- package/src/index.ts +4 -0
package/package.json
CHANGED
|
@@ -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
|
|