@squaredr/fieldcraft-pro 1.1.0 → 1.2.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.
package/README.md CHANGED
@@ -1,12 +1,17 @@
1
1
  # @squaredr/fieldcraft-pro
2
2
 
3
- Pro components for [FieldCraft](https://squaredr.tech/products/fieldcraft) — a schema-driven form engine for React.
3
+ [![npm version](https://img.shields.io/npm/v/@squaredr/fieldcraft-pro.svg)](https://www.npmjs.com/package/@squaredr/fieldcraft-pro)
4
+ [![license](https://img.shields.io/badge/license-Commercial-blue.svg)](https://squaredr.tech/products/fieldcraft/admin-pro#pricing)
4
5
 
5
- **Includes:**
6
+ Pro components for [FieldCraft](https://squaredr.tech/products/fieldcraft) — a schema-driven form engine for React. Build forms visually, view responses, and customize themes without writing code.
6
7
 
7
- - **Form Builder** — Drag-and-drop visual form designer with 32 field types, undo/redo, import/export, and live preview
8
- - **Response Viewer** — Read-only viewer for submitted form data with table and detail views, CSV/JSON export
9
- - **Theme Editor** Visual theme editor for form styling and branding with CSS custom properties and live preview
8
+ ## What's Included
9
+
10
+ | Component | Description |
11
+ |-----------|-------------|
12
+ | **FormBuilder** | Drag-and-drop visual form designer with 32 field types, undo/redo, import/export, and live preview |
13
+ | **ResponseViewer** | Read-only viewer for submitted form data with table and detail views, CSV/JSON export |
14
+ | **ThemeEditor** | Visual theme editor for form styling with color pickers, typography controls, and live preview |
10
15
 
11
16
  ## Install
12
17
 
@@ -48,38 +53,21 @@ import "@squaredr/fieldcraft-pro/styles.css"; // Form Builder style
48
53
  import "@squaredr/fieldcraft-pro/theme-editor/styles.css"; // Theme Editor styles
49
54
  ```
50
55
 
51
- ## Response Viewer
52
-
53
- Display submitted form responses in table or card/detail views:
54
-
55
- ```tsx
56
- import { ResponseViewer } from "@squaredr/fieldcraft-pro";
57
-
58
- <ResponseViewer
59
- schema={schema}
60
- responses={responses}
61
- onExport={(format) => console.log(`Export as ${format}`)}
62
- />
63
- ```
64
-
65
- Supports clinical field rendering (scoring instruments, body diagrams, pain scales, BMI, vitals) when paired with `@squaredr/fieldcraft-pro-telehealth`.
66
-
67
- ## Theme Editor
56
+ ## Form Builder
68
57
 
69
- Live-edit form themes with color pickers, typography controls, and shape/spacing sliders:
58
+ Visual form designer with drag-and-drop, live preview, and schema export:
70
59
 
71
60
  ```tsx
72
- import { ThemeEditor } from "@squaredr/fieldcraft-pro";
61
+ import { FormBuilder } from "@squaredr/fieldcraft-pro";
73
62
 
74
- <ThemeEditor
75
- onChange={(theme) => console.log(theme)}
76
- onSave={(theme) => saveToServer(theme)}
63
+ <FormBuilder
64
+ onSave={(schema) => saveToServer(schema)}
65
+ onChange={(schema) => setPreview(schema)}
66
+ height="100vh"
77
67
  />
78
68
  ```
79
69
 
80
- Includes presets: Clean, Dark, Modern, High Contrast, Clinical, Playful.
81
-
82
- ## Field Types (32)
70
+ ### Field Types (32)
83
71
 
84
72
  | Category | Fields |
85
73
  |----------|--------|
@@ -92,9 +80,7 @@ Includes presets: Clean, Dark, Modern, High Contrast, Clinical, Playful.
92
80
  | Structural | Section Header, Info Block, Page Break, Consent |
93
81
  | Advanced | Matrix, Calculated, Hidden |
94
82
 
95
- Need telehealth fields (PHQ-9, GAD-7, clinical instruments, patient intake)? See [`@squaredr/fieldcraft-pro-telehealth`](https://www.npmjs.com/package/@squaredr/fieldcraft-pro-telehealth).
96
-
97
- ## Extending with Custom Fields
83
+ ### Extending with Custom Fields
98
84
 
99
85
  The FormBuilder accepts `questionTypes` and `palette` props to inject additional field types at runtime:
100
86
 
@@ -107,11 +93,40 @@ import { FormBuilder, QUESTION_TYPE_INFO, DEFAULT_PALETTE } from "@squaredr/fiel
107
93
  />
108
94
  ```
109
95
 
96
+ ## Response Viewer
97
+
98
+ Display submitted form responses in table or card/detail views:
99
+
100
+ ```tsx
101
+ import { ResponseViewer } from "@squaredr/fieldcraft-pro";
102
+
103
+ <ResponseViewer
104
+ schema={schema}
105
+ responses={responses}
106
+ onExport={(format) => console.log(`Export as ${format}`)}
107
+ />
108
+ ```
109
+
110
+ ## Theme Editor
111
+
112
+ Live-edit form themes with color pickers, typography controls, and shape/spacing sliders:
113
+
114
+ ```tsx
115
+ import { ThemeEditor } from "@squaredr/fieldcraft-pro";
116
+
117
+ <ThemeEditor
118
+ onChange={(theme) => console.log(theme)}
119
+ onSave={(theme) => saveToServer(theme)}
120
+ />
121
+ ```
122
+
123
+ Includes presets: Clean, Dark, Modern, High Contrast, Clinical, Playful.
124
+
110
125
  ## Requirements
111
126
 
112
127
  - React 18 or 19
113
- - `@squaredr/fieldcraft-core` ^1.3.3
114
- - `@squaredr/fieldcraft-react` ^1.2.8
128
+ - `@squaredr/fieldcraft-core` >= 1.3.3
129
+ - `@squaredr/fieldcraft-react` >= 1.2.8
115
130
 
116
131
  ## License
117
132
 
@@ -119,4 +134,6 @@ Commercial license required for production use. Free in development mode.
119
134
 
120
135
  See [LICENSE.txt](./LICENSE.txt) for details.
121
136
 
122
- [Get a license](https://squaredr.tech/products/fieldcraft/admin-pro#pricing) | [Documentation](https://squaredr.tech/products/fieldcraft/docs/pro) | [Discord](https://discord.gg/zMxdu5UVW)
137
+ ---
138
+
139
+ [Get a license](https://squaredr.tech/products/fieldcraft/admin-pro#pricing) | [Documentation](https://squaredr.tech/products/fieldcraft/docs/pro) | [GitHub](https://github.com/SquaredR98/fieldcraft-pro) | [Discord](https://discord.gg/squaredr)
@@ -1,7 +1,7 @@
1
1
  import { cn } from './chunk-QQ4JZGTD.mjs';
2
2
  import { requireLicense } from './chunk-VECQKSWS.mjs';
3
- import { useState } from 'react';
4
- import { Button, Separator } from '@squaredr/fieldcraft-react';
3
+ import { useState, useEffect } from 'react';
4
+ import { Button, Separator, Input, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@squaredr/fieldcraft-react';
5
5
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
6
6
 
7
7
  // src/response-viewer/clinical-display-data.ts
@@ -732,16 +732,24 @@ function formatFallbackValue(value) {
732
732
  }
733
733
 
734
734
  // src/response-viewer/export-utils.ts
735
- function exportToCsv(schema, responses, filename = "responses.csv") {
735
+ function buildCsvContent(schema, responses, options = {}) {
736
+ const { dateFormat = "locale", columnLabels } = options;
736
737
  const questions = getExportableQuestions(schema);
737
- const headers = ["Submitted", ...questions.map((q) => q.label), "Score"];
738
+ const headers = [
739
+ "Submitted",
740
+ ...questions.map((q) => columnLabels?.[q.id] ?? q.label),
741
+ "Score"
742
+ ];
738
743
  const rows = responses.map((r) => {
739
- const submitted = new Date(r.submittedAt).toLocaleString();
744
+ const submitted = dateFormat === "iso" ? new Date(r.submittedAt).toISOString() : new Date(r.submittedAt).toLocaleString();
740
745
  const values = questions.map((q) => formatExportValue(r.values[q.id], q.type));
741
746
  const score = r.totalScore != null ? String(r.totalScore) : "";
742
747
  return [submitted, ...values, score];
743
748
  });
744
- const csvContent = [headers, ...rows].map((row) => row.map(escapeCsvField).join(",")).join("\n");
749
+ return [headers, ...rows].map((row) => row.map(escapeCsvField).join(",")).join("\n");
750
+ }
751
+ function exportToCsv(schema, responses, filename = "responses.csv", options = {}) {
752
+ const csvContent = buildCsvContent(schema, responses, options);
745
753
  downloadBlob(csvContent, filename, "text/csv;charset=utf-8;");
746
754
  }
747
755
  function exportToJson(responses, filename = "responses.json") {
@@ -836,15 +844,145 @@ function downloadBlob(content, filename, mimeType) {
836
844
  document.body.removeChild(link);
837
845
  URL.revokeObjectURL(url);
838
846
  }
847
+
848
+ // src/response-viewer/pagination-utils.ts
849
+ function paginate(items, currentPage, pageSize) {
850
+ const totalItems = items.length;
851
+ const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
852
+ const page = Math.max(1, Math.min(currentPage, totalPages));
853
+ const startIndex = (page - 1) * pageSize;
854
+ const endIndex = Math.min(startIndex + pageSize, totalItems);
855
+ const pageItems = items.slice(startIndex, endIndex);
856
+ return {
857
+ pageItems,
858
+ currentPage: page,
859
+ totalPages,
860
+ startItem: totalItems === 0 ? 0 : startIndex + 1,
861
+ endItem: endIndex,
862
+ totalItems,
863
+ showPagination: totalItems > pageSize
864
+ };
865
+ }
866
+
867
+ // src/response-viewer/filter-utils.ts
868
+ function createEmptyFilterState() {
869
+ return { filters: [], dateRange: {} };
870
+ }
871
+ function hasActiveFilters(state) {
872
+ return state.filters.length > 0 || !!state.dateRange.from || !!state.dateRange.to;
873
+ }
874
+ function applyFilters(responses, state) {
875
+ if (!hasActiveFilters(state)) return responses;
876
+ return responses.filter((r) => {
877
+ if (!matchesDateRange(r.submittedAt, state.dateRange)) return false;
878
+ for (const filter of state.filters) {
879
+ if (!matchesFieldFilter(r.values[filter.fieldId], filter)) return false;
880
+ }
881
+ return true;
882
+ });
883
+ }
884
+ function matchesDateRange(submittedAt, range) {
885
+ if (!range.from && !range.to) return true;
886
+ const submitted = new Date(submittedAt);
887
+ if (range.from) {
888
+ const from = new Date(range.from);
889
+ from.setHours(0, 0, 0, 0);
890
+ if (submitted < from) return false;
891
+ }
892
+ if (range.to) {
893
+ const to = new Date(range.to);
894
+ to.setHours(23, 59, 59, 999);
895
+ if (submitted > to) return false;
896
+ }
897
+ return true;
898
+ }
899
+ function matchesFieldFilter(value, filter) {
900
+ switch (filter.operator) {
901
+ case "equals":
902
+ if (value == null) return false;
903
+ return String(value).toLowerCase() === String(filter.value).toLowerCase();
904
+ case "contains":
905
+ if (value == null) return false;
906
+ return String(value).toLowerCase().includes(String(filter.value).toLowerCase());
907
+ case "gt":
908
+ if (typeof value !== "number") return false;
909
+ return value > Number(filter.value);
910
+ case "lt":
911
+ if (typeof value !== "number") return false;
912
+ return value < Number(filter.value);
913
+ case "is_true":
914
+ return value === true || value === "true" || value === "agreed";
915
+ case "is_false":
916
+ return value === false || value === "false" || value == null;
917
+ default:
918
+ return true;
919
+ }
920
+ }
921
+ var PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
922
+ var FIELD_FILTER_OPERATORS = {
923
+ text: [
924
+ { label: "equals", value: "equals" },
925
+ { label: "contains", value: "contains" }
926
+ ],
927
+ number: [
928
+ { label: "equals", value: "equals" },
929
+ { label: "greater than", value: "gt" },
930
+ { label: "less than", value: "lt" }
931
+ ],
932
+ boolean: [
933
+ { label: "is true", value: "is_true" },
934
+ { label: "is false", value: "is_false" }
935
+ ],
936
+ select: [
937
+ { label: "equals", value: "equals" }
938
+ ]
939
+ };
940
+ function getOperatorsForType(type) {
941
+ if (type === "number" || type === "slider" || type === "rating" || type === "nps" || type === "opinion_scale" || type === "pain_scale") {
942
+ return FIELD_FILTER_OPERATORS.number;
943
+ }
944
+ if (type === "boolean" || type === "consent") {
945
+ return FIELD_FILTER_OPERATORS.boolean;
946
+ }
947
+ if (type === "single_select" || type === "dropdown" || type === "country_select") {
948
+ return FIELD_FILTER_OPERATORS.select;
949
+ }
950
+ return FIELD_FILTER_OPERATORS.text;
951
+ }
952
+ function operatorNeedsValue(operator) {
953
+ return operator !== "is_true" && operator !== "is_false";
954
+ }
839
955
  function ResponseViewerInner({
840
956
  schema,
841
957
  responses,
842
958
  onResponseSelect,
843
959
  height = "500px",
844
- width = "100%"
960
+ width = "100%",
961
+ filename,
962
+ dateFormat,
963
+ columnLabels,
964
+ onExport,
965
+ pageSize = 25
845
966
  }) {
846
967
  const [viewMode, setViewMode] = useState("table");
847
968
  const [selectedResponse, setSelectedResponse] = useState(null);
969
+ const [currentPage, setCurrentPage] = useState(1);
970
+ const [effectivePageSize, setEffectivePageSize] = useState(pageSize);
971
+ const [filterState, setFilterState] = useState(createEmptyFilterState);
972
+ const [showFilterPanel, setShowFilterPanel] = useState(false);
973
+ const [draftFieldId, setDraftFieldId] = useState("");
974
+ const [draftOperator, setDraftOperator] = useState("contains");
975
+ const [draftValue, setDraftValue] = useState("");
976
+ useEffect(() => {
977
+ setCurrentPage(1);
978
+ }, [responses]);
979
+ useEffect(() => {
980
+ setEffectivePageSize(pageSize);
981
+ setCurrentPage(1);
982
+ }, [pageSize]);
983
+ const isFiltered = hasActiveFilters(filterState);
984
+ const filteredResponses = applyFilters(responses, filterState);
985
+ const pag = paginate(filteredResponses, currentPage, effectivePageSize);
848
986
  function handleSelect(response) {
849
987
  setSelectedResponse(response);
850
988
  onResponseSelect?.(response);
@@ -852,6 +990,45 @@ function ResponseViewerInner({
852
990
  function handleBack() {
853
991
  setSelectedResponse(null);
854
992
  }
993
+ function handlePageSizeChange(newSize) {
994
+ setEffectivePageSize(Number(newSize));
995
+ setCurrentPage(1);
996
+ }
997
+ function handleAddFilter() {
998
+ if (!draftFieldId) return;
999
+ if (operatorNeedsValue(draftOperator) && !draftValue) return;
1000
+ const newFilter = {
1001
+ fieldId: draftFieldId,
1002
+ operator: draftOperator,
1003
+ value: draftOperator === "gt" || draftOperator === "lt" ? Number(draftValue) : draftValue
1004
+ };
1005
+ setFilterState((prev) => ({
1006
+ ...prev,
1007
+ filters: [...prev.filters, newFilter]
1008
+ }));
1009
+ setDraftFieldId("");
1010
+ setDraftOperator("contains");
1011
+ setDraftValue("");
1012
+ setCurrentPage(1);
1013
+ }
1014
+ function handleRemoveFilter(index) {
1015
+ setFilterState((prev) => ({
1016
+ ...prev,
1017
+ filters: prev.filters.filter((_, i) => i !== index)
1018
+ }));
1019
+ setCurrentPage(1);
1020
+ }
1021
+ function handleDateRangeChange(field, value) {
1022
+ setFilterState((prev) => ({
1023
+ ...prev,
1024
+ dateRange: { ...prev.dateRange, [field]: value || void 0 }
1025
+ }));
1026
+ setCurrentPage(1);
1027
+ }
1028
+ function handleClearAllFilters() {
1029
+ setFilterState(createEmptyFilterState());
1030
+ setCurrentPage(1);
1031
+ }
855
1032
  const questions = getAllQuestions2(schema);
856
1033
  function getFields(response) {
857
1034
  return questions.map((q) => ({
@@ -869,12 +1046,8 @@ function ResponseViewerInner({
869
1046
  className: "flex flex-col border border-border rounded-lg overflow-hidden bg-background text-foreground",
870
1047
  style: { height: heightValue, width: widthValue },
871
1048
  children: [
872
- !selectedResponse && /* @__PURE__ */ jsxs("div", { className: "flex justify-between items-center px-3 py-2 border-b border-border", children: [
873
- /* @__PURE__ */ jsxs("div", { className: "text-xs text-muted-foreground", children: [
874
- responses.length,
875
- " response",
876
- responses.length !== 1 ? "s" : ""
877
- ] }),
1049
+ !selectedResponse && /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-x-2 gap-y-1 px-3 py-2 border-b border-border", children: [
1050
+ /* @__PURE__ */ jsx("div", { className: "text-xs text-muted-foreground mr-auto", children: isFiltered ? `Showing ${filteredResponses.length} of ${responses.length} responses (filtered)` : `${responses.length} response${responses.length !== 1 ? "s" : ""}` }),
878
1051
  /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1", children: [
879
1052
  /* @__PURE__ */ jsx(
880
1053
  Button,
@@ -901,15 +1074,50 @@ function ResponseViewerInner({
901
1074
  onClick: () => setViewMode("card"),
902
1075
  children: "Cards"
903
1076
  }
1077
+ )
1078
+ ] }),
1079
+ /* @__PURE__ */ jsx(Separator, { orientation: "vertical", className: "h-5 hidden sm:block" }),
1080
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1", children: [
1081
+ /* @__PURE__ */ jsxs(
1082
+ Button,
1083
+ {
1084
+ variant: showFilterPanel ? "secondary" : "outline",
1085
+ size: "sm",
1086
+ className: cn(
1087
+ "fc-rv-filter__button h-7 text-xs",
1088
+ showFilterPanel && "font-semibold border border-ring"
1089
+ ),
1090
+ onClick: () => setShowFilterPanel((v) => !v),
1091
+ children: [
1092
+ "Filter",
1093
+ isFiltered ? ` (${filterState.filters.length + (filterState.dateRange.from || filterState.dateRange.to ? 1 : 0)})` : ""
1094
+ ]
1095
+ }
904
1096
  ),
905
- /* @__PURE__ */ jsx(Separator, { orientation: "vertical", className: "h-5 mx-1" }),
1097
+ isFiltered && /* @__PURE__ */ jsx(
1098
+ Button,
1099
+ {
1100
+ variant: "ghost",
1101
+ size: "sm",
1102
+ className: "h-7 text-xs text-destructive",
1103
+ onClick: handleClearAllFilters,
1104
+ children: "Clear all"
1105
+ }
1106
+ )
1107
+ ] }),
1108
+ /* @__PURE__ */ jsx(Separator, { orientation: "vertical", className: "h-5 hidden sm:block" }),
1109
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1", children: [
906
1110
  /* @__PURE__ */ jsx(
907
1111
  Button,
908
1112
  {
909
1113
  variant: "outline",
910
1114
  size: "sm",
911
1115
  className: "h-7 text-xs",
912
- onClick: () => exportToCsv(schema, responses),
1116
+ onClick: () => {
1117
+ const csvFilename = filename ? filename.replace(/\.\w+$/, ".csv") : "responses.csv";
1118
+ exportToCsv(schema, responses, csvFilename, { dateFormat, columnLabels });
1119
+ onExport?.("csv", responses.length);
1120
+ },
913
1121
  disabled: responses.length === 0,
914
1122
  children: "Export CSV"
915
1123
  }
@@ -920,14 +1128,146 @@ function ResponseViewerInner({
920
1128
  variant: "outline",
921
1129
  size: "sm",
922
1130
  className: "h-7 text-xs",
923
- onClick: () => exportToJson(responses),
1131
+ onClick: () => {
1132
+ const jsonFilename = filename ? filename.replace(/\.\w+$/, ".json") : "responses.json";
1133
+ exportToJson(responses, jsonFilename);
1134
+ onExport?.("json", responses.length);
1135
+ },
924
1136
  disabled: responses.length === 0,
925
1137
  children: "Export JSON"
926
1138
  }
927
1139
  )
928
1140
  ] })
929
1141
  ] }),
930
- /* @__PURE__ */ jsx("div", { className: cn("flex-1 overflow-auto", selectedResponse && "p-4"), children: selectedResponse ? /* @__PURE__ */ jsx(
1142
+ !selectedResponse && showFilterPanel && /* @__PURE__ */ jsxs("div", { className: "fc-rv-filter__panel px-3 py-2 border-b border-border bg-muted/50", children: [
1143
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-2 mb-2", children: [
1144
+ /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground w-16 shrink-0", children: "Date:" }),
1145
+ /* @__PURE__ */ jsx(
1146
+ Input,
1147
+ {
1148
+ type: "date",
1149
+ className: "h-7 text-xs w-36",
1150
+ value: filterState.dateRange.from ?? "",
1151
+ onChange: (e) => handleDateRangeChange("from", e.target.value)
1152
+ }
1153
+ ),
1154
+ /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: "to" }),
1155
+ /* @__PURE__ */ jsx(
1156
+ Input,
1157
+ {
1158
+ type: "date",
1159
+ className: "h-7 text-xs w-36",
1160
+ value: filterState.dateRange.to ?? "",
1161
+ onChange: (e) => handleDateRangeChange("to", e.target.value)
1162
+ }
1163
+ )
1164
+ ] }),
1165
+ /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [
1166
+ /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground w-16 shrink-0", children: "Field:" }),
1167
+ /* @__PURE__ */ jsxs(
1168
+ Select,
1169
+ {
1170
+ value: draftFieldId || void 0,
1171
+ onValueChange: (val) => {
1172
+ setDraftFieldId(val);
1173
+ const q = questions.find((q2) => q2.id === val);
1174
+ if (q) {
1175
+ const ops = getOperatorsForType(q.type);
1176
+ setDraftOperator(ops[0].value);
1177
+ }
1178
+ setDraftValue("");
1179
+ },
1180
+ children: [
1181
+ /* @__PURE__ */ jsx(SelectTrigger, { size: "sm", className: "h-7 text-xs w-40", children: /* @__PURE__ */ jsx(SelectValue, { placeholder: "Select field" }) }),
1182
+ /* @__PURE__ */ jsx(SelectContent, { children: questions.map((q) => /* @__PURE__ */ jsx(SelectItem, { value: q.id, children: q.label }, q.id)) })
1183
+ ]
1184
+ }
1185
+ ),
1186
+ draftFieldId && /* @__PURE__ */ jsxs(Fragment, { children: [
1187
+ /* @__PURE__ */ jsxs(
1188
+ Select,
1189
+ {
1190
+ value: draftOperator,
1191
+ onValueChange: (val) => setDraftOperator(val),
1192
+ children: [
1193
+ /* @__PURE__ */ jsx(SelectTrigger, { size: "sm", className: "h-7 text-xs w-32", children: /* @__PURE__ */ jsx(SelectValue, {}) }),
1194
+ /* @__PURE__ */ jsx(SelectContent, { children: getOperatorsForType(
1195
+ questions.find((q) => q.id === draftFieldId)?.type ?? "short_text"
1196
+ ).map((op) => /* @__PURE__ */ jsx(SelectItem, { value: op.value, children: op.label }, op.value)) })
1197
+ ]
1198
+ }
1199
+ ),
1200
+ operatorNeedsValue(draftOperator) && /* @__PURE__ */ jsx(
1201
+ Input,
1202
+ {
1203
+ className: "h-7 text-xs w-32",
1204
+ placeholder: "Value",
1205
+ value: draftValue,
1206
+ onChange: (e) => setDraftValue(e.target.value),
1207
+ onKeyDown: (e) => e.key === "Enter" && handleAddFilter()
1208
+ }
1209
+ ),
1210
+ /* @__PURE__ */ jsx(
1211
+ Button,
1212
+ {
1213
+ variant: "outline",
1214
+ size: "sm",
1215
+ className: "h-7 text-xs",
1216
+ onClick: handleAddFilter,
1217
+ children: "Add"
1218
+ }
1219
+ )
1220
+ ] })
1221
+ ] })
1222
+ ] }),
1223
+ !selectedResponse && isFiltered && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1 px-3 py-1.5 border-b border-border flex-wrap", children: [
1224
+ filterState.dateRange.from && /* @__PURE__ */ jsxs(
1225
+ "span",
1226
+ {
1227
+ className: "fc-rv-filter__chip inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-accent text-xs text-foreground cursor-pointer hover:bg-destructive/20",
1228
+ onClick: () => handleDateRangeChange("from", ""),
1229
+ children: [
1230
+ "From: ",
1231
+ filterState.dateRange.from,
1232
+ " \xD7"
1233
+ ]
1234
+ }
1235
+ ),
1236
+ filterState.dateRange.to && /* @__PURE__ */ jsxs(
1237
+ "span",
1238
+ {
1239
+ className: "fc-rv-filter__chip inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-accent text-xs text-foreground cursor-pointer hover:bg-destructive/20",
1240
+ onClick: () => handleDateRangeChange("to", ""),
1241
+ children: [
1242
+ "To: ",
1243
+ filterState.dateRange.to,
1244
+ " \xD7"
1245
+ ]
1246
+ }
1247
+ ),
1248
+ filterState.filters.map((f, i) => {
1249
+ const q = questions.find((q2) => q2.id === f.fieldId);
1250
+ const label = q?.label ?? f.fieldId;
1251
+ const opLabel = f.operator.replace("_", " ");
1252
+ const valueStr = operatorNeedsValue(f.operator) ? ` "${f.value}"` : "";
1253
+ return /* @__PURE__ */ jsxs(
1254
+ "span",
1255
+ {
1256
+ className: "fc-rv-filter__chip inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-accent text-xs text-foreground cursor-pointer hover:bg-destructive/20",
1257
+ onClick: () => handleRemoveFilter(i),
1258
+ children: [
1259
+ label,
1260
+ " ",
1261
+ opLabel,
1262
+ valueStr,
1263
+ " \xD7"
1264
+ ]
1265
+ },
1266
+ i
1267
+ );
1268
+ })
1269
+ ] }),
1270
+ /* @__PURE__ */ jsx("div", { className: cn("flex-1 min-w-0 overflow-auto", selectedResponse && "p-4"), children: selectedResponse ? /* @__PURE__ */ jsx(
931
1271
  ResponseDetail,
932
1272
  {
933
1273
  response: selectedResponse,
@@ -938,11 +1278,11 @@ function ResponseViewerInner({
938
1278
  ResponseTable,
939
1279
  {
940
1280
  schema,
941
- responses,
1281
+ responses: pag.pageItems,
942
1282
  onRowClick: handleSelect
943
1283
  }
944
1284
  ) : /* @__PURE__ */ jsxs("div", { className: "grid gap-3 p-3 grid-cols-[repeat(auto-fill,minmax(280px,1fr))]", children: [
945
- responses.map((response, idx) => /* @__PURE__ */ jsx(
1285
+ pag.pageItems.map((response, idx) => /* @__PURE__ */ jsx(
946
1286
  ResponseCard,
947
1287
  {
948
1288
  response,
@@ -951,8 +1291,63 @@ function ResponseViewerInner({
951
1291
  },
952
1292
  response.sessionToken || idx
953
1293
  )),
954
- responses.length === 0 && /* @__PURE__ */ jsx("div", { className: "p-8 text-center text-muted-foreground", children: "No responses yet" })
955
- ] }) })
1294
+ filteredResponses.length === 0 && /* @__PURE__ */ jsx("div", { className: "p-8 text-center text-muted-foreground", children: isFiltered ? "No matching responses" : "No responses yet" })
1295
+ ] }) }),
1296
+ !selectedResponse && pag.showPagination && /* @__PURE__ */ jsxs("div", { className: "fc-rv-pagination flex flex-wrap items-center gap-x-2 gap-y-1 px-3 py-2 border-t border-border", children: [
1297
+ /* @__PURE__ */ jsxs("div", { className: "fc-rv-pagination__info text-xs text-muted-foreground mr-auto", children: [
1298
+ "Showing ",
1299
+ pag.startItem,
1300
+ "\u2013",
1301
+ pag.endItem,
1302
+ " of ",
1303
+ pag.totalItems,
1304
+ " responses"
1305
+ ] }),
1306
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
1307
+ /* @__PURE__ */ jsxs("div", { className: "fc-rv-pagination__size-select flex items-center gap-1", children: [
1308
+ /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: "Per page:" }),
1309
+ /* @__PURE__ */ jsxs(
1310
+ Select,
1311
+ {
1312
+ value: String(effectivePageSize),
1313
+ onValueChange: handlePageSizeChange,
1314
+ children: [
1315
+ /* @__PURE__ */ jsx(SelectTrigger, { size: "sm", className: "h-7 w-16 text-xs", children: /* @__PURE__ */ jsx(SelectValue, {}) }),
1316
+ /* @__PURE__ */ jsx(SelectContent, { children: PAGE_SIZE_OPTIONS.map((size) => /* @__PURE__ */ jsx(SelectItem, { value: String(size), children: size }, size)) })
1317
+ ]
1318
+ }
1319
+ )
1320
+ ] }),
1321
+ /* @__PURE__ */ jsxs("span", { className: "text-xs text-muted-foreground", children: [
1322
+ "Page ",
1323
+ pag.currentPage,
1324
+ " of ",
1325
+ pag.totalPages
1326
+ ] }),
1327
+ /* @__PURE__ */ jsx(
1328
+ Button,
1329
+ {
1330
+ variant: "outline",
1331
+ size: "sm",
1332
+ className: "fc-rv-pagination__button h-7 text-xs",
1333
+ onClick: () => setCurrentPage((p) => p - 1),
1334
+ disabled: pag.currentPage <= 1,
1335
+ children: "Previous"
1336
+ }
1337
+ ),
1338
+ /* @__PURE__ */ jsx(
1339
+ Button,
1340
+ {
1341
+ variant: "outline",
1342
+ size: "sm",
1343
+ className: "fc-rv-pagination__button h-7 text-xs",
1344
+ onClick: () => setCurrentPage((p) => p + 1),
1345
+ disabled: pag.currentPage >= pag.totalPages,
1346
+ children: "Next"
1347
+ }
1348
+ )
1349
+ ] })
1350
+ ] })
956
1351
  ]
957
1352
  }
958
1353
  );