@kahitsan/ksui 0.3.0 → 0.5.0

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.
Files changed (38) hide show
  1. package/README.md +121 -26
  2. package/host-ui.d.ts +4 -4
  3. package/package.json +7 -3
  4. package/src/components/{AccountAvatar.tsx → base/AccountAvatar.tsx} +10 -10
  5. package/src/components/base/ChartLegend.tsx +34 -0
  6. package/src/components/base/CopyButton.tsx +53 -0
  7. package/src/components/base/DateTile.tsx +84 -0
  8. package/src/components/base/DetailRow.tsx +17 -0
  9. package/src/components/{ExistingAttachmentTile.tsx → base/ExistingAttachmentTile.tsx} +2 -2
  10. package/src/components/base/FormErrorBanner.tsx +27 -0
  11. package/src/components/base/FormField.tsx +19 -0
  12. package/src/components/base/ImageCropper.tsx +275 -0
  13. package/src/components/base/KpiCard.tsx +125 -0
  14. package/src/components/base/ProgressBar.tsx +328 -0
  15. package/src/components/base/RadioCardGroup.tsx +146 -0
  16. package/src/components/base/SegmentedFilter.tsx +50 -0
  17. package/src/components/base/StatusPill.tsx +90 -0
  18. package/src/components/base/TagPill.tsx +24 -0
  19. package/src/components/base/Tooltip.tsx +64 -0
  20. package/src/components/{ClientPicker.tsx → composite/ClientPicker.tsx} +1 -1
  21. package/src/components/composite/FormActions.tsx +79 -0
  22. package/src/components/composite/LiveTimer.tsx +434 -0
  23. package/src/components/{MarkdownNotes.tsx → composite/MarkdownNotes.tsx} +1 -1
  24. package/src/components/{PaymentAccountPicker.tsx → composite/PaymentAccountPicker.tsx} +2 -2
  25. package/src/components/composite/SecretReveal.tsx +63 -0
  26. package/src/components/{VoucherPicker.tsx → composite/VoucherPicker.tsx} +1 -1
  27. package/src/index.ts +85 -27
  28. package/src/utils/INPUT_CLASS.ts +7 -0
  29. package/src/{lib → utils}/account-logo-url.ts +1 -1
  30. package/src/{lib → utils}/accounts-index.tsx +2 -2
  31. package/src/{lib → utils}/attachments.ts +3 -3
  32. package/src/utils/formatFullDate.ts +14 -0
  33. package/src/utils/formatPHP.ts +13 -0
  34. package/src/utils/formatShortDate.ts +17 -0
  35. /package/src/components/{AddAttachmentTile.tsx → base/AddAttachmentTile.tsx} +0 -0
  36. /package/src/components/{CameraCapture.tsx → base/CameraCapture.tsx} +0 -0
  37. /package/src/components/{MentionTextarea.tsx → composite/MentionTextarea.tsx} +0 -0
  38. /package/src/{lib → utils}/account-icons.ts +0 -0
package/README.md CHANGED
@@ -1,45 +1,140 @@
1
1
  # @kahitsan/ksui
2
2
 
3
- The single canonical copy of the shared SolidJS UI components that KahitSan/Hilinga
4
- plugins consume — pickers, the mention textarea, markdown notes, the camera +
5
- attachment tiles, and the data-driven account avatar — plus the `@kserp/host-ui`
6
- ambient type contract for the host UI kit.
3
+ [![npm version](https://img.shields.io/npm/v/@kahitsan/ksui.svg)](https://www.npmjs.com/package/@kahitsan/ksui)
4
+ [![npm downloads](https://img.shields.io/npm/dm/@kahitsan/ksui.svg)](https://www.npmjs.com/package/@kahitsan/ksui)
5
+ [![license](https://img.shields.io/npm/l/@kahitsan/ksui.svg)](./LICENSE)
7
6
 
8
- Extracted from `kserp/packages/plugin-ui` (`@kahitsan/plugin-ui`) into its own repo
9
- so the UI package versions and publishes independently of the kernel.
7
+ A set of UI components for SolidJS apps, built and used by the KahitSan team.
8
+
9
+ On npm: [@kahitsan/ksui](https://www.npmjs.com/package/@kahitsan/ksui). Docs:
10
+ [ksui.kahitsan.com](https://ksui.kahitsan.com/).
11
+
12
+ ## What this is
13
+
14
+ `@kahitsan/ksui` is a small library of ready-made user interface pieces for
15
+ [SolidJS](https://www.solidjs.com/). SolidJS is a way to build web pages with
16
+ reusable components, a bit like React but with its own way of working. These
17
+ components are written for SolidJS only. They will not work in React or Vue.
18
+
19
+ Inside you will find small building-block components, ready-made widgets built
20
+ from them, and a few non-visual helpers. The full, always-current list lives in
21
+ the docs (see below), so this README stays short on purpose.
22
+
23
+ ## Where you can use it
24
+
25
+ There are two ways to use KSUI.
26
+
27
+ 1. **In your own SolidJS project.** Install it from npm and use the components
28
+ like any other dependency. It is MIT licensed, so you are free to use it in
29
+ personal or commercial work. See the License section below.
30
+ 2. **As a contributor to KahitSan plugins.** If you build or help with our
31
+ plugins, these are the shared components you reuse instead of writing your
32
+ own copy.
33
+
34
+ One honest note so nothing surprises you. Some components are self-contained and
35
+ work right away. Others, like the client, voucher, and payment account pickers,
36
+ expect a backend that answers certain requests, so they fit best inside an app
37
+ built the KahitSan way. A few components also expect a small set of shared UI
38
+ pieces from your app, like a button and a dialog. The setup note below explains
39
+ that part, and each component page says what it needs.
40
+
41
+ ## What is inside
42
+
43
+ The package is organized into three kinds of exports:
44
+
45
+ - **Base components** are the small building blocks, like form fields, status
46
+ pills, tooltips, progress bars, and avatars.
47
+ - **Composite components** combine those building blocks into ready-made widgets,
48
+ like the search-and-pick selectors and the notes editor.
49
+ - **Utils** are non-visual helpers, like the peso and date formatters and the
50
+ safe link builders.
51
+
52
+ We do not list every component here on purpose, so this README does not fall out
53
+ of date as the set grows. The complete, current catalog, with a live example and
54
+ the props for each one, is in the docs:
55
+
56
+ **https://ksui.kahitsan.com/**
10
57
 
11
58
  ## Install
12
59
 
13
- Published to the **public npm registry** (npmjs.com) as `@kahitsan/ksui` no
14
- registry config or auth needed:
60
+ `@kahitsan/ksui` is on the public npm registry, so no extra registry setup or
61
+ login is needed.
15
62
 
16
63
  ```
17
64
  npm install @kahitsan/ksui
18
65
  ```
19
66
 
20
- ## How it ships
67
+ You also need a SolidJS app. If you are starting fresh, set one up with
68
+ [`vite-plugin-solid`](https://github.com/solidjs/vite-plugin-solid), which is the
69
+ tool that compiles Solid components.
21
70
 
22
- This is a SolidJS component library shipped as **source** under a `solid` export
23
- condition (see `package.json`). The consumer's `vite-plugin-solid` compiles the
24
- components, while `solid-js` and `@kserp/host-ui` stay **externalized** to the host
25
- runtime globals — so the component source is bundled into the plugin IIFE exactly
26
- as a local copy would be: one Solid instance, the host UI kit reused. `lucide-solid`
27
- is bundled from the consumer's own deps.
71
+ ## A tiny usage example
28
72
 
29
- Consumers must keep `solid-js` + `@kserp/host-ui` externalized in their
30
- `vite.remote.config.ts`, and (until a `.d.ts` bundle ships) reference the host kit
31
- types via the `./host-ui` export:
73
+ ```tsx
74
+ import { createSignal } from "solid-js";
75
+ import { MentionTextarea } from "@kahitsan/ksui";
32
76
 
33
- ```ts
34
- /// <reference types="@kahitsan/ksui/host-ui" />
77
+ function NotesField() {
78
+ const [value, setValue] = createSignal("");
79
+ return (
80
+ <MentionTextarea
81
+ value={value()}
82
+ setValue={setValue}
83
+ placeholder="Add notes, type @ to tag a client"
84
+ rows={3}
85
+ />
86
+ );
87
+ }
35
88
  ```
36
89
 
37
- ## Type-checking
90
+ The avatar chip is also easy and needs no backend:
91
+
92
+ ```tsx
93
+ import { AccountAvatar } from "@kahitsan/ksui";
94
+
95
+ <AccountAvatar account={{ id: 0, type: "user", name: "Maria Cruz" }} size={32} />;
96
+ ```
97
+
98
+ ## The honest setup note
99
+
100
+ Two things this library expects from the app around it:
101
+
102
+ 1. **`solid-js` stays external.** This library ships as source, and your own
103
+ `vite-plugin-solid` compiles it. Keep `solid-js` as a single shared copy in
104
+ your app (not bundled twice), so there is only one Solid instance running.
105
+
106
+ 2. **A host UI kit under the name `@kserp/host-ui`.** Some components import
107
+ shared pieces from your app, like a `Button`, a `confirm` dialog, and a few
108
+ hooks. Your app provides these under the import name `@kserp/host-ui`, and that
109
+ name is kept external at build time too. Components that need it are:
110
+ MarkdownNotes, ClientPicker, CameraCapture, ExistingAttachmentTile, and the
111
+ `useAccountsIndex` hook. The rest do not need it.
112
+
113
+ So a few components work right away with no backend and no host kit
114
+ (MentionTextarea as a plain notes box, AccountAvatar, AddAttachmentTile,
115
+ VoucherPicker, and all the helper functions). Others assume the ERP host and a
116
+ backend that answers calls like `/api/clients`. The docs site walks through how
117
+ to mock the host kit and stub those calls so you can see every component render.
118
+
119
+ ## Docs
120
+
121
+ The full documentation site, with a component-by-component guide and setup steps,
122
+ will be at:
123
+
124
+ https://ksui.kahitsan.com/
125
+
126
+ ## Contributing
127
+
128
+ We welcome help. Please read [CONTRIBUTING.md](./CONTRIBUTING.md) for how to set
129
+ up the repo, the code style we follow, and how to send a change.
130
+
131
+ ## License
38
132
 
39
- `npm run typecheck` runs `tsc --noEmit` standalone (`jsxImportSource: solid-js`, the
40
- shipped `host-ui.d.ts` in scope). The authoritative gate remains each consuming
41
- plugin's own `tsc`, where the plugin's `lucide-solid` and host runtime are present.
133
+ MIT. See [LICENSE](./LICENSE). You are free to use KSUI in your own personal or
134
+ commercial SolidJS projects, as long as you keep the MIT license notice. Source
135
+ lives at https://github.com/KahitSan/ksui.
42
136
 
43
- ## Publishing
137
+ ---
44
138
 
45
- Push a `v*` tag (e.g. `v0.3.0`); the release workflow publishes to GitHub Packages.
139
+ Created with by [Luis Edward Miranda](https://github.com/llupRisinglll) for
140
+ KahitSan Corp.
package/host-ui.d.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  // CANONICAL SDK type defs for the host UI kit (window.__KSERP_UI__, externalized
2
- // as "@kserp/host-ui"). This ships in @kahitsan/plugin-ui and is the single
3
- // source of truth every plugin (ours, and any third-party with no kernel
2
+ // as "@kserp/host-ui"). This ships in @kahitsan/ksui and is the single
3
+ // source of truth. Every plugin (ours, and any third-party with no kernel
4
4
  // source) gets it from the installed package via
5
- // `/// <reference types="@kahitsan/plugin-ui/host-ui" />`; there are no more
5
+ // `/// <reference types="@kahitsan/ksui/host-ui" />`; there are no more
6
6
  // per-plugin copies to drift. The host owns the runtime: its remote loader
7
7
  // (kserp src/lib/remote-loader.ts) populates the global from the host's kit
8
8
  // barrel (kserp src/lib/host-ui.tsx) before loading any remote. Keep this in sync
9
- // with that barrel every member here must be exported there, and vice-versa.
9
+ // with that barrel. Every member here must be exported there, and vice versa.
10
10
  declare module "@kserp/host-ui" {
11
11
  import type { JSX, Accessor } from "solid-js";
12
12
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kahitsan/ksui",
3
- "version": "0.3.0",
4
- "description": "ksui shared SolidJS UI components + the @kserp/host-ui type contract for KahitSan/Hilinga plugins. Published to GitHub Packages and consumed as a normal dependency. Ships source under a `solid` export condition so the consumer's vite-plugin-solid compiles it with solid-js + @kserp/host-ui externalized to the host runtime.",
3
+ "version": "0.5.0",
4
+ "description": "ksui is a set of shared SolidJS UI components plus the @kserp/host-ui type contract for KahitSan/Hilinga plugins. Published to the public npm registry and consumed as a normal dependency. Ships source under a `solid` export condition so the consumer's vite-plugin-solid compiles it with solid-js + @kserp/host-ui externalized to the host runtime.",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
7
7
  "types": "./src/index.ts",
@@ -21,7 +21,10 @@
21
21
  "host-ui.d.ts"
22
22
  ],
23
23
  "scripts": {
24
- "typecheck": "tsc --noEmit"
24
+ "typecheck": "tsc --noEmit",
25
+ "changeset": "changeset",
26
+ "version": "changeset version",
27
+ "release": "changeset publish"
25
28
  },
26
29
  "dependencies": {
27
30
  "lucide-solid": "^1.7.0"
@@ -30,6 +33,7 @@
30
33
  "solid-js": "^1.9.0"
31
34
  },
32
35
  "devDependencies": {
36
+ "@changesets/cli": "^2.31.0",
33
37
  "solid-js": "^1.9.0",
34
38
  "typescript": "^5.6.0"
35
39
  },
@@ -1,15 +1,15 @@
1
- // AccountAvatar renders a small visual chip for an account or a user.
1
+ // AccountAvatar renders a small visual chip for an account or a user.
2
2
  //
3
3
  // Two distinct semantics share this chip, and the chip picks by intent:
4
4
  //
5
- // • ACCOUNT (a financial account the default). Shows the account's
5
+ // • ACCOUNT (a financial account, the default). Shows the account's
6
6
  // uploaded logo, else its chosen icon glyph (the slug picked in the
7
7
  // financial-accounts create/edit modal, or the type default). NEVER an
8
8
  // initials circle, and the container is a ROUNDED SQUARE (rounded-md).
9
- // This matches the /financial-accounts page exactly a financial
9
+ // This matches the /financial-accounts page exactly. A financial
10
10
  // account is not a person, so it must not render as an initials avatar.
11
11
  //
12
- // • USER (a person badge calendar entries, mention lists, timesheet
12
+ // • USER (a person badge for calendar entries, mention lists, timesheet
13
13
  // attribution). Shows the profile photo, else an initial-on-color
14
14
  // CIRCLE, in a circular (rounded-full) container. Callers opt in with
15
15
  // `{ id: 0, type: 'user', name: 'Myra Abilay', image }` (the `type:
@@ -21,10 +21,10 @@
21
21
 
22
22
  import { Show } from "solid-js";
23
23
  import { Dynamic } from "solid-js/web";
24
- import { getAccountIcon } from "../lib/account-icons";
25
- import { buildLogoSrc } from "../lib/account-logo-url";
24
+ import { getAccountIcon } from "../../utils/account-icons";
25
+ import { buildLogoSrc } from "../../utils/account-logo-url";
26
26
 
27
- // Shared 16-color palette and initials algorithm keep in lockstep with
27
+ // Shared 16-color palette and initials algorithm. Keep in lockstep with
28
28
  // the kserp's ~/lib/avatar.ts so the host runtime and the plugin fleet
29
29
  // render the same chip for the same user. Exported so any future widget
30
30
  // (or a caller's inline use) can derive a user's color/initials without
@@ -52,7 +52,7 @@ export function getAvatarColor(name: string): string {
52
52
 
53
53
  /** Build a base64 SVG data URL for the initial-on-color circle. Because it
54
54
  * renders as an `<img>`, the chip scales uniformly with every other source
55
- * (photo, s3 logo) no per-plugin/inline text-size drift. The viewBox is
55
+ * (photo, s3 logo), so there is no per-plugin/inline text-size drift. The viewBox is
56
56
  * 100×100 and the font-size is calculated to keep 2-character initials
57
57
  * comfortably inside the circle. */
58
58
  export function buildInitialsSvg(name: string): string {
@@ -81,7 +81,7 @@ export interface AvatarAccount {
81
81
  * account variant, which never renders initials. */
82
82
  name?: string;
83
83
  /** User profile photo URL (e.g. Google identity provider). USER variant
84
- * only higher priority than the initials fallback. */
84
+ * only. Higher priority than the initials fallback. */
85
85
  image?: string | null;
86
86
  }
87
87
 
@@ -136,7 +136,7 @@ export default function AccountAvatar(props: AccountAvatarProps) {
136
136
  when={isUser()}
137
137
  fallback={
138
138
  // ACCOUNT: uploaded logo (rounded square), else the chosen icon
139
- // glyph. No initials a financial account is not a person.
139
+ // glyph. No initials, since a financial account is not a person.
140
140
  <Show when={props.account.s3_link} fallback={iconGlyph()}>
141
141
  <img
142
142
  src={buildLogoSrc(props.account.s3_link)}
@@ -0,0 +1,34 @@
1
+ // ChartLegend renders a single legend entry for a chart: a small colored dot,
2
+ // an uppercase label, and a value beneath it. Used by cashflow / analytics
3
+ // charts to label each series (money in, money out, net) with its current
4
+ // total. Purely presentational; the caller supplies the dot color class, the
5
+ // label, the formatted value, and an optional value color override.
6
+
7
+ interface ChartLegendProps {
8
+ /** Tailwind background class for the legend dot (e.g. "bg-emerald-400"). */
9
+ dot: string;
10
+ /** Uppercase label shown above the value (e.g. "Money in"). */
11
+ label: string;
12
+ /** Pre-formatted value string (e.g. a currency string). */
13
+ value: string;
14
+ /** Optional Tailwind text color class for the value. Defaults to text-zinc-100. */
15
+ valueColor?: string;
16
+ }
17
+
18
+ export default function ChartLegend(props: ChartLegendProps) {
19
+ return (
20
+ <div class="flex items-center gap-2">
21
+ <span class={`w-2 h-2 rounded-sm ${props.dot}`} />
22
+ <div class="flex flex-col">
23
+ <span class="text-[9px] uppercase tracking-widest text-zinc-500 font-medium leading-tight">
24
+ {props.label}
25
+ </span>
26
+ <span
27
+ class={`text-xs font-bold tabular-nums leading-tight ${props.valueColor || "text-zinc-100"}`}
28
+ >
29
+ {props.value}
30
+ </span>
31
+ </div>
32
+ </div>
33
+ );
34
+ }
@@ -0,0 +1,53 @@
1
+ import { createSignal, onCleanup, type JSX } from "solid-js";
2
+ import Copy from "lucide-solid/icons/copy";
3
+ import Check from "lucide-solid/icons/check";
4
+
5
+ interface CopyButtonProps {
6
+ /** The text written to the clipboard on click. Caller owns the value. */
7
+ text: string;
8
+ /** Label shown in the default state. Default "Copy". */
9
+ label?: string;
10
+ /** Label shown for ~1.5s after a successful copy. Default "Copied". */
11
+ copiedLabel?: string;
12
+ /** Accessible label for the button. Default "Copy". */
13
+ ariaLabel?: string;
14
+ /** Icon size in px. Default 12. */
15
+ size?: number;
16
+ /** Extra classes on the button. */
17
+ class?: string;
18
+ }
19
+
20
+ // Self-contained copy-to-clipboard button. Owns its own "copied" signal:
21
+ // on click it writes props.text via navigator.clipboard, flips copied true
22
+ // for ~1500ms, swaps the label, and shows the lucide Check icon while copied
23
+ // (Copy icon otherwise). The pending timer is cleared on cleanup so a copy
24
+ // flash never fires after the button unmounts. No domain coupling — the
25
+ // caller passes the text and any label overrides.
26
+ export default function CopyButton(props: CopyButtonProps): JSX.Element {
27
+ const [copied, setCopied] = createSignal(false);
28
+ let timer: ReturnType<typeof setTimeout> | undefined;
29
+
30
+ const handleClick = () => {
31
+ navigator.clipboard.writeText(props.text).then(() => {
32
+ setCopied(true);
33
+ if (timer) clearTimeout(timer);
34
+ timer = setTimeout(() => setCopied(false), 1500);
35
+ });
36
+ };
37
+
38
+ onCleanup(() => {
39
+ if (timer) clearTimeout(timer);
40
+ });
41
+
42
+ return (
43
+ <button
44
+ type="button"
45
+ onClick={handleClick}
46
+ aria-label={props.ariaLabel ?? "Copy"}
47
+ class={`ks-interactive px-2 py-1.5 rounded bg-zinc-800 border border-zinc-700 text-zinc-300 hover:text-white text-xs flex items-center gap-1 ${props.class ?? ""}`}
48
+ >
49
+ {copied() ? <Check size={props.size ?? 12} /> : <Copy size={props.size ?? 12} />}
50
+ {copied() ? (props.copiedLabel ?? "Copied") : (props.label ?? "Copy")}
51
+ </button>
52
+ );
53
+ }
@@ -0,0 +1,84 @@
1
+ // DateTile — a compact calendar day cell.
2
+ //
3
+ // A 60x68 tile with three stacked parts: a small top band (e.g. a month
4
+ // label), a big primary value (e.g. the day-of-month), and an optional
5
+ // muted sub-label (e.g. hours worked, a count, a short note). It is the
6
+ // visual unit a caller repeats into a ledger-style row of days — scanned
7
+ // like a register, not rendered as a full month grid.
8
+ //
9
+ // Domain-free by design: it knows nothing about timesheets, payroll, or any
10
+ // data shape. The caller formats every string it shows (`topLabel`,
11
+ // `value`, `subLabel`) and owns selection state. Pass `onToggle` to make the
12
+ // tile an interactive, selectable button (with `aria-pressed`); omit it for a
13
+ // static, read-only cell that renders dimmed so a row can show which tiles are
14
+ // "in" vs "out" of a selection at a glance.
15
+
16
+ import { Show, type JSX } from "solid-js";
17
+ import { Dynamic } from "solid-js/web";
18
+
19
+ export interface DateTileProps {
20
+ /** The big primary value — typically a day-of-month number, but any short
21
+ * string works (the caller formats it). */
22
+ value: number | string;
23
+ /** Small uppercase band above the value, e.g. a month label ("MAY"). */
24
+ topLabel?: string;
25
+ /** Muted line below the value, e.g. "10h" or a short count. */
26
+ subLabel?: string;
27
+ /** Selected state. For interactive tiles this drives the amber accent +
28
+ * `aria-pressed`; ignored for read-only tiles (they always render "on"). */
29
+ selected?: boolean;
30
+ /** Make the tile a selectable button. When omitted the tile is a static,
31
+ * non-interactive cell. */
32
+ onToggle?: () => void;
33
+ /** Optional test id, applied only to interactive tiles. */
34
+ testId?: string;
35
+ }
36
+
37
+ export default function DateTile(props: DateTileProps): JSX.Element {
38
+ const interactive = (): boolean => !!props.onToggle;
39
+ const on = (): boolean => (interactive() ? props.selected ?? false : true);
40
+ return (
41
+ <Dynamic
42
+ component={interactive() ? "button" : "div"}
43
+ type={interactive() ? "button" : undefined}
44
+ onClick={props.onToggle}
45
+ aria-pressed={interactive() ? on() : undefined}
46
+ data-testid={interactive() ? props.testId : undefined}
47
+ style={{ width: "60px", height: "68px", flex: "0 0 auto" }}
48
+ class={`${interactive() ? "cursor-pointer" : ""} flex flex-col rounded-md overflow-hidden border transition-colors`}
49
+ classList={{
50
+ "border-amber-500/25 bg-zinc-800/40": on(),
51
+ "border-zinc-800/60 bg-zinc-900/40 opacity-50 hover:opacity-80": !on(),
52
+ }}
53
+ >
54
+ <div
55
+ style={{ height: "18px" }}
56
+ class="flex items-center justify-center text-[9px] font-semibold uppercase tracking-[0.15em] bg-zinc-800/70 text-zinc-500"
57
+ >
58
+ {props.topLabel ?? ""}
59
+ </div>
60
+ <div class="flex-1 flex flex-col items-center justify-center">
61
+ <div
62
+ class="text-xl font-semibold leading-none tabular-nums"
63
+ classList={{
64
+ "text-amber-400/90": on(),
65
+ "text-zinc-500": !on(),
66
+ }}
67
+ >
68
+ {props.value}
69
+ </div>
70
+ <Show when={props.subLabel != null && props.subLabel !== ""}>
71
+ <div
72
+ class="text-[9px] mt-0.5 tabular-nums"
73
+ classList={{
74
+ "text-zinc-500": on(),
75
+ "text-zinc-700": !on(),
76
+ }}
77
+ >
78
+ {props.subLabel}
79
+ </div>
80
+ </Show>
81
+ </div>
82
+ </Dynamic>
83
+ );
84
+ }
@@ -0,0 +1,17 @@
1
+ // DetailRow renders a single labeled read-only field: a small muted label
2
+ // stacked above its value, with an em-dash placeholder when the value is
3
+ // empty. Used in detail/view surfaces (e.g. the client detail modal) to lay
4
+ // out a record's fields uniformly.
5
+ //
6
+ // Ported verbatim from kplugin_clients/ui/remote/index.tsx. It is purely
7
+ // presentational with no external dependencies, so the only adaptation is
8
+ // the move into the shared kit.
9
+
10
+ export default function DetailRow(props: { label: string; value: string | null | undefined }) {
11
+ return (
12
+ <div>
13
+ <span class="text-xs text-zinc-500 block">{props.label}</span>
14
+ <span class="text-sm text-zinc-200">{props.value || "—"}</span>
15
+ </div>
16
+ );
17
+ }
@@ -9,7 +9,7 @@ import Paperclip from "lucide-solid/icons/paperclip";
9
9
  import X from "lucide-solid/icons/x";
10
10
  import TriangleAlert from "lucide-solid/icons/triangle-alert";
11
11
  import { confirm } from "@kserp/host-ui";
12
- import { attachmentUrl, isResolvableAttachment } from "../lib/attachments";
12
+ import { attachmentUrl, isResolvableAttachment } from "../../utils/attachments";
13
13
 
14
14
  export interface ExistingAttachment {
15
15
  id: number;
@@ -39,7 +39,7 @@ export default function ExistingAttachmentTile(props: Props) {
39
39
  fallback={
40
40
  <div
41
41
  class="flex w-24 h-24 flex-col items-center justify-center gap-1 rounded-lg border border-dashed border-zinc-700 bg-zinc-900/40 px-2 text-center text-zinc-500"
42
- title={`${props.attachment.file_name} file is no longer available`}
42
+ title={`${props.attachment.file_name} (file is no longer available)`}
43
43
  >
44
44
  <TriangleAlert size={18} class="text-amber-500/70" />
45
45
  <span class="truncate max-w-full text-[10px]">{props.attachment.file_name}</span>
@@ -0,0 +1,27 @@
1
+ import { Show, type JSX } from "solid-js";
2
+
3
+ interface FormErrorBannerProps {
4
+ /** The error message to display. When falsy (null / undefined / empty
5
+ * string) the banner renders nothing, so callers do not need to wrap
6
+ * it in their own <Show>. */
7
+ message: string | null | undefined;
8
+ /** Extra classes on the banner. Use this for margin spacing such as
9
+ * "mt-3" or "mb-3" — the banner ships no margin of its own. */
10
+ class?: string;
11
+ }
12
+
13
+ // Standard form error banner: the red role="alert" box that nearly every
14
+ // create/edit modal hand-rolls. Self-guarding — it renders nothing when the
15
+ // message is falsy, so a surrounding <Show> is unnecessary.
16
+ export default function FormErrorBanner(props: FormErrorBannerProps): JSX.Element {
17
+ return (
18
+ <Show when={props.message}>
19
+ <div
20
+ role="alert"
21
+ class={`rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300 ${props.class ?? ""}`}
22
+ >
23
+ {props.message}
24
+ </div>
25
+ </Show>
26
+ );
27
+ }
@@ -0,0 +1,19 @@
1
+ // FormField is a small presentational wrapper for a labeled form control. It
2
+ // renders a label above the control, the control itself (passed as children),
3
+ // and an optional hint line below. Copied faithfully from the packages plugin
4
+ // remote UI atoms; behavior is unchanged.
5
+
6
+ import { Show } from "solid-js";
7
+ import type { JSX } from "solid-js";
8
+
9
+ export default function FormField(props: { label: string; children: JSX.Element; hint?: string }) {
10
+ return (
11
+ <div>
12
+ <label class="block text-xs text-zinc-500 mb-1">{props.label}</label>
13
+ {props.children}
14
+ <Show when={props.hint}>
15
+ <p class="text-[10px] text-zinc-600 mt-1">{props.hint}</p>
16
+ </Show>
17
+ </div>
18
+ );
19
+ }