| 1 | 'use client' |
| 2 | |
| 3 | import { Plus, X } from 'lucide-react' |
| 4 | import { useCallback, useEffect |
| 5 | import { Button } |
| 6 | import { |
| 7 | Popover, |
| 8 | PopoverContent, |
| 9 | PopoverTrigger, |
| 10 | } from '@/components/ui/popover' |
| 11 | import { |
| 12 | Select, |
| 13 | SelectContent, |
| 14 | SelectItem, |
| 15 | SelectTrigger, |
| 16 | SelectValue, |
| 17 | } from '@/components/ui/select' |
| 18 | import { |
| 19 | InvoiceNumberGeneratorEngine, |
| 20 | type InvoiceTemplateVariable, |
| 21 | } from '@/services/invoice-number.service' |
| 22 | |
| 23 | type Part |
| 24 | | { type |
| 25 | | { type |
| 26 | |
| 27 | type EnumSpec |
| 28 | type NumberSpec |
| 29 | |
| 30 | const TOKEN_REGEX = / |
| 31 | |
| 32 | function parse(template: |
| 33 | const parts: |
| 34 | let lastIndex = |
| 35 | for (const match |
| 36 | const idx = |
| 37 | const id = |
| 38 | if (!validIds.has |
| 39 | continue |
| 40 | } |
| 41 | if (idx > |
| 42 | parts.push({ |
| 43 | } |
| 44 | parts.push({ |
| 45 | lastIndex = idx |
| 46 | } |
| 47 | if (lastIndex < |
| 48 | parts.push({ |
| 49 | } |
| 50 | return parts |
| 51 | } |
| 52 | |
| 53 | function serialize(parts: |
| 54 | return parts |
| 55 | .map((p) |
| 56 | if (p.type |
| 57 | return p.value |
| 58 | } |
| 59 | if (p.arg |
| 60 | return `%${p |
| 61 | } |
| 62 | return `%${p |
| 63 | }) |
| 64 | .join('') |
| 65 | } |
| 66 | |
| 67 | function normalize(parts: |
| 68 | const merged: |
| 69 | for (const p |
| 70 | const last = |
| 71 | if (p.type |
| 72 | last.value += |
| 73 | } else { |
| 74 | merged.push({ |
| 75 | } |
| 76 | } |
| 77 | const withSeps: |
| 78 | for (let i |
| 79 | const prev = |
| 80 | const curr = |
| 81 | if (prev?.type |
| 82 | withSeps.push({ |
| 83 | } |
| 84 | withSeps.push(curr |
| 85 | } |
| 86 | if (withSeps[0] |
| 87 | withSeps.unshift({ |
| 88 | } |
| 89 | if (withSeps.at |
| 90 | withSeps.push({ |
| 91 | } |
| 92 | return withSeps |
| 93 | } |
| 94 | |
| 95 | export function InvoiceNumberTemplateInput({ |
| 96 | value, |
| 97 | onChange, |
| 98 | previewSequence = 1, |
| 99 | timezone, |
| 100 | }: { |
| 101 | value: string |
| 102 | onChange: (value |
| 103 | previewSequence?: number |
| 104 | timezone?: string |
| 105 | }) { |
| 106 | const variables = |
| 107 | () => InvoiceNumberGeneratorEngine. |
| 108 | [] |
| 109 | ) |
| 110 | const variableMap = |
| 111 | () => new |
| 112 | [variables] |
| 113 | ) |
| 114 | const validIds = |
| 115 | () => new |
| 116 | [variables] |
| 117 | ) |
| 118 | |
| 119 | const parts = |
| 120 | () => normalize( |
| 121 | [value, validIds] |
| 122 | ) |
| 123 | |
| 124 | const preview = |
| 125 | () => |
| 126 | InvoiceNumberGeneratorEngine.generateInvoiceNumber(value |
| 127 | sequence: previewSequence, |
| 128 | timezone, |
| 129 | }), |
| 130 | [value, previewSequence, |
| 131 | ) |
| 132 | |
| 133 | const commit = |
| 134 | (next: Part |
| 135 | onChange(serialize(next |
| 136 | }, |
| 137 | [onChange] |
| 138 | ) |
| 139 | |
| 140 | const updatePart = |
| 141 | (index: number |
| 142 | commit(parts.map |
| 143 | }, |
| 144 | [parts, commit] |
| 145 | ) |
| 146 | |
| 147 | const removePart = |
| 148 | (index: number |
| 149 | commit(parts.filter |
| 150 | }, |
| 151 | [parts, commit] |
| 152 | ) |
| 153 | |
| 154 | const addVariable = |
| 155 | (variable: InvoiceTemplateVariable |
| 156 | const token: |
| 157 | type: 'token', |
| 158 | id: variable.id |
| 159 | arg: |
| 160 | variable.arg.type |
| 161 | ? undefined |
| 162 | : String(variable. |
| 163 | } |
| 164 | commit([...parts, |
| 165 | }, |
| 166 | [parts, commit] |
| 167 | ) |
| 168 | |
| 169 | return ( |
| 170 | <div className='space-y-2 |
| 171 | <div className='flex flex-wrap items-center gap-1 rounded-md border bg-background p-2 |
| 172 | {parts.map(( |
| 173 | if (p.type |
| 174 | return ( |
| 175 | <TextSegment |
| 176 | key={`text-${i |
| 177 | onChange={(val) |
| 178 | placeholder={i === |
| 179 | value={p.value |
| 180 | /> |
| 181 | ) |
| 182 | } |
| 183 | const variable = |
| 184 | if (!variable) { |
| 185 | return null |
| 186 | } |
| 187 | return ( |
| 188 | <TokenChip |
| 189 | arg={p.arg |
| 190 | key={`token-${i |
| 191 | onArgChange={(arg) |
| 192 | onRemove={() => |
| 193 | variable={variable} |
| 194 | /> |
| 195 | ) |
| 196 | })} |
| 197 | |
| 198 | <AddVariablePopover onSelect={ |
| 199 | </div> |
| 200 | |
| 201 | <div className='flex items-center gap-2 text-muted-foreground text-xs |
| 202 | <span>Preview:</span> |
| 203 | <code className='rounded bg-muted px-1.5 py-0.5 font-mono text-foreground |
| 204 | {preview || '— |
| 205 | </code> |
| 206 | </div> |
| 207 | </div> |
| 208 | ) |
| 209 | } |
| 210 | |
| 211 | function TextSegment({ |
| 212 | value, |
| 213 | onChange, |
| 214 | placeholder, |
| 215 | }: { |
| 216 | value: string |
| 217 | onChange: (value |
| 218 | placeholder: string |
| 219 | }) { |
| 220 | const width = |
| 221 | return ( |
| 222 | <input |
| 223 | aria-label={placeholder} |
| 224 | className='h-7 rounded border-0 bg-transparent px-1 font-mono text-sm outline-none placeholder:text-muted-foreground/50 focus:bg-muted/50' |
| 225 | onChange={(e) |
| 226 | placeholder={placeholder} |
| 227 | style={{ width |
| 228 | value={value} |
| 229 | /> |
| 230 | ) |
| 231 | } |
| 232 | |
| 233 | function TokenChip({ |
| 234 | variable, |
| 235 | arg, |
| 236 | onArgChange, |
| 237 | onRemove, |
| 238 | }: { |
| 239 | variable: InvoiceTemplateVariable |
| 240 | arg?: string |
| 241 | onArgChange: (arg |
| 242 | onRemove: () |
| 243 | }) { |
| 244 | return ( |
| 245 | <span className='inline-flex h-7 items-center gap-1 rounded-md border border-primary/30 bg-primary/10 pr-1 pl-2 text-primary text-xs |
| 246 | <span className='font-medium |
| 247 | {variable.arg.type |
| 248 | <EnumArgControl |
| 249 | onChange={onArgChange} |
| 250 | spec={variable.arg |
| 251 | value={arg ?? |
| 252 | /> |
| 253 | )} |
| 254 | {variable.arg.type |
| 255 | <NumberArgControl |
| 256 | onChange={onArgChange} |
| 257 | spec={variable.arg |
| 258 | value={arg ?? |
| 259 | /> |
| 260 | )} |
| 261 | <button |
| 262 | aria-label={`Remove ${variable |
| 263 | className='rounded p-0.5 hover:bg-primary/20' |
| 264 | onClick={onRemove} |
| 265 | type='button' |
| 266 | > |
| 267 | <X className=' |
| 268 | </button> |
| 269 | </span> |
| 270 | ) |
| 271 | } |
| 272 | |
| 273 | function EnumArgControl({ |
| 274 | spec, |
| 275 | value, |
| 276 | onChange, |
| 277 | }: { |
| 278 | spec: EnumSpec |
| 279 | value: string |
| 280 | onChange: (arg |
| 281 | }) { |
| 282 | return ( |
| 283 | <Select onValueChange={ |
| 284 | <SelectTrigger className=' |
| 285 | <SelectValue /> |
| 286 | </SelectTrigger> |
| 287 | <SelectContent> |
| 288 | {spec.options.map |
| 289 | <SelectItem key={ |
| 290 | {opt.label} |
| 291 | </SelectItem> |
| 292 | ))} |
| 293 | </SelectContent> |
| 294 | </Select> |
| 295 | ) |
| 296 | } |
| 297 | |
| 298 | function NumberArgControl({ |
| 299 | spec, |
| 300 | value, |
| 301 | onChange, |
| 302 | }: { |
| 303 | spec: NumberSpec |
| 304 | value: string |
| 305 | onChange: (arg |
| 306 | }) { |
| 307 | const [draft, |
| 308 | |
| 309 | useEffect(() => |
| 310 | setDraft(value) |
| 311 | }, [value]) |
| 312 | |
| 313 | const handleChange = |
| 314 | setDraft(raw) |
| 315 | if (raw === |
| 316 | return |
| 317 | } |
| 318 | const num = |
| 319 | if (!Number.isFinite |
| 320 | return |
| 321 | } |
| 322 | const str = |
| 323 | if (str !== |
| 324 | onChange(str) |
| 325 | } |
| 326 | } |
| 327 | |
| 328 | const handleBlur = |
| 329 | const num = |
| 330 | if (draft === |
| 331 | setDraft(value) |
| 332 | return |
| 333 | } |
| 334 | const clamped = |
| 335 | const str = |
| 336 | setDraft(str) |
| 337 | if (str !== |
| 338 | onChange(str) |
| 339 | } |
| 340 | } |
| 341 | |
| 342 | return ( |
| 343 | <input |
| 344 | className='w-10 rounded bg-primary/10 px-1 text-center text-primary text-xs outline-none [appearance:textfield] focus:bg-primary/20 [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none' |
| 345 | max={spec.max |
| 346 | min={spec.min |
| 347 | onBlur={handleBlur} |
| 348 | onChange={(e) |
| 349 | type='number' |
| 350 | value={draft} |
| 351 | /> |
| 352 | ) |
| 353 | } |
| 354 | |
| 355 | function AddVariablePopover({ |
| 356 | variables, |
| 357 | onSelect, |
| 358 | }: { |
| 359 | variables: InvoiceTemplateVariable |
| 360 | onSelect: (variable |
| 361 | }) { |
| 362 | const [open, |
| 363 | return ( |
| 364 | <Popover onOpenChange={ |
| 365 | <PopoverTrigger asChild> |
| 366 | <Button className=' |
| 367 | <Plus className=' |
| 368 | Add variable |
| 369 | </Button> |
| 370 | </PopoverTrigger> |
| 371 | <PopoverContent align=' |
| 372 | <ul className='max-h-72 overflow-auto |
| 373 | {variables.map(( |
| 374 | <li key={v |
| 375 | <button |
| 376 | className='flex w-full flex-col items-start gap-0.5 rounded-sm px-2 py-1.5 text-left hover:bg-accent' |
| 377 | onClick={() => |
| 378 | onSelect(v) |
| 379 | setOpen(false) |
| 380 | }} |
| 381 | type='button' |
| 382 | > |
| 383 | <span className='flex items-center gap-2 |
| 384 | <span className='font-medium text-sm |
| 385 | <code className='rounded bg-muted px-1 font-mono text-muted-foreground text-xs |
| 386 | {v.sample} |
| 387 | </code> |
| 388 | </span> |
| 389 | <span className='text-muted-foreground text-xs leading-snug |
| 390 | {v.description} |
| 391 | </span> |
| 392 | </button> |
| 393 | </li> |
| 394 | ))} |
| 395 | </ul> |
| 396 | </PopoverContent> |
| 397 | </Popover> |
| 398 | ) |
| 399 | } |