diff --git a/.env.localapi b/.env.localapi
new file mode 100644
index 0000000..451a99f
--- /dev/null
+++ b/.env.localapi
@@ -0,0 +1,2 @@
+VITE_API_MODE=local
+VITE_LARAVEL_ORIGIN=http://127.0.0.1:8000
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..8d15140
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+node_modules/
+*.cursor/
\ No newline at end of file
diff --git a/package.json b/package.json
index a2eb5ce..9a2293d 100644
--- a/package.json
+++ b/package.json
@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite",
+ "dev:local": "vite --mode localapi",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview",
diff --git a/src/components/EntityForm.jsx b/src/components/EntityForm.jsx
index 7cf10a5..d06d4fd 100644
--- a/src/components/EntityForm.jsx
+++ b/src/components/EntityForm.jsx
@@ -14,6 +14,7 @@ import {
} from '@/components/metric/MetricFieldControls';
import MilestoneMappingField from '@/components/recognition/MilestoneMappingField';
import EmailNotificationRulesField from '@/components/form/EmailNotificationRulesField';
+import { DataDesignerField, normalizeDataDesigner } from '@/components/form/DataDesignerField';
import RankRolesField from '@/components/form/RankRolesField';
import ReminderRowsField from '@/components/form/ReminderRowsField';
import DependentSelectField from '@/components/form/DependentSelectField';
@@ -36,6 +37,7 @@ function defaultValue(field, item) {
const raw = fromItem !== undefined && fromItem !== null ? fromItem : field.default;
if (raw === undefined || raw === null) {
if (field.type === 'multiselect' || field.type === 'bigmultiselect' || field.type === 'checkbox') return [];
+ if (field.type === 'data_designer') return { fields: [] };
if (field.type === 'mapping' || field.type === 'color_ranges' || field.type === 'ranking_attributes' || field.type === 'combined_metrics' || field.type === 'milestonemapping' || field.type === 'email_notifications' || field.type === 'rank_roles' || field.type === 'reminders' || field.type === 'template_positions') {
return [];
}
@@ -44,6 +46,7 @@ function defaultValue(field, item) {
return '';
}
if (field.type === 'combined_metrics') return parseCombinedMetrics(raw);
+ if (field.type === 'data_designer') return normalizeDataDesigner(raw);
if (field.type === 'email_notifications') {
if (typeof raw === 'string' && raw.trim()) {
try {
@@ -475,6 +478,9 @@ function FieldControl({ field, control, register, watch, moduleName, onUploading
if (field.type === 'email_notifications') {
return ;
}
+ if (field.type === 'data_designer') {
+ return ;
+ }
if (field.type === 'rank_roles') {
return ;
}
diff --git a/src/components/form/DataDesignerField.jsx b/src/components/form/DataDesignerField.jsx
new file mode 100644
index 0000000..621b910
--- /dev/null
+++ b/src/components/form/DataDesignerField.jsx
@@ -0,0 +1,334 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useSearchParams } from 'react-router';
+import { Controller } from 'react-hook-form';
+import { Button, Dropdown, Form } from 'react-bootstrap';
+import { closestCenter, DndContext, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
+import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+import { signedGet, isSuccess } from '@/api/client';
+
+const DEFAULT_HIERARCHY_LEVELS = ['Country', 'Zone', 'Region', 'Area', 'Territory'];
+const MANAGER_LEVELS = [
+ ['l1', 'L1'],
+ ['l2', 'L2'],
+ ['l3', 'L3'],
+];
+const MANAGER_KEYS = [
+ ['name', 'Name'],
+ ['email', 'Email'],
+ ['employee_code', 'Employee Code'],
+ ['position_name', 'Position Name'],
+];
+
+function emptyDesigner() {
+ return { fields: [] };
+}
+
+function asDesigner(raw) {
+ let value = raw;
+ if (typeof raw === 'string' && raw.trim()) {
+ try {
+ value = JSON.parse(raw);
+ } catch {
+ value = null;
+ }
+ }
+ const fields = Array.isArray(value?.fields) ? value.fields : [];
+ return {
+ fields: fields
+ .filter((row) => row && row.id && row.source && row.key)
+ .map((row) => ({
+ id: String(row.id),
+ source: String(row.source),
+ key: String(row.key),
+ label: row.label || String(row.key),
+ level: row.level || undefined,
+ show_in_export: row.show_in_export !== false,
+ show_in_view: Boolean(row.show_in_view),
+ })),
+ };
+}
+
+export function normalizeDataDesigner(raw) {
+ return asDesigner(raw);
+}
+
+const SOURCE_LABELS = {
+ user: 'User attributes',
+ hierarchy: 'Hierarchy',
+ manager: 'Manager info',
+ question: 'Form questions',
+ manual: 'Manual',
+};
+
+function managerStoredId(level, key) {
+ const number = String(level || '').replace('l', '');
+ const suffix = key === 'employee_code' ? 'code' : key === 'position_name' ? 'position' : key;
+ return `manager${number}_${suffix}`;
+}
+
+function option(id, source, key, label, extra = {}) {
+ return { value: id, label, row: { id, source, key, label, show_in_export: true, show_in_view: false, ...extra } };
+}
+
+function CheckDropdown({ title, count, options, taken, onToggle, emptyText, disabled, disabledText }) {
+ return (
+
+
+ {disabled ? disabledText : `${title} (${count})`}
+
+
+ {options.length ? options.map((item) => (
+
+ onToggle(item, event.target.checked)}
+ />
+
+ )) : {emptyText}
}
+
+
+ );
+}
+
+function DesignerRow({ row, onPatch, onRemove }) {
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: row.id });
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.6 : 1,
+ };
+ return (
+
+
+
+
{row.label}
+
{SOURCE_LABELS[row.source] || row.source}
+
+
onPatch(row.id, { show_in_export: event.target.checked })}
+ />
+ onPatch(row.id, { show_in_view: event.target.checked })}
+ />
+
+
+ );
+}
+
+function DataDesignerEditor({ value, onChange, userAttributes = {} }) {
+ const [searchParams] = useSearchParams();
+ const formId = searchParams.get('id') || '';
+ const [open, setOpen] = useState(false);
+ const [questions, setQuestions] = useState([]);
+ const [hierarchyLevels, setHierarchyLevels] = useState([]);
+ const [manualOpen, setManualOpen] = useState(false);
+ const [manualKey, setManualKey] = useState('');
+ const [manualLabel, setManualLabel] = useState('');
+ const designer = asDesigner(value);
+ const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }));
+
+ useEffect(() => {
+ if (!formId) {
+ setQuestions([]);
+ return undefined;
+ }
+ let cancelled = false;
+ signedGet('form', 'formquestion/get_all', { entity_id: formId }).then((response) => {
+ if (cancelled || !isSuccess(response)) return;
+ const items = response.data?.items || [];
+ setQuestions(Array.isArray(items) ? items : Object.values(items));
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [formId]);
+
+ useEffect(() => {
+ let cancelled = false;
+ signedGet('ums', 'domain/get_one').then((response) => {
+ if (cancelled) return;
+ const types = isSuccess(response) ? response.data?.item?.territory_types : [];
+ const levels = Array.isArray(types) ? types.map((type) => String(type || '').trim()).filter(Boolean) : [];
+ setHierarchyLevels(levels.length ? levels : DEFAULT_HIERARCHY_LEVELS);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, []);
+
+ const taken = useMemo(() => new Set(designer.fields.map((row) => row.id)), [designer.fields]);
+
+ const userOptions = useMemo(() => {
+ const fixed = [
+ ['name', 'Name'],
+ ['email', 'Email'],
+ ['employee_code', 'Employee Code'],
+ ];
+ const attributes = Object.entries(userAttributes);
+ const labelKey = (label) => String(label || '').trim().toLowerCase();
+ const usedCodes = new Set();
+ const usedLabels = new Set();
+ const rows = [];
+ const addRow = (key, label) => {
+ const normalized = labelKey(label);
+ if (!key || usedCodes.has(key) || usedLabels.has(normalized)) return;
+ usedCodes.add(key);
+ usedLabels.add(normalized);
+ rows.push(option(key, 'user', key, label));
+ };
+ fixed.forEach(([key, label]) => {
+ const match = attributes.find(([code, attrLabel]) => code === key || labelKey(attrLabel) === labelKey(label));
+ if (match) addRow(match[0], match[1]);
+ else addRow(key, label);
+ });
+ attributes.forEach(([code, label]) => addRow(code, label));
+ return rows;
+ }, [userAttributes]);
+
+ const hierarchyOptions = hierarchyLevels.map((level) => option(String(level).trim().toLowerCase(), 'hierarchy', level, level));
+
+ const managerOptions = MANAGER_LEVELS.flatMap(([level, title]) =>
+ MANAGER_KEYS.map(([key, name]) =>
+ option(managerStoredId(level, key), 'manager', key, `${title} Manager ${name}`, { level }),
+ ),
+ );
+
+ const questionOptions = [
+ ...questions
+ .filter((question) => question?.id && question.status !== 'disabled')
+ .map((question) =>
+ option(question.code || String(question.id), 'question', question.code || String(question.id), question.question || question.code || question.id),
+ ),
+ option('submission_id', 'question', 'submission_id', 'Submission ID'),
+ option('rec_date', 'question', 'rec_date', 'Record Date'),
+ option('created_date', 'question', 'created_date', 'Submitted Date'),
+ option('updated_date', 'question', 'updated_date', 'Last Updated Date'),
+ option('workflow_status', 'question', 'workflow_status', 'Workflow Status'),
+ ];
+
+ const appendRows = (picked) => {
+ const rows = (picked || []).map((item) => item.row).filter((row) => row && !taken.has(row.id));
+ if (!rows.length) return;
+ onChange({ fields: [...designer.fields, ...rows] });
+ };
+
+ const patchRow = (id, patch) => {
+ onChange({
+ fields: designer.fields.map((row) => (row.id === id ? { ...row, ...patch } : row)),
+ });
+ };
+
+ const removeRow = (id) => {
+ onChange({ fields: designer.fields.filter((row) => row.id !== id) });
+ };
+
+ const onDragEnd = (event) => {
+ const { active, over } = event;
+ if (!over || active.id === over.id) return;
+ const from = designer.fields.findIndex((row) => row.id === active.id);
+ const to = designer.fields.findIndex((row) => row.id === over.id);
+ if (from < 0 || to < 0) return;
+ onChange({ fields: arrayMove(designer.fields, from, to) });
+ };
+
+ const toggleOption = (item, checked) => {
+ if (checked) appendRows([item]);
+ else removeRow(item.value);
+ };
+
+ const countFor = (source) => designer.fields.filter((row) => row.source === source).length;
+
+ const addManual = () => {
+ const key = manualKey.trim();
+ const label = manualLabel.trim();
+ if (!key || !label) return;
+ const id = key;
+ if (taken.has(id)) return;
+ onChange({
+ fields: [...designer.fields, { id, source: 'manual', key, label, show_in_export: true, show_in_view: false }],
+ });
+ setManualKey('');
+ setManualLabel('');
+ };
+
+ return (
+
+
+ Data Designer
+
+
+ {open ? (
+
+
+
+
+
+
+
+
+ {manualOpen ? (
+
+
+ Field name
+ setManualKey(event.target.value)} placeholder="erdostyne_capsule_value" />
+
+
+ Column title
+ setManualLabel(event.target.value)} placeholder="ERDOSTYNE CAPSULE 10s Value" />
+
+
+
+ ) : null}
+
+ ) : null}
+
+ row.id)} strategy={verticalListSortingStrategy}>
+ {designer.fields.map((row) => (
+
+ ))}
+
+
+ {!designer.fields.length ?
No fields selected.
: null}
+
+ );
+}
+
+export function DataDesignerField({ field, control }) {
+ return (
+ (
+
+ )}
+ />
+ );
+}
diff --git a/src/modules/formentitys.js b/src/modules/formentitys.js
index e4bc811..bda4588 100644
--- a/src/modules/formentitys.js
+++ b/src/modules/formentitys.js
@@ -1,4 +1,5 @@
import { signedGet, signedPost, isSuccess } from '@/api/client';
+import { normalizeDataDesigner } from '@/components/form/DataDesignerField';
import { dropdownMap, fetchUserAttributes } from '@/api/dropdowns';
import { splitHash } from '@/helpers/metric';
@@ -201,6 +202,13 @@ const formentitys = {
roles,
users: notifyUsers,
},
+ {
+ key: 'data_designer',
+ type: 'data_designer',
+ fullwidth: true,
+ hide_label: true,
+ userAttributes,
+ },
];
},
beforeForm: (item) => {
@@ -237,6 +245,7 @@ const formentitys = {
next.roles_push_notification = asArray(next.roles_push_notification);
next.workflow_approvers = Array.isArray(next.workflow_approvers) && next.workflow_approvers.length ? next.workflow_approvers : ['l1manager'];
next.workflow_definition_id = next.workflow_definition_id || '';
+ next.data_designer = normalizeDataDesigner(next.data_designer);
return next;
},
beforeSave: async (data, mode) => {
@@ -245,6 +254,7 @@ const formentitys = {
payload.export_manager_levels = asArray(payload.export_manager_levels);
payload.manager_de_roles = asArray(payload.manager_de_roles);
payload.contact_attributes = asArray(payload.contact_attributes);
+ payload.data_designer = normalizeDataDesigner(payload.data_designer);
payload.show_user_attributes = payload.show_user_attributes === 'yes' ? 'yes' : 'no';
payload.user_attributes = asArray(payload.user_attributes);
if (payload.user_attributes.length) payload.show_user_attributes = 'yes';
diff --git a/src/modules/formexports.js b/src/modules/formexports.js
index c08f92a..6d4b4f5 100644
--- a/src/modules/formexports.js
+++ b/src/modules/formexports.js
@@ -29,6 +29,8 @@ const formexports = {
{ title: 'Add New', to: '/formexport/addnew' },
{ title: 'Refresh', action: 'reload' },
],
+ mapItems: (items) =>
+ [...(items || [])].sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0)),
formFields: async () => {
const [forms, dropdown] = await Promise.all([
dropdownMap('form', 'formentity', (item) => item.title),
diff --git a/vite.config.js b/vite.config.js
index 396d000..1d7fbc9 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,4 +1,4 @@
-import { defineConfig } from 'vite';
+import { defineConfig , loadEnv } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'node:path';
@@ -57,18 +57,20 @@ const laravelProxy = {
},
};
-export default defineConfig({
- plugins: [react()],
- resolve: {
- alias: {
- '@': resolve(__dirname, 'src'),
+
+export default defineConfig(({ mode }) => {
+ const env = loadEnv(mode, process.cwd(), '');
+ return {
+ plugins: [react()],
+ resolve: {
+ alias: { '@': resolve(__dirname, 'src') },
},
- },
- server: {
- port: 5173,
- proxy: {
- ...laravelProxy,
- ...(process.env.VITE_API_MODE === 'local' ? localProxy : productionProxy),
+ server: {
+ port: 5173,
+ proxy: {
+ ...laravelProxy,
+ ...(env.VITE_API_MODE === 'local' ? localProxy : productionProxy),
+ },
},
- },
+ };
});