25 Commits

Author SHA1 Message Date
horacio f200f91f93 Implement code changes to enhance functionality and improve performance 2026-09-14 13:43:23 -03:00
horacio 06aac5b126 fix: popup centrado con estilo Confirm (botones sin iconos, titulo 'Personalizar')
- Dialog centrado (no bottom-sheet) con ThemeProvider
- Botones Cancelar/Guardar en texto plano (sin iconos), mismo patrón que Confirm.tsx
- Recibe valores actuales por props para no depender de store global
- Titulo dice 'Personalizar' solamente
- Cierre se hace con icono X en el title bar
2026-09-14 13:01:36 -03:00
horacio 80d9f5d428 feat: permitir a admins personalizar header desde landing pública
- Nuevo endpoint POST /companies/check-admin para verificar rol de admin
- Componente HeaderEditPopup con color pickers e image picker
- Botón flotante de editar sobre el header cuando el usuario es admin
- Popup tipo drawer inferior con formulario de personalización
- Reutiliza APIs existentes: companies/update y companies/upload-header
2026-09-14 12:34:59 -03:00
horacio ad44e775e2 feat: agregar habilidad para sincronizar el proyecto Engram con la nube 2026-09-14 12:02:34 -03:00
horacio cebb34c88a feat: actualizar URL de webhook de Mercado Pago y eliminar conteo de citas en la creación de ciclo de uso de plan 2026-09-14 11:37:42 -03:00
horacio 0095bd884a feat: actualizar la validación del campo de comentarios en el esquema de actualización de citas 2026-09-11 15:03:26 -03:00
horacio c238f28179 feat: ajustar el relleno superior en la página de movimientos y actualizar el z-index en el componente de ubicación 2026-09-11 14:53:32 -03:00
horacio 57c5deb3bd Add QR code images for transaction assistant, login, and plans
- Added `qrcode-tx-assistant.png` for transaction assistant functionality.
- Added `qrcode-tx-login.png` for user login process.
- Added `qrcode-tx-plans.png` for displaying available plans.
2026-09-04 21:05:20 -03:00
horacio c5217aa7c8 feat(help): add Help Center with video tutorials and progress tracking
- Implemented Help Center page with video categories and tutorials.
- Added VideoModal component for video playback.
- Introduced progress tracking for watched videos using local storage.
- Created CSS styles for Help Center layout and components.
- Added video data structure and sample videos for various categories.
- Updated Footer to include a link to the Help Center.
2026-09-02 09:46:02 -03:00
horacio 48ed64eb87 feat: agregar referencia actual de fecha en el manejo de citas y mejorar la lógica de movimiento de citas 2026-08-29 11:43:56 -03:00
horacio 95b3e3698b feat: reprogramar trabajos de notificación tras el cambio de cita y manejar errores 2026-08-29 11:28:10 -03:00
horacio ee9da60b3b feat: mejorar el estilo del elemento del menú asistente con ajustes de diseño y color 2026-08-22 12:56:25 -03:00
horacio f00e258819 feat: agregar funcionalidad para marcar notificaciones como leídas y mejorar el diseño del pie de notificaciones 2026-08-22 12:39:08 -03:00
horacio 33743f12b9 feat: add CollaboratorPicker component and integrate into CollaboratorScheduleSummaryFlow
- Implemented CollaboratorPicker for selecting employees with support for single and multiple selections.
- Created CollaboratorScheduleSummaryFlow to display collaborator schedules and exceptions.
- Added ServicePublicationFlow for managing service visibility on public pages.
- Introduced CSS styles for ServicePrivate component to enhance UI.
2026-08-22 12:34:09 -03:00
horacio b6399d28fc feat: agregar soporte para plantillas de correo electrónico y contexto en el envío de notificaciones 2026-08-20 11:38:50 -03:00
horacio e4441fa476 feat: agregar envío de correos electrónicos para planes aprobados y mejorar el formato de fecha y monto 2026-08-19 15:05:45 -03:00
horacio c8029a6762 feat: mejorar el envío de correos electrónicos y agregar validaciones en el adaptador de notificaciones 2026-08-19 14:51:42 -03:00
horacio 18fa523fbe feat: agregar opciones de inicio de sesión y registro con Google y manual 2026-08-19 11:58:05 -03:00
horacio 63a85e8aab metricas en baja de repeticiones 2026-08-17 16:24:16 -03:00
horacio e82d2a0ece Add app launcher icon and strings resource for TurnosXpress
- Added a new WebP launcher icon for the app in the mipmap-xxxhdpi directory.
- Created a new strings.xml file containing the app name and asset statements for handling URLs.
2026-08-15 13:13:58 -03:00
horacio ff733296c8 Add Gradle wrapper and configuration files for Android project
- Created gradle-wrapper.properties to specify Gradle distribution details.
- Added gradlew and gradlew.bat scripts for executing Gradle tasks.
- Introduced settings.gradle for project configuration and dependency management.
- Added assetlinks.json for Android App Links support.
2026-08-15 00:22:56 -03:00
horacio ef044522b4 feat: agregar archivos de configuración para firma y keystore de Android 2026-08-14 23:03:56 -03:00
horacio e6686597f0 feat: actualizar APK a la última versión disponible 2026-08-14 20:00:43 -03:00
horacio 9e090c465a feat: implementar descarga de PDF de factura y soporte para guardar en Android 2026-08-14 19:51:59 -03:00
horacio b7bbef8035 feat: agregar soporte para cancelación de citas por parte del cliente en notificaciones 2026-08-14 19:34:00 -03:00
125 changed files with 5951 additions and 741 deletions
@@ -0,0 +1,33 @@
---
name: engram-cloud-sync
description: "Trigger: sync engram cloud, sincronizar memoria, push engram, engram cloud sync. Synchronize local Engram project to remote cloud server."
license: Apache-2.0
metadata:
author: "horacio"
version: "1.0"
---
## Activation Contract
Load this skill whenever the user explicitly asks to synchronize, push, or sync their Engram memory with the cloud.
## Hard Rules
- **Never skip the sync** when requested. Execute `engram sync --cloud --project turnosxpress` every time.
- **Always verify** the cloud server is reachable and the project is enrolled before syncing.
- **Report results to the user**: sessions, observations, prompts, mutations exported.
- **Fail fast** on 403/forbidden errors — that means the project needs enrollment first.
## Execution Steps
1. **Check cloud status:** `engram cloud status --cwd <repo>` (default: current project directory). If `Auth status: ready` and `Sync readiness: pending`, continue.
2. **If project is not enrolled** (`403: forbidden: project is not allowed` or similar), run `engram cloud enroll turnosxpress --cwd <repo>` then re-check status.
3. **Execute sync:** `engram sync --cloud --project turnosxpress --cwd <repo>`.
4. **Report results** — sessions, observations, prompts, mutations count. If sync fails, surface the exact error and suggest running `engram serve` for autosync mode.
## Output Contract
Return:
- Cloud server URL and enrollment status.
- Export counts: sessions, observations, prompts, mutations.
- Confirmation: "Cloud sync complete" or actionable error if it failed.
@@ -1,11 +1,17 @@
package com.hdrdevs.turnosxpress;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import android.Manifest;
import android.os.Bundle;
import android.os.Build;
import android.os.Message;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.media.MediaScannerConnection;
import android.net.Uri;
import android.util.Log;
import android.view.KeyEvent;
@@ -33,6 +39,7 @@ import android.content.ContentResolver;
public class MainActivity extends AppCompatActivity {
private static final int LOGIN_REQUEST_CODE = 1;
private static final int STORAGE_PERMISSION_REQUEST_CODE = 2;
private WebView webView;
private ValueCallback<Uri[]> fileChooserCallback;
@@ -43,6 +50,7 @@ public class MainActivity extends AppCompatActivity {
super.onCreate(savedInstanceState);
tokenResult = "";
requestLegacyStoragePermissionIfNeeded();
// For Android Emulator, use 10.0.2.2 to access localhost on your development machine.
// If testing on a physical device, replace 10.0.2.2 with your development machine's actual local IP address.
@@ -220,6 +228,28 @@ public class MainActivity extends AppCompatActivity {
}
}
private void requestLegacyStoragePermissionIfNeeded() {
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P
&& ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, STORAGE_PERMISSION_REQUEST_CODE);
}
}
private String safePdfFilename(String filename) {
String safeName = filename == null ? "reporte.pdf" : filename.trim();
safeName = safeName.replaceAll("[\\\\/:*?\"<>|]", "_");
if (safeName.length() == 0) {
safeName = "reporte.pdf";
}
if (!safeName.toLowerCase().endsWith(".pdf")) {
safeName += ".pdf";
}
return safeName;
}
public class WebAppInterface {
@JavascriptInterface
public void startGoogleLogin() {
@@ -235,16 +265,20 @@ public class MainActivity extends AppCompatActivity {
@JavascriptInterface
public void savePdf(String base64Data, String filename) {
String safeFilename = safePdfFilename(filename);
try {
byte[] pdfAsBytes = Base64.decode(base64Data, 0);
String normalizedBase64 = base64Data == null ? "" : base64Data.replaceFirst("^data:application/pdf;base64,", "");
byte[] pdfAsBytes = Base64.decode(normalizedBase64, Base64.DEFAULT);
boolean isSaved = false;
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
ContentResolver resolver = getContentResolver();
ContentValues contentValues = new ContentValues();
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, filename);
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, safeFilename);
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "application/pdf");
contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS);
contentValues.put(MediaStore.MediaColumns.IS_PENDING, 1);
Uri uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues);
@@ -253,18 +287,31 @@ public class MainActivity extends AppCompatActivity {
outputStream.write(pdfAsBytes);
isSaved = true;
} catch (IOException e) {
e.printStackTrace();
Log.e("WEBVIEW", "Error writing PDF to MediaStore: " + safeFilename, e);
resolver.delete(uri, null, null);
}
if (isSaved) {
ContentValues completedValues = new ContentValues();
completedValues.put(MediaStore.MediaColumns.IS_PENDING, 0);
resolver.update(uri, completedValues, null, null);
}
}
} else {
File path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
File file = new File(path, filename);
if (!path.exists() && !path.mkdirs()) {
Log.e("WEBVIEW", "Could not create Downloads directory: " + path.getAbsolutePath());
} else {
File file = new File(path, safeFilename);
try (FileOutputStream os = new FileOutputStream(file)) {
os.write(pdfAsBytes);
isSaved = true;
MediaScannerConnection.scanFile(MainActivity.this, new String[]{file.getAbsolutePath()}, new String[]{"application/pdf"}, null);
} catch (IOException e) {
e.printStackTrace();
Log.e("WEBVIEW", "Error writing PDF to Downloads: " + safeFilename, e);
}
}
}
@@ -275,7 +322,7 @@ public class MainActivity extends AppCompatActivity {
}
} catch (Exception e) {
e.printStackTrace();
Log.e("WEBVIEW", "Error saving PDF: " + safeFilename, e);
runOnUiThread(() -> Toast.makeText(MainActivity.this, "Error: " + e.getMessage(), Toast.LENGTH_LONG).show());
}
}
+26
View File
@@ -20,6 +20,7 @@
"@types/jest": "^30.0.0",
"@types/mongoose": "^5.11.96",
"@types/node": "^24.3.3",
"cross-env": "^10.1.0",
"jest": "^30.4.2",
"ts-jest": "^29.4.11",
"ts-node": "^10.9.2",
@@ -600,6 +601,13 @@
"tslib": "^2.4.0"
}
},
"node_modules/@epic-web/invariant": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
"dev": true,
"license": "MIT"
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -2322,6 +2330,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/cross-env": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@epic-web/invariant": "^1.0.0",
"cross-spawn": "^7.0.6"
},
"bin": {
"cross-env": "dist/bin/cross-env.js",
"cross-env-shell": "dist/bin/cross-env-shell.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+2 -1
View File
@@ -4,7 +4,7 @@
"type": "module",
"main": "index.js",
"scripts": {
"dev": "NODE_NO_WARNINGS=1 DOTENV_DISABLE_TELEMETRY=1 node --loader ts-node/esm src/index.ts",
"dev": "cross-env NODE_NO_WARNINGS=1 DOTENV_DISABLE_TELEMETRY=1 node --loader ts-node/esm src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"test": "jest --config jest.config.cjs"
@@ -18,6 +18,7 @@
"@types/jest": "^30.0.0",
"@types/mongoose": "^5.11.96",
"@types/node": "^24.3.3",
"cross-env": "^10.1.0",
"jest": "^30.4.2",
"ts-jest": "^29.4.11",
"ts-node": "^10.9.2",
@@ -91,12 +91,17 @@ export async function dispatchEmail(params: {
systemToken: string;
type: string;
email: string;
subject: string;
message: string;
subject?: string;
message?: string;
templateId?: string;
context?: Record<string, unknown>;
}): Promise<DispatchResult> {
try {
if (!params.email.trim() || !params.subject.trim() || !params.message.trim()) {
return { success: false, error: "Email dispatch requires email, subject, and message" };
const hasContent = Boolean(params.subject?.trim() && params.message?.trim());
const hasTemplate = Boolean(params.templateId?.trim() && params.context);
if (!params.email.trim() || (!hasContent && !hasTemplate)) {
return { success: false, error: "Email dispatch requires email and either subject/message or templateId/context" };
}
// Send worker-rendered content directly so cancellation jobs survive appointment deletion.
@@ -106,8 +111,10 @@ export async function dispatchEmail(params: {
{
systemToken: params.systemToken,
email: params.email,
subject: params.subject,
message: params.message,
...(params.subject ? { subject: params.subject } : {}),
...(params.message ? { message: params.message } : {}),
...(params.templateId ? { templateId: params.templateId } : {}),
...(params.context ? { context: params.context } : {}),
},
{
headers: { "Content-Type": "application/json" },
@@ -202,6 +202,8 @@ export class JobProcessor {
email: content.email || "",
subject: content.subject,
message: content.message,
templateId: content.emailTemplateId,
context: content.emailContext,
});
break;
case "system":
@@ -45,6 +45,8 @@ export interface ResolvedNotificationContent {
companyOwnerId?: string;
subject: string;
message: string;
emailTemplateId?: string;
emailContext?: Record<string, unknown>;
}
const appointmentSchema = new Schema(
@@ -169,6 +171,8 @@ export async function resolveNotificationContent(job: INotificationJobDocument):
companyOwnerId: companyOwnerId ? String(companyOwnerId) : undefined,
subject: fallbackSubject.trim(),
message: fallbackMessage.trim(),
emailTemplateId: job.payload.emailTemplateId?.trim(),
emailContext: job.payload.emailContext,
};
}
}
@@ -15,6 +15,8 @@ export interface NotificationJobPayload {
message?: string;
emailSubject?: string;
emailMessage?: string;
emailTemplateId?: string;
emailContext?: Record<string, unknown>;
wapMessage?: string;
systemSubject?: string;
systemMessage?: string;
@@ -43,6 +43,30 @@ describe("ChannelDispatchers", () => {
);
});
it("sends email template data when provided", async () => {
await dispatchEmail({
appointmentId: "appointment-1",
systemToken: "system-token",
type: "reminder",
email: "client@example.com",
templateId: "template-1",
context: { username: "Client Name" },
});
expect(mockedAxios.post).toHaveBeenCalledWith(
"https://api.example.com/notifications/send-email",
{
systemToken: "system-token",
email: "client@example.com",
templateId: "template-1",
context: { username: "Client Name" },
},
{
headers: { "Content-Type": "application/json" },
}
);
});
it("sends the job type in the WhatsApp system notification payload", async () => {
await dispatchWhatsApp({
companyId: "company-1",
@@ -0,0 +1 @@
mO9PXuuYR05od8cMVCIXEw
+1
View File
@@ -0,0 +1 @@
Frx8us2W@
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
ngrok config add-authtoken 3I94dMHXyh3wjRjGBu0oGQ8D8h4_2yBusck6ZWHJBx7WcbBFf
ngrok http 3000
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.
+1 -1
View File
@@ -45,7 +45,7 @@ MP_SUBSCRIPTION_FIND_URL = https://api.mercadopago.com/preapproval/
MP_ACCESS_TOKEN = APP_USR-8753424836448507-121412-731599daea1ffa0b278b5dcc78aa1ec8-1611134648
MP_BACK_URL = https://turnosxpress.com.ar/landing/dashboard
MP_CHECK_PAYMENT_MINUTES = 1
MP_WEBHOOK_URL = https://9300-186-132-200-176.ngrok-free.app/mercadopago/webhook
MP_WEBHOOK_URL = https://5f09-186-58-32-217.ngrok-free.app/mercadopago/webhook
DEFAULT_BOT_ADMIN_PORT = 3005
-82
View File
@@ -6648,38 +6648,6 @@
"url": "https://opencollective.com/mongoose"
}
},
"node_modules/mongoose/node_modules/gaxios": {
"version": "5.1.3",
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz",
"integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==",
"license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": {
"extend": "^3.0.2",
"https-proxy-agent": "^5.0.0",
"is-stream": "^2.0.0",
"node-fetch": "^2.6.9"
},
"engines": {
"node": ">=12"
}
},
"node_modules/mongoose/node_modules/gcp-metadata": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz",
"integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==",
"license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": {
"gaxios": "^5.0.0",
"json-bigint": "^1.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/mongoose/node_modules/mongodb": {
"version": "6.10.0",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.10.0.tgz",
@@ -6731,56 +6699,6 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
},
"node_modules/mongoose/node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/mongoose/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/mongoose/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause",
"optional": true,
"peer": true
},
"node_modules/mongoose/node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/mpath": {
"version": "0.9.0",
"resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz",
@@ -0,0 +1,45 @@
import { ScheduleItem } from "../Schedules/Schedules.Interface";
import { ISchedulesDisabled } from "../SchedulesDisabled/SchedulesDisabled.Interface";
import { ISchedulesOverrides } from "../SchedulesOverrides/SchedulesOverrides.Interface";
export type CollaboratorScheduleDetailsParams = {
companyId: string;
employeeId: string;
sessionUser: string;
fromDate?: Date;
};
export type CollaboratorScheduleDetailsCollaborator = {
employeeId: string;
fullName: string;
email: string;
avatar: string;
userId: string;
};
export type CollaboratorWeeklySchedule = {
companyId: string;
employeeId: string;
weekDay: number;
scheduleId: string;
schedules: ScheduleItem[];
};
export type CollaboratorScheduleServiceSummary = {
id: string;
name: string;
};
export type CollaboratorScheduleDetailsView = {
collaborator: CollaboratorScheduleDetailsCollaborator;
servicesById: Record<string, CollaboratorScheduleServiceSummary>;
weeklySchedules: CollaboratorWeeklySchedule[];
futureDisabledSchedules: ISchedulesDisabled[];
futureOverrides: ISchedulesOverrides[];
};
export interface IAdminAssistantManager {
getCollaboratorScheduleDetails(
data: CollaboratorScheduleDetailsParams
): Promise<CollaboratorScheduleDetailsView>;
}
@@ -0,0 +1,98 @@
import dayjs from "dayjs";
import EmployeesList from "../Employees/Employee";
import ServicesList from "../Services/Service";
import SchedulesList from "../Schedules/Schedules";
import SchedulesDisabledList from "../SchedulesDisabled/SchedulesDisabled";
import SchedulesOverridesList from "../SchedulesOverrides/SchedulesOverrides";
import UsersManager from "../Users/Users";
import getAvatar from "../../helpers/getAvatar";
import { joinStrings } from "../../helpers/String";
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
import {
CollaboratorScheduleDetailsParams,
CollaboratorScheduleDetailsView,
IAdminAssistantManager,
} from "./AdminAssistant.Interface";
class AdminAssistantManager implements IAdminAssistantManager {
public async getCollaboratorScheduleDetails(
data: CollaboratorScheduleDetailsParams
): Promise<CollaboratorScheduleDetailsView> {
await validateSessionUser({ sessionUser: data.sessionUser });
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
const employee = await EmployeesList.employees.findOne({
_id: data.employeeId,
companyId: data.companyId,
});
if (!employee) {
throw new Error("El colaborador no existe o no pertenece a la organización.");
}
const user = await UsersManager.users.findOne({ _id: String(employee.userId) });
const snapshot = employee.profileSnapshot || {};
const fullName = user
? joinStrings([user.firstName, user.lastName], " ")
: joinStrings([snapshot.firstName, snapshot.lastName], " ");
const email = user?.email || snapshot.email || "";
const avatar = user
? getAvatar(String(user.id), user.avatar, fullName)
: getAvatar(String(employee.userId), snapshot.avatar || "", fullName);
const fromDate = data.fromDate ? dayjs(data.fromDate).startOf("day").toDate() : dayjs().startOf("day").toDate();
const weeklySchedules = await SchedulesList.find({
companyId: data.companyId,
employeeId: data.employeeId,
});
const futureDisabledSchedules = await SchedulesDisabledList.find({
companyId: data.companyId,
employeeId: data.employeeId,
endDate: { $gte: fromDate } as any,
sessionUser: data.sessionUser,
});
const futureOverrides = await SchedulesOverridesList.find({
companyId: data.companyId,
employeeId: data.employeeId,
date: { $gte: fromDate } as any,
});
const companyServices = await ServicesList.findByCompanyId({ companyId: data.companyId });
const servicesById = companyServices.reduce((index, service) => {
index[service.id] = {
id: service.id,
name: service.name,
};
return index;
}, {} as CollaboratorScheduleDetailsView["servicesById"]);
return {
collaborator: {
employeeId: String(employee.id || employee._id),
fullName: fullName || email || "Colaborador",
email,
avatar,
userId: String(employee.userId),
},
servicesById,
weeklySchedules: weeklySchedules
.sort((a, b) => a.weekDay - b.weekDay)
.map((schedule) => ({
companyId: String(schedule.companyId),
employeeId: String(schedule.employeeId),
scheduleId: String(schedule.id || (schedule as any)._id || ""),
weekDay: schedule.weekDay,
schedules: schedule.schedules || [],
})),
futureDisabledSchedules: futureDisabledSchedules.sort((a, b) =>
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
),
futureOverrides: futureOverrides.sort((a, b) =>
new Date(a.date).getTime() - new Date(b.date).getTime()
),
};
}
}
const AdminAssistantList = new AdminAssistantManager();
export default AdminAssistantList;
@@ -46,6 +46,7 @@ export type SendAppointmentNotificationParams = {
systemToken?: string;
type: APPOINTMENT_NOTIFICATION_TYPE;
channel?: "whatsapp" | "email";
allowClientCancellation?: boolean;
};
export type CreateImmediateAppointmentNotificationJobsParams = {
@@ -203,6 +204,8 @@ export interface AppointmentNotificationIntent {
message: string;
checkClient: IClientDocument;
companyCheck: ICompanyDocument;
emailTemplateId?: string;
emailContext?: Record<string, unknown>;
}
export interface IAppointment {
+150 -13
View File
@@ -287,6 +287,23 @@ class AppointmentManager implements IAppointmentsManager {
return parseInt(`${process.env.CANCELLATION_TIME}`) || 24;
}
private formatAppointmentDuration(startHour: number, endHour: number): string {
const durationHours = endHour >= startHour ? endHour - startHour : endHour + 24 - startHour;
const durationMinutes = Math.round(durationHours * 60);
const hours = Math.floor(durationMinutes / 60);
const minutes = durationMinutes % 60;
if (hours > 0 && minutes > 0) {
return `${hours} ${hours === 1 ? "hora" : "horas"} y ${minutes} minutos`;
}
if (hours > 0) {
return `${hours} ${hours === 1 ? "hora" : "horas"}`;
}
return `${minutes} minutos`;
}
private validateCancellationTime(aTime: number | undefined, start: string | Date): boolean {
let cancellationTime = this.getCancellationTime(aTime);
@@ -517,7 +534,7 @@ class AppointmentManager implements IAppointmentsManager {
await this.updateHeatMap(availabilityConfig);
await this.tryApplyDiscounts(newAppointment);
await this.tryApplyDiscounts(newAppointment, String(companyCheck.ownerId));
if (notification && checkClient.userId) {
//Envio la notificacion al cliente.
@@ -575,7 +592,7 @@ class AppointmentManager implements IAppointmentsManager {
}
}
private async tryApplyDiscounts(appointment: IAppointment) {
private async tryApplyDiscounts(appointment: IAppointment, sessionUser: string) {
//Intenta aplicar un descuento al turno si esta especificado en DiscountsEmail
// y la fecha coincide con el rango especificado.
@@ -615,7 +632,7 @@ class AppointmentManager implements IAppointmentsManager {
appointmentId: String(appointment.id),
discountId: String(existDiscount.discountId),
companyId: String(appointment.companyId),
sessionUser: String(appointment.userId),
sessionUser: sessionUser,
});
} catch (err) {
console.log(err);
@@ -670,6 +687,47 @@ class AppointmentManager implements IAppointmentsManager {
// Update both heatmaps
await this.updateHeatMap(originalConfig);
await this.updateHeatMap(newConfig);
// Reprogram pending notification jobs for the new date/time. The appointment
// was already saved, so tryToSendNotification reads the new start date and
// builds the reminder content (email template included) from it.
try {
// Cancel the jobs scheduled for the original date/time so they don't fire late.
await this.jobService.cancelByAppointment(String(appointment._id));
const reminderEmailContent = await this.tryToSendNotification({
appointmentId: String(appointment._id),
sessionUser: data.sessionUser || "",
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
channel: "email",
});
const reminderWapContent = await this.tryToSendNotification({
appointmentId: String(appointment._id),
sessionUser: data.sessionUser || "",
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
channel: "whatsapp",
});
await this.createReminderNotificationJobs({
appointmentId: String(appointment._id),
companyId: String(appointment.companyId),
clientId: String(appointment.clientId),
clientUserId: reminderEmailContent.checkClient.userId
? String(reminderEmailContent.checkClient.userId)
: undefined,
clientEmail: reminderEmailContent.checkClient.email,
clientPhoneNumber: await this.getOptionalClientWapNumber(reminderEmailContent.checkClient),
companyOwnerId: String(reminderEmailContent.companyCheck.ownerId),
companyName: reminderEmailContent.companyCheck.name,
appointmentStart: appointment.start,
reminderEmailMessage: reminderEmailContent.message,
reminderWapMessage: reminderWapContent.message,
reminderEmailTemplateId: reminderEmailContent.emailTemplateId,
reminderEmailContext: reminderEmailContent.emailContext,
});
} catch (error) {
console.log("Error reprogramming notification jobs after move:", error);
}
}
public async changeServiceForce(data: ChangeServiceParams): Promise<void> {
@@ -2168,6 +2226,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
companyOwnerId: string;
companyName: string;
emailMessage: string;
emailTemplateId?: string;
emailContext?: Record<string, unknown>;
wapMessage: string;
}): Promise<void> {
const systemSubject = `Turno cancelado en ${data.companyName}`;
@@ -2187,6 +2247,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
message: data.emailMessage,
emailSubject: "TurnosXpress :: Turno cancelado",
emailMessage: data.emailMessage,
emailTemplateId: data.emailTemplateId,
emailContext: data.emailContext,
wapMessage: data.wapMessage,
systemSubject,
systemMessage: data.emailMessage,
@@ -2206,8 +2268,12 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
appointmentStart: Date;
emailMessage: string;
wapMessage: string;
emailTemplateId?: string;
emailContext?: Record<string, unknown>;
reminderEmailMessage: string;
reminderWapMessage: string;
reminderEmailTemplateId?: string;
reminderEmailContext?: Record<string, unknown>;
}): Promise<void> {
const systemSubject = `Turno reservado en ${data.companyName}`;
@@ -2220,6 +2286,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
message: data.emailMessage,
emailSubject: "TurnosXpress :: Alta de turno",
emailMessage: data.emailMessage,
emailTemplateId: data.emailTemplateId,
emailContext: data.emailContext,
wapMessage: data.wapMessage,
systemSubject,
systemMessage: data.emailMessage,
@@ -2277,6 +2345,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
companyName: data.companyName,
appointmentStart: data.appointmentStart,
reminderEmailMessage: reminderEmailContent.message,
reminderEmailTemplateId: reminderEmailContent.emailTemplateId,
reminderEmailContext: reminderEmailContent.emailContext,
reminderWapMessage: reminderWapContent.message,
};
@@ -2301,6 +2371,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
await this.createCreationNotificationJobs({
...notificationJobData,
emailMessage: emailContent.message,
emailTemplateId: emailContent.emailTemplateId,
emailContext: emailContent.emailContext,
wapMessage: wapContent.message,
});
}
@@ -2317,6 +2389,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
appointmentStart: Date;
reminderEmailMessage: string;
reminderWapMessage: string;
reminderEmailTemplateId?: string;
reminderEmailContext?: Record<string, unknown>;
}): Promise<void> {
const reminderSystemSubject = `Recordatorio de turno en ${data.companyName}`;
@@ -2335,6 +2409,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
message: data.reminderEmailMessage,
emailSubject: "TurnosXpress :: Recordatorio",
emailMessage: data.reminderEmailMessage,
emailTemplateId: data.reminderEmailTemplateId,
emailContext: data.reminderEmailContext,
wapMessage: data.reminderWapMessage,
systemSubject: reminderSystemSubject,
systemMessage: data.reminderEmailMessage,
@@ -2491,12 +2567,14 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
sessionUser: data.sessionUser,
type: APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION,
channel: "email",
allowClientCancellation: true,
});
const wapContent = await this.tryToSendNotification({
appointmentId: String(checkAppointment._id),
sessionUser: data.sessionUser,
type: APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION,
channel: "whatsapp",
allowClientCancellation: true,
});
await this.createCancellationNotificationJobs({
@@ -2509,6 +2587,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
companyOwnerId: String(companyCheck.ownerId),
companyName: companyCheck.name,
emailMessage: emailContent.message,
emailTemplateId: emailContent.emailTemplateId,
emailContext: emailContent.emailContext,
wapMessage: wapContent.message,
});
}
@@ -2650,6 +2730,14 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
throw new Error("La compañia no existe");
}
const checkClient = await ClientsManager.clients.findOne({
_id: String(checkAppointment.clientId),
});
if (!checkClient) {
throw new Error("El cliente no existe");
}
const hasSystemToken = this.hasValidSystemToken(data.systemToken);
if (!hasSystemToken) {
@@ -2657,19 +2745,19 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción");
}
if (!(await EmployeesList.checkPermission(companyCheck, data.sessionUser, EmployeeRoles.ADMIN))) {
const isAuthorizedClientCancellation =
data.allowClientCancellation === true &&
data.type === APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION &&
String(checkClient.userId) === data.sessionUser;
if (
!isAuthorizedClientCancellation &&
!(await EmployeesList.checkPermission(companyCheck, data.sessionUser, EmployeeRoles.ADMIN))
) {
throw new Error(NoPermissionMessage());
}
}
const checkClient = await ClientsManager.clients.findOne({
_id: String(checkAppointment.clientId),
});
if (!checkClient) {
throw new Error("El cliente no existe");
}
const checkEmployee = await EmployeesList.employees.findOne({
_id: String(checkAppointment.employeeId),
});
@@ -2695,6 +2783,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
}
let wapMessage = "";
let emailTemplateId: string | undefined;
let emailContext: Record<string, unknown> | undefined;
if (data.type == APPOINTMENT_NOTIFICATION_TYPE.REMINDER) {
if (companyCheck.templateWapNotifId) {
@@ -2738,6 +2828,19 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name);
wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n");
emailTemplateId = "6a86ef631898c6948d0f5308";
emailContext = {
username: ClientsManager.getClientFullName(checkClient),
nombre_organizacion: companyCheck.name,
fecha_turno: dayjs(checkAppointment.start).format("DD/MM/YYYY"),
hora_turno: dayjs(checkAppointment.start).format("HH:mm") + "hs.",
nombre_servicio: checkService.name,
nombre_profesional: UsersManager.getUserFullName(checkEmployeeUser),
duracion: this.formatAppointmentDuration(checkAppointment.startHour, checkAppointment.endHour),
precio: formatCurrency(checkAppointment.price),
direccion: CompaniesManager.getCompanyAddress(companyCheck),
};
} else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CREATION) {
const templateId = data.channel === "email"
? companyCheck.templateEmailAltaId || companyCheck.templateWapAltaId
@@ -2787,6 +2890,19 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name);
wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n");
emailTemplateId = "6a86f55e3edd051e8a04c711";
emailContext = {
username: ClientsManager.getClientFullName(checkClient),
nombre_organizacion: companyCheck.name,
fecha_turno: dayjs(checkAppointment.start).format("DD/MM/YYYY"),
hora_turno: dayjs(checkAppointment.start).format("HH:mm") + "hs.",
nombre_servicio: checkService.name,
nombre_profesional: UsersManager.getUserFullName(checkEmployeeUser),
duracion: this.formatAppointmentDuration(checkAppointment.startHour, checkAppointment.endHour),
precio: formatCurrency(checkAppointment.price),
direccion: CompaniesManager.getCompanyAddress(companyCheck),
};
} else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION || data.type == APPOINTMENT_NOTIFICATION_TYPE.UPDATE) {
const isUpdate = data.type == APPOINTMENT_NOTIFICATION_TYPE.UPDATE;
const templateId = data.channel === "email"
@@ -2842,12 +2958,29 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
wapMessage = wapMessage.replaceAll("<---PRECIO--->", formatCurrency(checkAppointment.price));
wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name);
wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n");
emailTemplateId = isUpdate
? "6a86febf6713e0bdd104c7ca"
: "6a86f77d376a7371170b21c5";
emailContext = {
username: ClientsManager.getClientFullName(checkClient),
nombre_organizacion: companyCheck.name,
fecha_turno: dayjs(checkAppointment.start).format("DD/MM/YYYY"),
hora_turno: dayjs(checkAppointment.start).format("HH:mm") + "hs.",
nombre_servicio: checkService.name,
nombre_profesional: UsersManager.getUserFullName(checkEmployeeUser),
duracion: this.formatAppointmentDuration(checkAppointment.startHour, checkAppointment.endHour),
precio: formatCurrency(checkAppointment.price),
direccion: CompaniesManager.getCompanyAddress(companyCheck),
};
}
return {
message: wapMessage,
checkClient: checkClient,
companyCheck: companyCheck,
emailTemplateId,
emailContext,
};
}
@@ -2866,7 +2999,7 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
}
}
const { message, checkClient, companyCheck } = await this.tryToSendNotification({
const { message, checkClient, companyCheck, emailTemplateId, emailContext } = await this.tryToSendNotification({
...data,
channel: "email",
});
@@ -2903,6 +3036,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
email: clientEmail,
subject: subjectEmail,
message: message,
templateId: emailTemplateId,
context: emailContext,
});
}
@@ -2954,6 +3089,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
message: emailContent.message,
emailSubject: isCreation ? "TurnosXpress :: Alta de turno" : "TurnosXpress :: Recordatorio",
emailMessage: emailContent.message,
emailTemplateId: emailContent.emailTemplateId,
emailContext: emailContent.emailContext,
wapMessage: wapContent.message,
systemSubject,
systemMessage: emailContent.message,
@@ -380,4 +380,5 @@ export interface ICompaniesManager {
sysAdminUpdate(data: SysAdminUpdateCompanyParams): Promise<void>;
sysAdminSetBanned(data: SysAdminSetCompanyBannedParams): Promise<void>;
sysAdminGetInsights(data: SysAdminCompanyInsightsParams): Promise<SysAdminCompanyInsightsResult>;
checkCompanyAdmin(data: { companyId: string; sessionUser: string }): Promise<boolean>;
}
+13
View File
@@ -1221,6 +1221,19 @@ class CompaniesManager implements ICompaniesManager {
},
};
}
public async checkCompanyAdmin(data: { companyId: string; sessionUser: string }): Promise<boolean> {
const filterData =
data.companyId.indexOf("-") >= 0 ? { slug: data.companyId } : { _id: data.companyId };
const company = await this.companies.findOne(filterData as any);
if (!company) {
return false;
}
return await EmployeesList.checkPermission(company, data.sessionUser, EmployeeRoles.ADMIN);
}
}
const CompaniesList = new CompaniesManager();
@@ -157,7 +157,10 @@ export class MetricsAdapterMongoose implements IMetricsAdapter {
if (!metrics.repeatsCount) {
metrics.repeatsCount = 0;
}
metrics.repeatsCount += isNull<number>(data.quantity, 1);
metrics.repeatsCount = Math.max(
0,
isNull<number>(metrics.repeatsCount, 0) + isNull<number>(data.quantity, 1)
);
await metrics.save();
}
}
@@ -37,6 +37,7 @@ export interface IMetricsAdapter {
addService(data: MetricsParams): Promise<void>;
addAppointment(data: MetricsParams): Promise<void>;
addClient(data: MetricsParams): Promise<void>;
addRepeat(data: MetricsParams): Promise<void>;
}
export interface IMetricsManager {
@@ -50,6 +51,7 @@ export interface IMetricsManager {
reserveAppointment(data: MetricsParams): Promise<boolean>;
releaseAppointment(data: MetricsParams): Promise<void>;
addClient(data: MetricsParams): Promise<void>;
addRepeat(data: MetricsParams): Promise<void>;
canAddOrganization(userId: string): Promise<boolean>;
canAddEmployee(userId: string): Promise<boolean>;
canAddService(userId: string): Promise<boolean>;
@@ -23,6 +23,8 @@ export interface NotificationJobPayload {
message?: string;
emailSubject?: string;
emailMessage?: string;
emailTemplateId?: string;
emailContext?: Record<string, unknown>;
wapMessage?: string;
systemSubject?: string;
systemMessage?: string;
@@ -3,13 +3,37 @@ import { INotificationsAdapter, NotificationDataEmail } from "./Notifications.In
export class NotificationsDonWebAdapter implements INotificationsAdapter<NotificationDataEmail> {
async send(data: NotificationDataEmail): Promise<void> {
const dataEmail = {
if (data.context && data.substitutions) {
throw new Error("No se puede enviar un email con context y substitutions al mismo tiempo.");
}
if (!data.templateId && !data.message) {
throw new Error("Debe indicarse el contenido del email mediante message o templateId.");
}
const dataEmail: Record<string, unknown> = {
from: "norepply@turnosxpress.com.ar",
to: data.email,
subject: data.subject,
html: data.message,
};
if (data.templateId) {
dataEmail.templateID = data.templateId;
} else {
dataEmail.html = data.message;
}
if (data.subject) {
dataEmail.subject = data.subject;
}
if (data.context) {
dataEmail.context = data.context;
}
if (data.substitutions) {
dataEmail.substitutions = data.substitutions;
}
const apiConfig = {
method: "post",
maxBodyLength: Infinity,
@@ -6,8 +6,11 @@ export enum NOTIFICATION_TYPES {
export type NotificationDataEmail = {
email: string;
subject: string;
message: string;
subject?: string;
message?: string;
templateId?: string;
context?: Record<string, unknown>;
substitutions?: Record<string, string | number | boolean>;
};
export type NotificationDataWap = {
@@ -112,7 +112,6 @@ export class PlanUsageCycleAdapterMongoose implements IPlanUsageCycleAdapter {
planId: data.planId,
cycleStart: data.cycleStart,
cycleEnd: data.cycleEnd,
appointmentsCount: 0,
creationDate: now,
},
$inc: { appointmentsCount: data.quantity },
+1
View File
@@ -212,6 +212,7 @@ class RepeatsManager implements IRepeatsManager {
}
await this.repeats.delete(data.repeatId);
await MetricsList.addRepeat({ userId: String(companyCheck.ownerId), quantity: -1 });
}
public async deleteRepeatsByCompany(data: DeleteRepeatsByCompanyParams): Promise<void> {
@@ -8,6 +8,34 @@ export type ScheduleItem = {
serviceIds?: string[];
};
export type ScheduleConflictStrategy = "reject" | "overwrite-conflicts";
export type ScheduleConflictDetail = {
employeeId: string;
employeeName: string;
weekDay: number;
weekDayLabel: string;
attempted: ScheduleItem;
existing: ScheduleItem;
message: string;
};
export type ScheduleApplyParams = {
companyId: string;
employeeIds: string[];
weekDays: number[];
schedules: ScheduleItem[];
sessionUser: string;
conflictStrategy: ScheduleConflictStrategy;
};
export type ScheduleApplyResult = {
valid: boolean;
applied: boolean;
conflicts: ScheduleConflictDetail[];
message: string;
};
export type FindSchedulesParams = {
_id?: string;
companyId?: string;
@@ -100,6 +128,7 @@ export interface ISchedulesManager {
findAllSchedulesByCollaborator(data: FindSchedulesParams): Promise<CollaboratorSchedulesView>;
delete(data: DeleteScheduleParams): Promise<void>;
isAvailable(data: AvailableSchedulesParams): Promise<boolean>;
applyScheduleChanges(data: ScheduleApplyParams): Promise<ScheduleApplyResult>;
deleteSchedulesByCompany(data: DeleteScheulesByCompanyParams): Promise<void>;
deleteSchedulesByEmployee(data: DeleteSchedulesByEmployeeParams): Promise<void>;
}
+169
View File
@@ -13,6 +13,9 @@ import {
DeleteScheulesByCompanyParams,
DeleteSchedulesByEmployeeParams,
ScheduleItem,
ScheduleApplyParams,
ScheduleApplyResult,
ScheduleConflictDetail,
} from "./Schedules.Interface";
import { EmployeeRoles } from "../Employees/Employees.Interface";
import UsersManager from "../Users/Users";
@@ -24,6 +27,9 @@ import { isNull } from "../../helpers/IsNull";
import getAvatar from "../../helpers/getAvatar";
import { NotificationsManager } from "../Notifications/Notifications";
import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface";
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
const WEEK_DAY_LABELS = ["Domingo", "Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado"];
class SchedulesManager implements ISchedulesManager {
schedules: SchedulesAdapterMongoose;
@@ -173,6 +179,169 @@ class SchedulesManager implements ISchedulesManager {
return await this.schedules.find(filters);
}
private parseTimeToMinutes(time: string): number {
const [hours, minutes] = time.split(":").map((part) => Number(part));
if (!Number.isInteger(hours) || !Number.isInteger(minutes)) {
throw new Error("El horario indicado no es válido");
}
return hours * 60 + minutes;
}
private hasTimeOverlap(first: ScheduleItem, second: ScheduleItem): boolean {
return this.parseTimeToMinutes(first.from) < this.parseTimeToMinutes(second.to)
&& this.parseTimeToMinutes(second.from) < this.parseTimeToMinutes(first.to);
}
private getServiceScope(schedule: ScheduleItem): "all" | "specific" {
return schedule.serviceScope || "all";
}
private hasServiceOverlap(first: ScheduleItem, second: ScheduleItem): boolean {
const firstScope = this.getServiceScope(first);
const secondScope = this.getServiceScope(second);
if (firstScope === "all" || secondScope === "all") {
return true;
}
const secondServices = new Set(second.serviceIds || []);
return (first.serviceIds || []).some((serviceId) => secondServices.has(serviceId));
}
private hasScheduleConflict(first: ScheduleItem, second: ScheduleItem): boolean {
if (first.disabled || second.disabled) {
return false;
}
return this.hasTimeOverlap(first, second) && this.hasServiceOverlap(first, second);
}
private formatSchedule(schedule: ScheduleItem): string {
if (this.getServiceScope(schedule) === "all") {
return `${schedule.from} a ${schedule.to} para todos los servicios`;
}
return `${schedule.from} a ${schedule.to} para servicios específicos`;
}
private getEmployeeName(employee: any): string {
const snapshotName = [employee.profileSnapshot?.firstName, employee.profileSnapshot?.lastName].filter(Boolean).join(" ").trim();
return snapshotName || employee.fullName || employee.name || String(employee._id);
}
private buildConflict(employee: any, weekDay: number, attempted: ScheduleItem, existing: ScheduleItem): ScheduleConflictDetail {
const employeeId = String(employee._id);
const employeeName = this.getEmployeeName(employee);
const weekDayLabel = WEEK_DAY_LABELS[weekDay] || String(weekDay);
return {
employeeId,
employeeName,
weekDay,
weekDayLabel,
attempted,
existing,
message: `${employeeName} ya tiene un horario el ${weekDayLabel} de ${this.formatSchedule(existing)} que se superpone con ${this.formatSchedule(attempted)}.`,
};
}
private async findScheduleDocument(companyId: string, employeeId: string, weekDay: number) {
return this.schedules.findOne({ companyId, employeeId, weekDay });
}
public async applyScheduleChanges(data: ScheduleApplyParams): Promise<ScheduleApplyResult> {
await validateSessionUser({ sessionUser: data.sessionUser });
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
if (!Array.isArray(data.employeeIds) || data.employeeIds.length === 0) {
throw new Error("Tenés que seleccionar al menos un colaborador.");
}
if (!Array.isArray(data.weekDays) || data.weekDays.length === 0 || data.weekDays.some((day) => !Number.isInteger(day) || day < 0 || day > 6)) {
throw new Error("Tenés que seleccionar días válidos entre 0 y 6.");
}
if (data.conflictStrategy !== "reject" && data.conflictStrategy !== "overwrite-conflicts") {
throw new Error("La estrategia de conflictos no es válida.");
}
const employees = await Promise.all(data.employeeIds.map(async (employeeId) => {
const employee = await EmployeesList.employees.findOne({ companyId: data.companyId, _id: employeeId });
if (!employee) {
throw new Error("El empleado no existe o no pertenece a la compañia.");
}
return employee;
}));
const conflicts: ScheduleConflictDetail[] = [];
for (const employee of employees) {
const employeeId = String(employee._id);
for (const weekDay of data.weekDays) {
const scheduleDocument = await this.findScheduleDocument(data.companyId, employeeId, weekDay);
const existingSchedules = scheduleDocument?.schedules || [];
for (const attempted of data.schedules) {
const existing = existingSchedules.find((schedule) => this.hasScheduleConflict(attempted, schedule));
if (existing) {
conflicts.push(this.buildConflict(employee, weekDay, attempted, existing));
}
}
}
}
if (conflicts.length > 0 && data.conflictStrategy === "reject") {
return {
valid: false,
applied: false,
conflicts,
message: "Encontramos horarios superpuestos. Revisalos antes de guardar o elegí sobrescribir solo esos conflictos.",
};
}
for (const employee of employees) {
const employeeId = String(employee._id);
for (const weekDay of data.weekDays) {
const scheduleDocument = await this.findScheduleDocument(data.companyId, employeeId, weekDay);
const existingSchedules = scheduleDocument?.schedules || [];
const nextSchedules = data.conflictStrategy === "overwrite-conflicts"
? existingSchedules.filter((existing) => !data.schedules.some((attempted) => this.hasScheduleConflict(attempted, existing)))
: existingSchedules;
const schedules = [...nextSchedules, ...data.schedules];
if (scheduleDocument) {
scheduleDocument.schedules = schedules;
await scheduleDocument.save();
} else {
await this.schedules.create({
companyId: data.companyId,
employeeId,
weekDay,
schedules,
sessionUser: data.sessionUser,
});
}
}
}
return {
valid: conflicts.length === 0,
applied: true,
conflicts,
message: conflicts.length > 0
? "Se sobrescribieron solo los horarios en conflicto."
: "Los horarios se guardaron correctamente.",
};
}
private isScheduleAvailableForService(schedule: ScheduleItem, serviceId?: string): boolean {
if (!serviceId || !schedule.serviceScope || schedule.serviceScope === "all") {
return true;
@@ -32,6 +32,12 @@ export class SchedulesDisabledAdapterMongoose implements ISchedulesDisabledAdapt
await this.schedulesDisabledList.deleteOne({ _id: id }).exec();
}
public async find(
filters: Omit<FindSchedulesDisabledParams, "sessionUser">
): Promise<ISchedulesDisabledDocument[]> {
return this.schedulesDisabledList.find(filters).exec();
}
public async findOne(
filters: Omit<FindSchedulesDisabledParams, "sessionUser">
): Promise<ISchedulesDisabledDocument | null> {
@@ -90,6 +90,7 @@ export interface SchedulesDisabledByCollaboratorView {
export interface ISchedulesDisabledAdapter {
create(data: CreateSchedulesDisabledParams): Promise<ISchedulesDisabled>;
delete(id: string): Promise<void>;
find(filters: Omit<FindSchedulesDisabledParams, "sessionUser">): Promise<ISchedulesDisabled[]>;
findOne(filters: FindSchedulesDisabledParams): Promise<ISchedulesDisabled | null>;
}
@@ -98,6 +99,7 @@ export interface ISchedulesDisabledManager {
createSchedulesDisabled(data: CreateSchedulesDisabledParams): Promise<ISchedulesDisabled>;
disableSchedule(data: DisableScheduleParams): Promise<void>;
enableSchedule(data: DisableScheduleParams): Promise<void>;
find(data: FindSchedulesDisabledParams): Promise<ISchedulesDisabled[]>;
findOne(data: FindSchedulesDisabledParams): Promise<ISchedulesDisabled | null>;
findSchedulesDisabledByCollaborator(
data: FindSchedulesDisabledParams
@@ -271,6 +271,17 @@ class SchedulesDisabledManager implements ISchedulesDisabledManager {
return await this.schedulesDisabled.findOne(data);
}
public async find(data: FindSchedulesDisabledParams): Promise<ISchedulesDisabled[]> {
await validateSessionUser({ sessionUser: data.sessionUser });
await validatePermissionsByCompany({
sessionUser: data.sessionUser,
companyId: data.companyId,
});
const { sessionUser, ...filters } = data;
return await this.schedulesDisabled.find(filters);
}
public async delete(data: DeleteSchedulesDisabledParams): Promise<void> {
await validateSessionUser({ sessionUser: data.sessionUser });
+54 -6
View File
@@ -351,6 +351,28 @@ class UsersManager implements IUsersManager {
.substring(0, 6);
}
private async sendWelcomeEmail(email: string, firstName?: string): Promise<void> {
try {
await NotificationsManager.sendEmail({
email: cleanEmail(email),
templateId: "68139f23f3e003139b08b4f0",
context: {
username: firstName || cleanEmail(email),
},
});
} catch (e) {
if (axios.isAxiosError(e)) {
console.error("Error enviando email de bienvenida:", {
status: e.response?.status,
data: e.response?.data,
});
return;
}
console.error("Error enviando email de bienvenida:", e);
}
}
public async signUp(data: SignUpParams): Promise<IUser> {
// Destructure email and password from the data object
const {
@@ -406,12 +428,13 @@ class UsersManager implements IUsersManager {
const newUser = await this.users.create(newUserData);
const signUpUrl = "https://turnosxpress.com.ar/landing/login/verification";
await NotificationsManager.sendEmail({
email: cleanEmail(email),
subject: "TurnosXpress :: Bienvenido/a",
message: `Hola ${firstName}, Gracias por registrarte en turnosXpress. Primero debes activar tu cuenta ingresando en ${signUpUrl} . Tu código de verificación es: ${verificationCode}`,
templateId: "6a85dd644ea98ccfcd05bdc7",
context: {
username: firstName || cleanEmail(email),
verificationcode: verificationCode,
},
});
return newUser;
@@ -459,8 +482,11 @@ class UsersManager implements IUsersManager {
await NotificationsManager.sendEmail({
email: cleanEmail(data.email),
subject: "TurnosXpress :: Recuperación de cuenta",
message: `Hola ${user.firstName}, Te enviamos un código de verificación para recuperar tu cuenta. Tu código de verificación es: ${code}. Ingresa https://turnosxpress.com.ar/landing/recover-account/verify para establecer una nueva clave`,
templateId: "6a85c7ad96475f2825015277",
context: {
username: user.firstName || cleanEmail(data.email),
verificationcode: code,
},
});
return;
@@ -515,10 +541,16 @@ class UsersManager implements IUsersManager {
throw new Error("El código de verificación es incorrecto");
}
const wasVerificated = Boolean(userTest.verificated);
userTest.verificated = true;
await userTest.save();
if (!wasVerificated) {
await this.sendWelcomeEmail(userTest.email, userTest.firstName);
}
try {
const freePlan = await PlansList.plans.findOne({ price: 0 });
if (freePlan) {
@@ -724,11 +756,17 @@ class UsersManager implements IUsersManager {
});
if (userCheck) {
const wasVerificated = Boolean(userCheck.verificated);
userCheck.external_id = googleUserId;
userCheck.external_service = "google";
userCheck.verificated = true;
await userCheck.save();
if (!wasVerificated) {
await this.sendWelcomeEmail(userCheck.email, userCheck.firstName);
}
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({
sessionUser: userCheck.id,
});
@@ -770,6 +808,8 @@ class UsersManager implements IUsersManager {
throw new Error("Error creating user");
}
await this.sendWelcomeEmail(newUser.email, newUser.firstName);
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({
sessionUser: newUser.id,
});
@@ -821,11 +861,17 @@ class UsersManager implements IUsersManager {
});
if (userCheck) {
const wasVerificated = Boolean(userCheck.verificated);
userCheck.external_id = googleData.id;
userCheck.external_service = "google";
userCheck.verificated = true;
await userCheck.save();
if (!wasVerificated) {
await this.sendWelcomeEmail(userCheck.email, userCheck.firstName);
}
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({
sessionUser: userCheck.id,
});
@@ -867,6 +913,8 @@ class UsersManager implements IUsersManager {
throw new Error("Error creating user");
}
await this.sendWelcomeEmail(newUser.email, newUser.firstName);
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({
sessionUser: newUser.id,
});
@@ -0,0 +1,29 @@
import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa";
import { ApiValidationError } from "../../Models/Server.Error.model";
import {
CollaboratorScheduleDetailsParams,
CollaboratorScheduleDetailsView,
} from "../../Models/AdminAssistant/AdminAssistant.Interface";
import { authenticateMiddleware } from "../../middleware/authentication";
import { AdminAssistantService } from "./AdminAssistant.Service";
@Route("admin-assistant/collaborator-schedule-details")
@Middlewares(authenticateMiddleware)
export class GetCollaboratorScheduleDetailsController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post()
public async getCollaboratorScheduleDetails(
@Body() requestBody: CollaboratorScheduleDetailsParams
): Promise<CollaboratorScheduleDetailsView | ApiValidationError> {
try {
const details = await new AdminAssistantService().getCollaboratorScheduleDetails(requestBody);
this.setStatus(200);
return details;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@@ -0,0 +1,16 @@
import { connect } from "mongoose";
import AdminAssistantList from "../../Models/AdminAssistant/AdminAssistant";
import {
CollaboratorScheduleDetailsParams,
CollaboratorScheduleDetailsView,
} from "../../Models/AdminAssistant/AdminAssistant.Interface";
export class AdminAssistantService {
public async getCollaboratorScheduleDetails(
data: CollaboratorScheduleDetailsParams
): Promise<CollaboratorScheduleDetailsView> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await AdminAssistantList.getCollaboratorScheduleDetails(data);
}
}
@@ -405,3 +405,24 @@ export class CheckCompanyNameController extends Controller {
}
}
}
@Route("companies/check-admin")
@Middlewares(authenticateMiddleware)
export class CheckCompanyAdminController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post()
public async checkAdmin(
@Body() requestBody: { companyId: string; sessionUser: string }
): Promise<{ isAdmin: boolean } | ApiValidationError> {
try {
const isAdmin = await new CompaniesService().checkCompanyAdmin(requestBody);
this.setStatus(200);
return { isAdmin };
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@@ -112,4 +112,9 @@ export class CompaniesService {
const isValid = await CompaniestList.isValidName({ name });
return isValid;
}
public async checkCompanyAdmin(data: { companyId: string; sessionUser: string }): Promise<boolean> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await CompaniestList.checkCompanyAdmin(data);
}
}
@@ -6,6 +6,9 @@ import dayjs from "dayjs";
import PlanPaymentsList from "../../Models/PlanPayments/PlanPayments";
import { IPlanSuscriptionDocument } from "../../Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose";
import { VerifyPendingPlanPaymentResponse } from "../../Models/PlanSubscriptions/PlanSubscriptions.interface";
import PlansList from "../../Models/Plans/Plans";
import UserList from "../../Models/Users/Users";
import { NotificationsManager } from "../../Models/Notifications/Notifications";
type MercadoPagoPaymentData = {
id: string | number;
@@ -41,6 +44,66 @@ type MercadoPagoPaymentVerification = {
const REJECTED_PAYMENT_STATUSES = ["rejected", "cancelled", "failed"];
export class MercadoPagoWebhookService {
private formatTemplateDate(date: Date): string {
return new Intl.DateTimeFormat("es-AR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(date);
}
private formatTemplateAmount(amount: number): string {
return new Intl.NumberFormat("es-AR", {
style: "currency",
currency: "ARS",
}).format(amount);
}
private async sendApprovedPaidPlanEmail(
subscription: IPlanSuscriptionDocument,
paymentData: MercadoPagoPaymentData,
userId: string,
approvedAt: Date,
periodStart: Date
): Promise<void> {
try {
const plan = await PlansList.findOne({ _id: String(subscription.planId) });
if (plan.price <= 0) {
return;
}
const user = await UserList.users.findOne({ _id: userId });
if (!user) {
return;
}
await NotificationsManager.sendEmail({
email: user.email,
templateId: "6a85ebb1323720a53d0cfd3f",
context: {
username: user.firstName || user.email,
nombre_plan: plan.name,
importe: this.formatTemplateAmount(paymentData.transaction_amount || 0),
fecha_pago: this.formatTemplateDate(approvedAt),
periodo_desde: this.formatTemplateDate(periodStart),
periodo_hasta: this.formatTemplateDate(subscription.endDate),
},
});
} catch (e) {
if (axios.isAxiosError(e)) {
console.error("Error enviando email de pago de plan aprobado:", {
status: e.response?.status,
data: e.response?.data,
});
return;
}
console.error("Error enviando email de pago de plan aprobado:", e);
}
}
public async handleWebhook(body: any): Promise<void> {
await connect(`${process.env.DATABASE_CONNECTION}`);
@@ -300,6 +363,9 @@ export class MercadoPagoWebhookService {
const periodStart = isExtensionPayment && dayjs(subscription.endDate).isAfter(approvedAt)
? subscription.endDate
: approvedAt;
const transactionId = String(paymentData.id);
const existingPayments = await PlanPaymentsList.find({ transactionId });
const shouldSendApprovedPaymentEmail = existingPayments.length === 0;
if (isUpgradePayment && subscription.pendingPaymentPlanId) {
subscription.planId = subscription.pendingPaymentPlanId;
@@ -342,8 +408,12 @@ export class MercadoPagoWebhookService {
paymentDate: approvedAt,
paymentMethod: paymentData.payment_method_id || paymentData.payment_type_id || "mercadopago",
status: "completed",
transactionId: String(paymentData.id),
transactionId,
});
if (shouldSendApprovedPaymentEmail) {
await this.sendApprovedPaidPlanEmail(subscription, paymentData, userId, approvedAt, periodStart);
}
}
private async applyRejectedPayment(
@@ -7,8 +7,10 @@ import { ApiVoidResult } from "../../Models/Api.VoidResult.type";
interface SendEmailByContentParams {
systemToken: string;
email: string;
subject: string;
message: string;
subject?: string;
message?: string;
templateId?: string;
context?: Record<string, unknown>;
}
interface SendWapByContentParams {
@@ -29,6 +31,8 @@ export class SendEmailByContentController extends Controller {
email: requestBody.email,
subject: requestBody.subject,
message: requestBody.message,
templateId: requestBody.templateId,
context: requestBody.context,
});
this.setStatus(200);
@@ -10,6 +10,8 @@ import {
PaginateSchedulesResults,
FindSchedulesParams,
CollaboratorSchedulesView,
ScheduleApplyParams,
ScheduleApplyResult,
} from "../../Models/Schedules/Schedules.Interface";
import { authenticateMiddleware } from "../../middleware/authentication";
import { ApiVoidResult } from "../../Models/Api.VoidResult.type";
@@ -36,6 +38,7 @@ export class CreateScheduleController extends Controller {
}
@Route("schedules/get-all-by-collaborator")
@Middlewares(authenticateMiddleware)
export class GetAllSchedulsByCollaboratoreController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "El empleado fue creado con exito")
@@ -81,6 +84,27 @@ export class UpdateScheduleController extends Controller {
}
}
@Route("schedules/apply")
@Middlewares(authenticateMiddleware)
export class ApplyScheduleController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "Done")
@Post()
public async applyScheduleChanges(
@Body() requestBody: ScheduleApplyParams
): Promise<ScheduleApplyResult | ApiValidationError> {
try {
const result = await new SchedulesService().applyScheduleChanges(requestBody);
this.setStatus(200);
return result;
} catch (e) {
const errorOccurred: Error = e as Error;
this.setStatus(500);
return new ApiValidationError(500, errorOccurred.message);
}
}
}
@Route("schedules/delete")
@Middlewares(authenticateMiddleware)
export class DeleteScheduleController extends Controller {
@@ -9,6 +9,8 @@ import {
PaginateSchedulesResults,
CollaboratorSchedulesView,
FindSchedulesParams,
ScheduleApplyParams,
ScheduleApplyResult,
} from "../../Models/Schedules/Schedules.Interface";
export class SchedulesService {
@@ -26,6 +28,12 @@ export class SchedulesService {
await ScheduleList.updateSchedule(data);
}
public async applyScheduleChanges(data: ScheduleApplyParams): Promise<ScheduleApplyResult> {
await connect(`${process.env.DATABASE_CONNECTION}`);
return await ScheduleList.applyScheduleChanges(data);
}
public async deleteSchedule(data: DeleteScheduleParams): Promise<void> {
await connect(`${process.env.DATABASE_CONNECTION}`);
await ScheduleList.delete(data);
Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

+33
View File
@@ -0,0 +1,33 @@
# Gradle
.gradle/
build/
**/build/
gradle-app.setting
!gradle-wrapper.jar
# Android Studio
.idea/
*.iml
local.properties
.navigation/
.externalNativeBuild/
.cxx/
# Signing
keystore.properties
*.jks
*.keystore
*.p12
*.pem
# Generated
captures/
outputs/
*.apk
*.aab
*.ap_
# Logs / OS
*.log
Thumbs.db
.DS_Store
+85
View File
@@ -0,0 +1,85 @@
# TurnosXpress Android TWA
Minimal Android Trusted Web Activity app for `https://turnosxpress.com.ar`.
## Project
- Package/application id: `com.hdrdevs.turnosxpress`
- Launch URL: `https://turnosxpress.com.ar`
- TWA library: `com.google.androidbrowserhelper:androidbrowserhelper:2.7.2`
- Launcher activity: `com.google.androidbrowserhelper.trusted.LauncherActivity`
## Build
This folder intentionally does not include a Gradle wrapper. Open `tx-android` in Android Studio, or use an installed Gradle distribution:
```powershell
cd tx-android
gradle tasks
gradle assembleDebug
```
If Android Studio creates `local.properties`, keep it local. It is ignored by Git.
## Signing
For local debug builds Android uses the debug keystore. For Play Store releases, sign with the release keystore or Play App Signing certificate configured for this package.
Example local release keystore creation:
```powershell
keytool -genkeypair -v -keystore turnosxpress-release.jks -alias turnosxpress -keyalg RSA -keysize 2048 -validity 10000
```
Do not commit keystores or signing passwords. Put local signing values in `keystore.properties` if you later wire release signing into Gradle.
## SHA-256 Fingerprint
Digital Asset Links must use the SHA-256 fingerprint of the certificate that signs the installed APK.
Debug keystore example:
```powershell
keytool -list -v -alias androiddebugkey -keystore "$env:USERPROFILE\.android\debug.keystore" -storepass android -keypass android
```
Release keystore example:
```powershell
keytool -list -v -alias turnosxpress -keystore .\turnosxpress-release.jks
```
If Play App Signing is enabled, use the SHA-256 fingerprint from Play Console's app signing certificate, not only the upload key.
## Digital Asset Links
Publish this file publicly at:
```text
https://turnosxpress.com.ar/.well-known/assetlinks.json
```
Example:
```json
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.hdrdevs.turnosxpress",
"sha256_cert_fingerprints": [
"AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99"
]
}
}
]
```
The JSON must be served over HTTPS with `Content-Type: application/json` and no redirects that break access to `/.well-known/assetlinks.json`.
## Play Protect And Local APK Notes
Local APK installs can show Play Protect warnings because the APK is not installed from Google Play and may be signed by a new or unknown certificate. This is expected during sideload testing and does not prove the TWA or Digital Asset Links setup is wrong.
The full-screen trusted experience only works when Android can verify the website-to-app relationship through Digital Asset Links for the certificate used to sign that APK. Until that is valid, Chrome may open the site with browser UI or behave like a Custom Tab.
+21
View File
@@ -0,0 +1,21 @@
plugins {
id 'com.android.application'
}
android {
namespace 'com.hdrdevs.turnosxpress'
compileSdk 36
defaultConfig {
applicationId 'com.hdrdevs.turnosxpress'
minSdk 23
targetSdk 35
versionCode 1
versionName '1.0.0'
}
}
dependencies {
implementation 'com.google.androidbrowserhelper:androidbrowserhelper:2.7.2'
implementation 'androidx.appcompat:appcompat:1.7.0'
}
@@ -0,0 +1,37 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.hdrdevs.turnosxpress",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 1,
"versionName": "1.0.0",
"outputFile": "app-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/app-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/app-release.dm"
]
}
],
"minSdkVersionForDexing": 23
}
@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.TurnosXpressTwa">
<meta-data
android:name="asset_statements"
android:resource="@string/asset_statements" />
<activity
android:name="com.google.androidbrowserhelper.trusted.LauncherActivity"
android:exported="true"
android:theme="@style/Theme.TurnosXpressTwa">
<meta-data
android:name="android.support.customtabs.trusted.DEFAULT_URL"
android:value="https://turnosxpress.com.ar" />
<meta-data
android:name="android.support.customtabs.trusted.STATUS_BAR_COLOR"
android:resource="@color/twa_status_bar_color" />
<meta-data
android:name="android.support.customtabs.trusted.STATUS_BAR_COLOR_DARK"
android:resource="@color/twa_status_bar_color_dark" />
<meta-data
android:name="android.support.customtabs.trusted.NAVIGATION_BAR_COLOR"
android:resource="@color/twa_navigation_bar_color" />
<meta-data
android:name="android.support.customtabs.trusted.NAVIGATION_BAR_COLOR_DARK"
android:resource="@color/twa_navigation_bar_color_dark" />
<meta-data
android:name="android.support.customtabs.trusted.FALLBACK_STRATEGY"
android:value="customtabs" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="turnosxpress.com.ar"
android:scheme="https" />
</intent-filter>
</activity>
<activity
android:name="com.google.androidbrowserhelper.trusted.ManageDataLauncherActivity"
android:exported="false"
android:theme="@style/Theme.TurnosXpressTwa" />
</application>
</manifest>
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="@color/launcher_background"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="@color/launcher_foreground"
android:pathData="M24,24h60v12h-24v48h-12v-48h-24z" />
<path
android:fillColor="@color/launcher_foreground"
android:pathData="M30,72h48v12h-48z" />
</vector>
@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
android:height="108dp"
android:width="108dp"
android:viewportHeight="108"
android:viewportWidth="108"
xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z"/>
<path android:fillColor="#00000000" android:pathData="M9,0L9,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,0L19,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,0L29,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,0L39,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,0L49,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,0L59,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,0L69,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,0L79,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M89,0L89,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M99,0L99,108"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,9L108,9"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,19L108,19"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,29L108,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,39L108,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,49L108,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,59L108,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,69L108,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,79L108,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,89L108,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M0,99L108,99"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,29L89,29"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,39L89,39"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,49L89,49"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,59L89,59"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,69L89,69"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M19,79L89,79"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M29,19L29,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M39,19L39,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M49,19L49,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M59,19L59,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M69,19L69,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
<path android:fillColor="#00000000" android:pathData="M79,19L79,89"
android:strokeColor="#33FFFFFF" android:strokeWidth="0.8"/>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="twa_status_bar_color">#330080</color>
<color name="twa_status_bar_color_dark">#330080</color>
<color name="twa_navigation_bar_color">#330080</color>
<color name="twa_navigation_bar_color_dark">#330080</color>
<color name="launcher_background">#330080</color>
<color name="launcher_foreground">#FFFFFF</color>
</resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">TurnosXpress</string>
<string name="asset_statements">
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "web",
"site": "https://turnosxpress.com.ar"
}
}]
</string>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.TurnosXpressTwa" parent="Theme.AppCompat.Light.NoActionBar">
<item name="android:windowNoTitle">true</item>
<item name="android:windowActionBar">false</item>
<item name="android:windowLightStatusBar">false</item>
<item name="android:navigationBarColor">@color/twa_navigation_bar_color</item>
<item name="android:statusBarColor">@color/twa_status_bar_color</item>
</style>
</resources>
+3
View File
@@ -0,0 +1,3 @@
plugins {
id 'com.android.application' version '8.9.1' apply false
}
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
android.nonTransitiveRClass=true
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.0-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+93
View File
@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = 'TurnosXpressTwa'
include ':app'
@@ -0,0 +1,14 @@
[
{
"relation": [
"delegate_permission/common.handle_all_urls"
],
"target": {
"namespace": "android_app",
"package_name": "com.hdrdevs.turnosxpress",
"sha256_cert_fingerprints": [
"CE:15:98:AB:BE:28:AF:CB:DA:85:97:76:DD:35:B3:94:48:90:3E:3C:5A:AD:A5:CD:73:EF:DC:EC:C4:CC:5F:A3"
]
}
}
]
Binary file not shown.
@@ -37,7 +37,7 @@ const schemaAppointmentUpdate = Yup.object().shape({
start: Yup.string().required("Debe seleccionar una fecha."),
price: Yup.number().positive().required("Debe introducir un precio valido."),
length: Yup.number().positive().required("Debe introducir una duración."),
comments: Yup.string().required("Debe introducir un comentario."),
comments: Yup.string().notRequired(),
sessionUser: Yup.string().required("Para crear un turno debe iniciar sesión."),
});
@@ -204,6 +204,9 @@ export default function OrganizationProfile() {
const [currentDate, setCurrentDate] = useState<dayjs.Dayjs>(appointmentDate);
const currentDateRef = useRef(currentDate);
currentDateRef.current = currentDate;
const [calendarEventsCount, setCalendarEventsCount] = useState<number>(0);
const [calendarFrom, setCalendarFrom] = useState<number>(8);
const [calendarTo, setCalendarTo] = useState<number>(23);
@@ -421,32 +424,35 @@ export default function OrganizationProfile() {
const handleMoveAppointment = async (newDay: dayjs.Dayjs) => {
if (!copiedAppointment || !copiedAppointment.appointmentId) return;
const moveAppointment = useClipboardStore.getState().copiedAppointment;
if (!moveAppointment || !moveAppointment.appointmentId) return;
timePicker.hide();
eventHandler.setEventType(EVENT_TYPES.LOADING);
const newStart = dayjs(currentDate).hour(newDay.hour()).minute(newDay.minute()).second(0).millisecond(0).toISOString();
const oldStart = dayjs(copiedAppointment.startTime);
const oldEnd = dayjs(copiedAppointment.endTime);
const newStart = currentDateRef.current.hour(newDay.hour()).minute(newDay.minute()).second(0).millisecond(0).toISOString();
const oldStart = dayjs(moveAppointment.startTime);
const oldEnd = dayjs(moveAppointment.endTime);
const durationMin = oldEnd.diff(oldStart, 'minute');
const newEnd = dayjs(newStart).add(durationMin, 'minute').toISOString();
try {
await ApiRequest.post("/appointments/move-appointment-force", {
id: copiedAppointment.appointmentId,
id: moveAppointment.appointmentId,
companyId: id,
newStart: newStart,
newEnd: newEnd,
sessionUser: SessionInfo.userId,
});
alert.showSuccess("Turno movido con éxito");
setCopiedAppointment(null);
useClipboardStore.getState().setCopiedAppointment(null);
loadCalendarData();
} catch (error: unknown) {
console.error(error);
alert.showError("Error al mover el turno");
} finally {
eventHandler.setEventType(copiedAppointment ? EVENT_TYPES.SLEEP : EVENT_TYPES.NEED_ADD);
eventHandler.setEventType(
useClipboardStore.getState().copiedAppointment ? EVENT_TYPES.SLEEP : EVENT_TYPES.NEED_ADD
);
}
};
@@ -1,5 +1,6 @@
.movementsPage {
padding: 16px;
padding-top: 200px;
padding-bottom: 32px;
}
@@ -844,7 +844,16 @@ export default function CashFlowMovementsList() {
},
};
(pdfMake as any).createPdf({ ...documentDefinition, content }).download(`factura-c-${invoice.pointOfSale || fiscalProfile.pointOfSale || 0}-${invoice.voucherNumber || 0}.pdf`);
const filename = `factura-c-${invoice.pointOfSale || fiscalProfile.pointOfSale || 0}-${invoice.voucherNumber || 0}.pdf`;
const pdf = (pdfMake as any).createPdf({ ...documentDefinition, content });
if ((window as unknown as { Android: any }).Android && (window as unknown as { Android: any }).Android.savePdf) {
pdf.getBase64((data: string) => {
(window as unknown as { Android: any }).Android?.savePdf(data, filename);
});
} else {
pdf.download(filename);
}
} catch (error) {
console.error("Error generating invoice PDF:", error);
alert.showError("No pudimos generar el PDF de la factura.");
@@ -9,6 +9,7 @@ import RocketLaunchIcon from '@mui/icons-material/RocketLaunch';
import EventBusyIcon from '@mui/icons-material/EventBusy';
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
import GroupAddIcon from '@mui/icons-material/GroupAdd';
import ManageSearchIcon from '@mui/icons-material/ManageSearch';
import SearchIcon from "@mui/icons-material/Search";
import BlockIcon from "@mui/icons-material/Block";
import EventRepeatIcon from "@mui/icons-material/EventRepeat";
@@ -20,6 +21,8 @@ import NotificationsIcon from '@mui/icons-material/Notifications';
import WhatsAppIcon from '@mui/icons-material/WhatsApp';
import QrCode2Icon from '@mui/icons-material/QrCode2';
import ReceiptLongIcon from '@mui/icons-material/ReceiptLong';
import PublicIcon from '@mui/icons-material/Public';
import PublicOffIcon from '@mui/icons-material/PublicOff';
import API from "@services/Api.Service";
import { ReservationPeriodsSummary } from "@models/SchedulesEnabled.type";
@@ -134,6 +137,24 @@ const AVAILABLE_FLOWS: FlowDefinition[] = [
keywords: ["nuevo servicio", "prestacion", "ofrecer", "crear servicio", "precio", "duracion"],
icon: AddCircleOutlineIcon
},
{
id: "publish-services",
flowId: "publish-services",
title: "Publicar Servicios",
description: "Hacer visibles uno o más servicios en la página pública.",
category: "Servicios",
keywords: ["publicar", "servicios", "visible", "publico", "mostrar", "habilitar reservas"],
icon: PublicIcon
},
{
id: "unpublish-services",
flowId: "unpublish-services",
title: "Retirar Servicios",
description: "Quitar uno o más servicios de la página pública.",
category: "Servicios",
keywords: ["despublicar", "ocultar", "servicios", "privado", "no mostrar", "quitar publicacion"],
icon: PublicOffIcon
},
{
id: "update-schedule",
flowId: "update-schedule",
@@ -178,6 +199,15 @@ const AVAILABLE_FLOWS: FlowDefinition[] = [
category: "Colaboradores",
keywords: ["invitar", "colaborador", "profesional", "empleado", "staff", "equipo"],
icon: GroupAddIcon
},
{
id: "collaborator-schedule-summary",
flowId: "collaborator-schedule-summary",
title: "Ver horarios de colaborador",
description: "Consultá horarios, excepciones y restricciones configuradas.",
category: "Colaboradores",
keywords: ["horarios", "colaborador", "agenda", "restricciones", "excepciones", "cerrada", "consulta"],
icon: ManageSearchIcon
}
];
@@ -72,6 +72,19 @@
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(128, 0, 32, 0.3);
}
.nextButton:disabled {
cursor: not-allowed;
opacity: 0.55;
transform: none;
box-shadow: none;
}
.nextButton:disabled:hover {
transform: none;
box-shadow: none;
}
.pressEnterText {
font-size: 13px;
color: #6b7280;
@@ -34,7 +34,7 @@ export default function QuestionCard({
<div className={classNames(style.cardContainer, style.animateEnter)}>
<div className={style.contentWrapper}>
{topAccessory && <div className={style.topAccessory}>{topAccessory}</div>}
<h1 className={style.title}>{title}</h1>
{title && <h1 className={style.title}>{title}</h1>}
{description && <p className={style.description}>{description}</p>}
<div className={style.inputArea}>
@@ -14,6 +14,7 @@ dayjs.locale("es");
import { NotificationChannel, ReminderRule } from "@models/NotificationPreferences.model";
import { OrganizationClientView } from "@models/Clients.model";
import { ReservationPeriodDuration, ReservationPeriodsSummary } from "@models/SchedulesEnabled.type";
import { CompanyServiceView, SERVICE_PUBLISHED_STATUS } from "@models/Service.model";
import OnboardingOrgFlow from "./flows/OnboardingOrgFlow";
import OnboardingServiceFlow from "./flows/OnboardingServiceFlow";
import OnboardingScheduleFlow from "./flows/OnboardingScheduleFlow";
@@ -23,6 +24,8 @@ import UpdateScheduleFlow from "./flows/UpdateScheduleFlow";
import OverrideScheduleFlow from "./flows/OverrideScheduleFlow";
import DisableScheduleFlow from "./flows/DisableScheduleFlow";
import PublicVisibilityFlow from "./flows/PublicVisibilityFlow";
import ServicePublicationFlow from "./flows/ServicePublicationFlow";
import CollaboratorScheduleSummaryFlow, { CollaboratorScheduleDetails } from "./flows/CollaboratorScheduleSummaryFlow";
import NotificationsFlow from "./flows/NotificationsFlow";
import NewCollaboratorFlow from "./flows/NewCollaboratorFlow";
import SetupCollaboratorFlow from "./flows/SetupCollaboratorFlow";
@@ -31,6 +34,8 @@ import ArcaOnboardingFlow from "./flows/ArcaOnboardingFlow";
import ArcaTestConnectionFlow, { ArcaWsaaTestResult, ArcaWsfeDiagnosticsResult } from "./flows/ArcaTestConnectionFlow";
import SuccessScreen from "./components/SuccessScreen";
import OrganizationPicker from "./components/OrganizationPicker";
import QuestionCard from "../QuestionCard/QuestionCard";
import { getCollaboratorLabel } from "./components/CollaboratorPicker";
import { reservationPeriodOptions } from "./constants/reservationPeriods";
import { NOTIF_CHANNELS, NOTIF_REMINDER_PRESETS } from "./constants/notifications";
import { getEmployeeId, getEmployeeUserId, getTargetEmployees } from "./helpers/employees";
@@ -96,11 +101,18 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
const [serviceLimit, setServiceLimit] = useState<number>(1);
const [serviceImage, setServiceImage] = useState<File | null>(null);
const [createdServiceId, setCreatedServiceId] = useState<string | null>(null);
const [serviceCreateError, setServiceCreateError] = useState<string>("");
const [serviceAssignmentError, setServiceAssignmentError] = useState<string>("");
const [serviceAssignmentMode, setServiceAssignmentMode] = useState<"options" | "collaborators">("options");
const [serviceAssignmentWarning, setServiceAssignmentWarning] = useState<string>("");
const [isLoadingServiceCollaborators, setIsLoadingServiceCollaborators] = useState(false);
const [selectedServiceCollaboratorIds, setSelectedServiceCollaboratorIds] = useState<string[]>([]);
const [employeeId, setEmployeeId] = useState<string | null>(null);
const [doesProvideServices, setDoesProvideServices] = useState<boolean | null>(null);
// State for Schedule Flow
const [workingDaysMode, setWorkingDaysMode] = useState<string>(""); // "mon-fri", "mon-sat", "mon-sun", "custom"
const [specificWeekDay, setSpecificWeekDay] = useState<number | null>(null);
const [openTime, setOpenTime] = useState<string>("09:00");
const [closeTime, setCloseTime] = useState<string>("18:00");
const [hasBreak, setHasBreak] = useState<boolean | null>(null);
@@ -116,7 +128,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
const [isSuccess, setIsSuccess] = useState<boolean>(false);
const [isAddingAnotherService, setIsAddingAnotherService] = useState<boolean>(false);
const [isResuming, setIsResuming] = useState(false);
const [isResuming, setIsResuming] = useState(true);
const [headerImage, setHeaderImage] = useState<File | null>(null);
const [headerColor, setHeaderColor] = useState<string>("#250525");
@@ -133,6 +145,13 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
const [servicesShowPublicScores, setServicesShowPublicScores] = useState(true);
const [servicesShowPublicOpinions, setServicesShowPublicOpinions] = useState(true);
const [publicLinkCopied, setPublicLinkCopied] = useState(false);
const [publicationServices, setPublicationServices] = useState<CompanyServiceView[]>([]);
const [selectedPublicationServiceIds, setSelectedPublicationServiceIds] = useState<string[]>([]);
const [publicationLoading, setPublicationLoading] = useState(false);
const [publicationMessage, setPublicationMessage] = useState("");
const [collaboratorScheduleDetails, setCollaboratorScheduleDetails] = useState<CollaboratorScheduleDetails | null>(null);
const [collaboratorScheduleLoading, setCollaboratorScheduleLoading] = useState(false);
const [collaboratorScheduleMessage, setCollaboratorScheduleMessage] = useState("");
// State for Notifications Flow
const [notificationsScope, setNotificationsScope] = useState<"org" | "client" | null>(null);
@@ -149,6 +168,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
// State for standalone update schedule flow
const [scheduleScope, setScheduleScope] = useState<"me" | "specific" | "all" | null>(null);
const [targetEmployeeId, setTargetEmployeeId] = useState<string | null>(null);
const [selectedScheduleEmployeeIds, setSelectedScheduleEmployeeIds] = useState<string[]>([]);
const [allEmployees, setAllEmployees] = useState<any[]>([]);
const [overrideDate, setOverrideDate] = useState<string>("");
const [allOrganizations, setAllOrganizations] = useState<any[]>([]);
@@ -163,6 +183,11 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
const [reservationPeriodSummary, setReservationPeriodSummary] = useState<ReservationPeriodsSummary | null>(null);
const [reservationPeriodEmployeesLoading, setReservationPeriodEmployeesLoading] = useState(false);
const [reservationPeriodEmployeesError, setReservationPeriodEmployeesError] = useState<string | null>(null);
const [scheduleServiceScope, setScheduleServiceScope] = useState<"all" | "specific">("all");
const [scheduleServiceIds, setScheduleServiceIds] = useState<string[]>([]);
const [scheduleAvailableServices, setScheduleAvailableServices] = useState<any[]>([]);
const [scheduleConflictMessage, setScheduleConflictMessage] = useState<string>("");
const [pendingScheduleOverwrite, setPendingScheduleOverwrite] = useState(false);
// State for New Collaborator Flow
const [inviteEmail, setInviteEmail] = useState("");
@@ -208,6 +233,49 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
return !employee.removed && (isCurrentUser || employee.guestOk === true);
};
const requiresOrganizationContext = () => {
return flowId === "onboarding-service"
|| flowId === "onboarding-schedule"
|| flowId === "setup-collaborator"
|| flowId === "public-visibility"
|| flowId === "publish-services"
|| flowId === "unpublish-services"
|| flowId === "collaborator-schedule-summary"
|| flowId === "public-link"
|| flowId === "notifications"
|| flowId === "whatsapp-bot"
|| flowId === "arca-onboarding"
|| flowId === "arca-test-connection"
|| flowId === "update-schedule"
|| flowId === "override-schedule"
|| flowId === "disable-schedule"
|| flowId === "open-reservation-periods";
};
const loadPublicationServices = async (companyId: string) => {
const publishMode = flowId === "publish-services";
setPublicationLoading(true);
setPublicationMessage("");
setSelectedPublicationServiceIds([]);
try {
const services = await API.post<CompanyServiceView[]>("services/get-by-company", { companyId });
const filteredServices = (services || []).filter(service => {
if (publishMode) {
return service.published !== SERVICE_PUBLISHED_STATUS.PUBLISHED && !service.banned;
}
return service.published === SERVICE_PUBLISHED_STATUS.PUBLISHED;
});
setPublicationServices(filteredServices);
} catch (error: any) {
console.error("Error cargando servicios para publicación:", error);
setPublicationServices([]);
setPublicationMessage(error?.message || "No pudimos cargar los servicios. Reintentá en unos segundos.");
} finally {
setPublicationLoading(false);
}
};
const loadOrganizationContext = (org: any) => {
setCreatedCompanyId(org.id || org._id);
setSelectedOrganization(org);
@@ -299,7 +367,24 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
}).catch(console.error);
}
}).catch(console.error);
} else if (flowId === "update-schedule" || flowId === "override-schedule" || flowId === "disable-schedule" || flowId === "open-reservation-periods") {
} else if (flowId === "update-schedule" || flowId === "override-schedule" || flowId === "disable-schedule" || flowId === "open-reservation-periods" || flowId === "collaborator-schedule-summary") {
if (flowId === "collaborator-schedule-summary") {
setCurrentStepIndex(0);
setAllEmployees([]);
setTargetEmployeeId(null);
setCollaboratorScheduleDetails(null);
setCollaboratorScheduleMessage("");
}
if (flowId === "update-schedule") {
setSelectedScheduleEmployeeIds([]);
setScheduleServiceScope("all");
setScheduleServiceIds([]);
setScheduleAvailableServices([]);
setScheduleConflictMessage("");
setPendingScheduleOverwrite(false);
}
if (flowId === "open-reservation-periods") {
setCurrentStepIndex(0);
setReservationPeriodScope(null);
@@ -313,7 +398,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
API.post<any[]>("employees/get-by-company", { companyId: org.id || org._id })
.then(empRes => {
const availableEmployees = flowId === "open-reservation-periods"
const availableEmployees = flowId === "open-reservation-periods" || flowId === "collaborator-schedule-summary"
? (empRes || []).filter((employee: any) => !employee.removed)
: (empRes || []).filter(canManageEmployeeSchedules);
setAllEmployees(availableEmployees);
@@ -360,6 +445,9 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
setServicesShowPublicScores(services.every(service => service.showPublicScores !== false));
setServicesShowPublicOpinions(services.every(service => service.showPublicOpinions !== false));
}).catch(console.error);
} else if (flowId === "publish-services" || flowId === "unpublish-services") {
setCurrentStepIndex(0);
loadPublicationServices(org.id || org._id);
} else if (flowId === "public-link") {
setCurrentStepIndex(0);
setPublicLinkCopied(false);
@@ -448,7 +536,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
};
React.useEffect(() => {
if (flowId === "new-collaborator" || flowId === "setup-collaborator" || flowId === "public-visibility" || flowId === "public-link" || flowId === "notifications" || flowId === "whatsapp-bot" || flowId === "arca-onboarding" || flowId === "arca-test-connection" || flowId.startsWith("onboarding") || flowId === "update-schedule" || flowId === "override-schedule" || flowId === "disable-schedule" || flowId === "open-reservation-periods") {
if (flowId === "new-collaborator" || flowId === "setup-collaborator" || flowId === "public-visibility" || flowId === "publish-services" || flowId === "unpublish-services" || flowId === "collaborator-schedule-summary" || flowId === "public-link" || flowId === "notifications" || flowId === "whatsapp-bot" || flowId === "arca-onboarding" || flowId === "arca-test-connection" || flowId.startsWith("onboarding") || flowId === "update-schedule" || flowId === "override-schedule" || flowId === "disable-schedule" || flowId === "open-reservation-periods") {
setIsResuming(true);
API.post<any[]>("companies/get-by-user", { sessionUser: SessionInfo.userId })
.then(response => {
@@ -469,7 +557,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
.finally(() => setIsResuming(false));
}
if (flowId === "onboarding-service" || flowId === "onboarding-schedule" || flowId === "setup-collaborator" || flowId === "public-visibility" || flowId === "public-link" || flowId === "notifications" || flowId === "whatsapp-bot" || flowId === "arca-onboarding" || flowId === "arca-test-connection" || flowId === "open-reservation-periods" || (flowId === "onboarding-org" && action === "new-org")) {
if (flowId === "onboarding-service" || flowId === "onboarding-schedule" || flowId === "setup-collaborator" || flowId === "public-visibility" || flowId === "publish-services" || flowId === "unpublish-services" || flowId === "collaborator-schedule-summary" || flowId === "public-link" || flowId === "notifications" || flowId === "whatsapp-bot" || flowId === "arca-onboarding" || flowId === "arca-test-connection" || flowId === "open-reservation-periods" || (flowId === "onboarding-org" && action === "new-org")) {
setCurrentStepIndex(0);
}
}, [flowId, SessionInfo.metrics.organizationsCount, SessionInfo.userId, router]);
@@ -1060,15 +1148,55 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
);
const handleCreateService = async () => {
if (!createdCompanyId || !serviceName || !serviceDescription || !serviceLength || isCreating) return;
setServiceCreateError("");
const defaultOrganization = selectedOrganization || (allOrganizations.length === 1 ? allOrganizations[0] : null);
let companyId = createdCompanyId || defaultOrganization?.id || defaultOrganization?._id;
const normalizedPrice = typeof servicePrice === "string" ? servicePrice.replace(",", ".") : servicePrice;
const parsedPrice = Number(normalizedPrice);
if (isCreating) return;
if (!companyId) {
try {
const organizations = await API.post<any[]>("companies/get-by-user", { sessionUser: SessionInfo.userId });
const availableOrganizations = organizations || [];
setAllOrganizations(availableOrganizations);
if (availableOrganizations.length === 1) {
const organization = availableOrganizations[0];
companyId = organization.id || organization._id;
loadOrganizationContext(organization);
}
} catch (error) {
console.error("Error cargando organizaciones para crear servicio:", error);
setServiceCreateError("No pudimos cargar tus organizaciones para crear el servicio. Reintentá en unos segundos.");
return;
}
}
if (!companyId) {
setServiceCreateError("Para crear un servicio primero necesitás tener una organización asociada. Volvé al inicio del asistente y creá una organización.");
return;
}
if (!serviceName || !serviceDescription || !serviceLength) {
setServiceCreateError("Completá el nombre, la descripción y la duración antes de crear el servicio.");
return;
}
if (servicePrice === "" || Number.isNaN(parsedPrice)) {
setServiceCreateError("Ingresá el precio del servicio. Si es gratis, cargá 0.");
return;
}
setIsCreating(true);
try {
const service = await API.post<any>("services/create", {
companyId: createdCompanyId,
companyId,
name: serviceName,
description: serviceDescription,
length: serviceLength,
originalPrice: Number(servicePrice) || 0,
originalPrice: parsedPrice,
limit: serviceLimit,
sessionUser: SessionInfo.userId
});
@@ -1080,7 +1208,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
handleNext(); // Move to Step 4 (Service Image)
} catch (error: any) {
console.error("Error creando servicio:", error);
alert(error?.message || "Hubo un error al guardar el servicio.");
setServiceCreateError(error?.message || "Hubo un error al guardar el servicio.");
} finally {
setIsCreating(false);
}
@@ -1098,6 +1226,8 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
.then(res => {
setPreviewServices(res || []);
});
} else if (isStandaloneAction && action === "new-service") {
setCurrentStepIndex(5);
} else if (isStandaloneAction) {
handleActionSuccess();
} else {
@@ -1123,7 +1253,139 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
finalizeService();
} catch (error: any) {
console.error("Error subiendo imagen del servicio:", error);
alert(error?.message || "Hubo un error al subir la imagen. Puedes intentarlo más tarde desde tu panel.");
setServiceCreateError(error?.message || "Hubo un error al subir la imagen. Podés intentarlo más tarde desde tu panel.");
} finally {
setIsCreating(false);
}
};
const handleAssignCreatedServiceToMe = async () => {
if (!createdCompanyId || !createdServiceId || isCreating) return;
setIsCreating(true);
setServiceAssignmentError("");
setServiceAssignmentWarning("");
try {
const employees = await API.post<any[]>("employees/get-by-company", { companyId: createdCompanyId });
const currentEmployee = (employees || []).find((employee: any) => String(getEmployeeUserId(employee)) === String(SessionInfo.userId));
let finalEmployeeId = currentEmployee ? getEmployeeId(currentEmployee) : null;
if (!finalEmployeeId) {
const newEmp = await API.post<any>("employees/create", {
companyId: createdCompanyId,
userId: SessionInfo.userId,
roles: ["owner", "employee"],
sessionUser: SessionInfo.userId
});
finalEmployeeId = newEmp?.id || newEmp?._id;
}
if (!finalEmployeeId) {
throw new Error("No pudimos inicializar tu perfil de colaborador.");
}
setEmployeeId(finalEmployeeId);
await API.post<any>("employees/update-collaborator-services", {
companyId: createdCompanyId,
employeeId: finalEmployeeId,
services: [{ serviceId: createdServiceId, active: true }],
sessionUser: SessionInfo.userId
});
const schedulesRes = await API.post<any>("schedules/get-all-by-collaborator", {
companyId: createdCompanyId,
employeeId: finalEmployeeId
});
if (schedulesRes?.data?.length > 0) {
handleActionSuccess();
return;
}
router.push(`/admin/assistant?flow=update-schedule&companyId=${createdCompanyId}`);
} catch (error: any) {
console.error("Error asignando servicio al colaborador:", error);
setServiceAssignmentError(error?.message || "No pudimos asignarte el servicio. Reintentá en unos segundos.");
} finally {
setIsCreating(false);
}
};
const loadServiceAssignmentCollaborators = async () => {
if (!createdCompanyId || isCreating || isLoadingServiceCollaborators) return;
setServiceAssignmentMode("collaborators");
setServiceAssignmentError("");
setServiceAssignmentWarning("");
setIsLoadingServiceCollaborators(true);
try {
const employees = await API.post<any[]>("employees/get-by-company", { companyId: createdCompanyId });
const activeEmployees = (employees || []).filter((employee: any) => !employee.removed);
setAllEmployees(activeEmployees);
} catch (error: any) {
console.error("Error cargando colaboradores:", error);
setServiceAssignmentError(error?.message || "No pudimos cargar los colaboradores. Reintentá en unos segundos.");
} finally {
setIsLoadingServiceCollaborators(false);
}
};
const toggleServiceCollaboratorSelection = (employee: any) => {
const id = getEmployeeId(employee);
if (!id || isCreating) return;
setSelectedServiceCollaboratorIds(prev => (
prev.includes(id)
? prev.filter(selectedId => selectedId !== id)
: [...prev, id]
));
};
const handleAssignCreatedServiceToCollaborators = async () => {
if (!createdCompanyId || !createdServiceId || isCreating) return;
if (selectedServiceCollaboratorIds.length === 0) {
setServiceAssignmentError("Seleccioná al menos un colaborador para asignar el servicio.");
return;
}
const selectedEmployees = allEmployees.filter((employee: any) => selectedServiceCollaboratorIds.includes(getEmployeeId(employee)));
if (selectedEmployees.length === 0) {
setServiceAssignmentError("No encontramos los colaboradores seleccionados. Volvé a cargarlos e intentá de nuevo.");
return;
}
setIsCreating(true);
setServiceAssignmentError("");
setServiceAssignmentWarning("");
try {
await Promise.all(selectedEmployees.map((employee: any) => API.post<any>("employees/update-collaborator-services", {
companyId: createdCompanyId,
employeeId: getEmployeeId(employee),
services: [{ serviceId: createdServiceId, active: true }],
sessionUser: SessionInfo.userId
})));
const scheduleResults = await Promise.all(selectedEmployees.map(async (employee: any) => {
const employeeId = getEmployeeId(employee);
const schedulesRes = await API.post<any>("schedules/get-all-by-collaborator", {
companyId: createdCompanyId,
employeeId
});
return { employee, hasSchedules: (schedulesRes?.data || []).length > 0 };
}));
const missingSchedules = scheduleResults.filter(result => !result.hasSchedules);
if (missingSchedules.length > 0) {
const names = missingSchedules.map(({ employee }: any) => getCollaboratorLabel(employee)).join(", ");
setServiceAssignmentWarning(`Servicio asignado. Todavía necesitás cargar horarios para: ${names}. Hasta entonces no van a poder recibir reservas.`);
handleActionSuccess();
return;
}
handleActionSuccess();
} catch (error: any) {
console.error("Error asignando servicio a colaboradores:", error);
setServiceAssignmentError(error?.message || "No pudimos asignar el servicio a los colaboradores. Reintentá en unos segundos.");
} finally {
setIsCreating(false);
}
@@ -1147,9 +1409,22 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
setServicePrice={setServicePrice}
serviceLimit={serviceLimit}
setServiceLimit={setServiceLimit}
serviceCreateError={serviceCreateError}
handleUploadServiceImage={handleUploadServiceImage}
serviceImage={serviceImage}
setServiceImage={setServiceImage}
isStandaloneNewService={isStandaloneAction && action === "new-service"}
handleAssignCreatedServiceToMe={handleAssignCreatedServiceToMe}
handleShowCollaboratorAssignment={loadServiceAssignmentCollaborators}
handleToggleServiceCollaborator={toggleServiceCollaboratorSelection}
handleAssignCreatedServiceToCollaborators={handleAssignCreatedServiceToCollaborators}
handleFinishWithoutAssignment={handleActionSuccess}
serviceAssignmentError={serviceAssignmentError}
serviceAssignmentWarning={serviceAssignmentWarning}
serviceAssignmentMode={serviceAssignmentMode}
serviceAssignmentCollaborators={allEmployees}
selectedServiceCollaboratorIds={selectedServiceCollaboratorIds}
isLoadingServiceCollaborators={isLoadingServiceCollaborators}
/>
);
@@ -1321,57 +1596,160 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
);
const handleUpdateScheduleSubmit = async () => {
const formatScheduleConflicts = (conflicts: any[]) => {
if (!conflicts || conflicts.length === 0) return "";
return conflicts.map((conflict) => conflict.message).join("\n");
};
const getUpdateScheduleTargetEmployees = () => {
if (scheduleScope === "specific") {
return allEmployees.filter((employee: any) => selectedScheduleEmployeeIds.includes(String(getEmployeeId(employee))));
}
return getTargetEmployees(allEmployees, scheduleScope, targetEmployeeId, SessionInfo.userId);
};
const loadScheduleServicesForEmployees = async (employeeIdsToLoad: string[]) => {
if (!createdCompanyId || employeeIdsToLoad.length === 0) {
setScheduleAvailableServices([]);
return;
}
const results = await Promise.all(employeeIdsToLoad.map((employeeIdToLoad) => API.post<any>("employees/get-all-services", {
companyId: createdCompanyId,
employeeId: employeeIdToLoad,
sessionUser: SessionInfo.userId
})));
const serviceGroups = results.map((result) => (result?.services || []).filter((service: any) => service.active));
if (serviceGroups.length === 0) {
setScheduleAvailableServices([]);
return;
}
const commonServiceIds = serviceGroups.slice(1).reduce((commonIds, services) => {
const currentIds = new Set(services.map((service: any) => String(service.id || service._id)));
return commonIds.filter((serviceId: string) => currentIds.has(serviceId));
}, serviceGroups[0].map((service: any) => String(service.id || service._id)));
const commonServiceIdSet = new Set(commonServiceIds);
const commonServices = serviceGroups[0].filter((service: any) => commonServiceIdSet.has(String(service.id || service._id)));
setScheduleAvailableServices(commonServices);
setScheduleServiceIds((currentIds) => currentIds.filter((serviceId) => commonServiceIdSet.has(String(serviceId))));
};
const handleToggleScheduleEmployee = (employee: any) => {
const employeeIdToToggle = String(getEmployeeId(employee));
setSelectedScheduleEmployeeIds((currentIds) => {
const nextIds = currentIds.includes(employeeIdToToggle)
? currentIds.filter((id) => id !== employeeIdToToggle)
: [...currentIds, employeeIdToToggle];
setScheduleServiceScope("all");
setScheduleServiceIds([]);
setScheduleAvailableServices([]);
setScheduleConflictMessage("");
return nextIds;
});
};
const handleSetUpdateScheduleScope = (scope: "me" | "specific" | "all") => {
setScheduleScope(scope);
setScheduleServiceScope("all");
setScheduleServiceIds([]);
setScheduleAvailableServices([]);
setScheduleConflictMessage("");
setPendingScheduleOverwrite(false);
};
const handleUpdateScheduleSubmit = async (conflictStrategy: "reject" | "overwrite-conflicts" = "reject") => {
if (!createdCompanyId || isCreating || !scheduleScope) return;
setIsCreating(true);
setScheduleConflictMessage("");
setPendingScheduleOverwrite(false);
try {
const targetEmployees = getTargetEmployees(allEmployees, scheduleScope, targetEmployeeId, SessionInfo.userId);
const targetEmployees = getUpdateScheduleTargetEmployees();
if (targetEmployees.length === 0) {
alert("No se encontró ningún colaborador seleccionado.");
setScheduleConflictMessage("Seleccioná al menos un colaborador para actualizar sus horarios.");
setIsCreating(false);
return;
}
const workingDays = getWorkingDays(workingDaysMode);
const employeeIds = targetEmployees.map((employee) => getEmployeeId(employee)).filter(Boolean);
const weekDays = workingDaysMode === "specific-day" && specificWeekDay !== null
? [specificWeekDay]
: getWorkingDays(workingDaysMode);
const canUseSpecificServices = workingDaysMode === "specific-day" && employeeIds.length > 0;
const effectiveServiceScope = canUseSpecificServices ? scheduleServiceScope : "all";
const schedules = buildScheduleBlocks(
openTime,
closeTime,
hasBreak,
breakStart,
breakEnd,
effectiveServiceScope,
effectiveServiceScope === "specific" ? scheduleServiceIds : []
);
for (const emp of targetEmployees) {
const empId = getEmployeeId(emp);
if (!empId) continue;
if (weekDays.length === 0) {
throw new Error("Seleccioná al menos un día de trabajo.");
}
for (let i = 0; i <= 6; i++) {
const isWorkingDay = workingDays.includes(i);
const schedules = isWorkingDay ? buildScheduleBlocks(openTime, closeTime, hasBreak, breakStart, breakEnd) : [];
if (effectiveServiceScope === "specific" && scheduleServiceIds.length === 0) {
throw new Error("Seleccioná al menos un servicio.");
}
await API.post<any>("schedules/update", {
if (effectiveServiceScope === "specific" && scheduleAvailableServices.length === 0) {
throw new Error("Los colaboradores seleccionados no tienen servicios activos en común.");
}
const result = await API.post<any>("schedules/apply", {
companyId: createdCompanyId,
employeeId: empId,
weekDay: i,
schedules: schedules,
sessionUser: SessionInfo.userId
employeeIds,
weekDays,
schedules,
sessionUser: SessionInfo.userId,
conflictStrategy
});
if (result?.valid === false && !result?.applied) {
setScheduleConflictMessage(formatScheduleConflicts(result.conflicts) || result.message);
setPendingScheduleOverwrite(true);
return;
}
}
handleActionSuccess();
} catch (error: any) {
console.error("Error actualizando horarios:", error);
alert("Hubo un error al guardar los horarios: " + error?.message);
setScheduleConflictMessage("Hubo un error al guardar los horarios: " + error?.message);
} finally {
setIsCreating(false);
}
};
const renderUpdateScheduleFlow = () => (
<UpdateScheduleFlow
const renderUpdateScheduleFlow = () => {
const scheduleTargetEmployees = getUpdateScheduleTargetEmployees();
const scheduleTargetEmployeeIds = scheduleTargetEmployees.map((employee) => String(getEmployeeId(employee))).filter(Boolean);
const canUseSpecificScheduleServices = scheduleTargetEmployeeIds.length > 0;
return <UpdateScheduleFlow
currentStepIndex={currentStepIndex}
scheduleScope={scheduleScope}
setScheduleScope={setScheduleScope}
setScheduleScope={handleSetUpdateScheduleScope}
targetEmployeeId={targetEmployeeId}
setTargetEmployeeId={setTargetEmployeeId}
selectedScheduleEmployeeIds={selectedScheduleEmployeeIds}
handleToggleScheduleEmployee={handleToggleScheduleEmployee}
allEmployees={allEmployees}
handleNext={handleNext}
workingDaysMode={workingDaysMode}
setWorkingDaysMode={setWorkingDaysMode}
specificWeekDay={specificWeekDay}
setSpecificWeekDay={setSpecificWeekDay}
openTime={openTime}
setOpenTime={setOpenTime}
closeTime={closeTime}
@@ -1384,8 +1762,20 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
setBreakEnd={setBreakEnd}
isCreating={isCreating}
handleUpdateScheduleSubmit={handleUpdateScheduleSubmit}
/>
);
scheduleServiceScope={scheduleServiceScope}
setScheduleServiceScope={setScheduleServiceScope}
scheduleServiceIds={scheduleServiceIds}
setScheduleServiceIds={setScheduleServiceIds}
scheduleAvailableServices={scheduleAvailableServices}
loadScheduleServicesForEmployees={loadScheduleServicesForEmployees}
scheduleTargetEmployeeIds={scheduleTargetEmployeeIds}
canUseSpecificScheduleServices={canUseSpecificScheduleServices}
scheduleConflictMessage={scheduleConflictMessage}
pendingScheduleOverwrite={pendingScheduleOverwrite}
setScheduleConflictMessage={setScheduleConflictMessage}
setPendingScheduleOverwrite={setPendingScheduleOverwrite}
/>;
};
@@ -1747,6 +2137,45 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
}
};
const togglePublicationService = (serviceId: string) => {
if (isCreating) return;
setSelectedPublicationServiceIds(prev => (
prev.includes(serviceId)
? prev.filter(selectedId => selectedId !== serviceId)
: [...prev, serviceId]
));
};
const handleSaveServicePublication = async () => {
if (!createdCompanyId || isCreating) return;
if (selectedPublicationServiceIds.length === 0) {
setPublicationMessage("Seleccioná al menos un servicio para continuar.");
return;
}
const nextStatus = flowId === "publish-services"
? SERVICE_PUBLISHED_STATUS.PUBLISHED
: SERVICE_PUBLISHED_STATUS.PRIVATE;
setIsCreating(true);
setPublicationMessage("");
try {
await Promise.all(selectedPublicationServiceIds.map(serviceId =>
API.post<any>("services/set-published-status", {
serviceId,
published: nextStatus,
sessionUser: SessionInfo.userId
})
));
setIsSuccess(true);
} catch (error: any) {
console.error("Error actualizando publicación de servicios:", error);
setPublicationMessage(error?.message || "No pudimos guardar el cambio de publicación. Reintentá en unos segundos.");
} finally {
setIsCreating(false);
}
};
const renderPublicVisibilityFlow = () => (
<PublicVisibilityFlow
isSelectedOrganizationPaidPlan={isSelectedOrganizationPaidPlan}
@@ -1768,6 +2197,59 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
/>
);
const renderServicePublicationFlow = () => (
<ServicePublicationFlow
mode={flowId === "publish-services" ? "publish" : "unpublish"}
currentStepIndex={currentStepIndex}
services={publicationServices}
selectedServiceIds={selectedPublicationServiceIds}
toggleService={togglePublicationService}
handleSave={handleSaveServicePublication}
isCreating={isCreating}
isLoading={publicationLoading}
message={publicationMessage}
selectedOrganization={selectedOrganization}
orgName={orgName}
/>
);
const handleLoadCollaboratorScheduleDetails = async () => {
if (!createdCompanyId || !targetEmployeeId || collaboratorScheduleLoading) return;
setCollaboratorScheduleLoading(true);
setCollaboratorScheduleMessage("");
try {
const details = await API.post<CollaboratorScheduleDetails>("admin-assistant/collaborator-schedule-details", {
companyId: createdCompanyId,
employeeId: targetEmployeeId,
sessionUser: SessionInfo.userId,
});
setCollaboratorScheduleDetails(details);
setCurrentStepIndex(1);
} catch (error: any) {
console.error("Error consultando horarios del colaborador:", error);
setCollaboratorScheduleMessage(error?.message || "No pudimos cargar los horarios del colaborador. Reintentá en unos segundos.");
} finally {
setCollaboratorScheduleLoading(false);
}
};
const renderCollaboratorScheduleSummaryFlow = () => (
<CollaboratorScheduleSummaryFlow
currentStepIndex={currentStepIndex}
employees={allEmployees}
selectedEmployeeId={targetEmployeeId}
setSelectedEmployeeId={setTargetEmployeeId}
details={collaboratorScheduleDetails}
loading={collaboratorScheduleLoading}
message={collaboratorScheduleMessage}
selectedOrganization={selectedOrganization}
orgName={orgName}
loadDetails={handleLoadCollaboratorScheduleDetails}
goToAssistantHome={() => router.push("/admin/assistant")}
/>
);
const getSelectedOrganizationSlug = () => {
return buildSelectedOrganizationSlug(selectedOrganization, orgName);
};
@@ -1905,13 +2387,31 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
/>
);
const renderFlow = () => {
if (requiresOrganizationContext() && !isResuming && allOrganizations.length === 0 && !createdCompanyId) {
return (
<QuestionCard
isActive={true}
title="Necesitás una organización"
description="Para continuar con este flujo primero tenés que crear o tener asociada una organización. Sin una organización no podemos aplicar esta configuración."
onNext={() => router.push("/admin/assistant")}
nextLabel="Volver al inicio del asistente"
>
<div style={{ color: "rgba(255,255,255,0.75)", textAlign: "center", lineHeight: 1.6 }}>
Creá una organización desde el inicio del asistente y después volvé a intentar esta acción.
</div>
</QuestionCard>
);
}
if (allOrganizations.length > 1 && !createdCompanyId && action !== "new-org") {
return <OrganizationPicker allOrganizations={allOrganizations} loadOrganizationContext={loadOrganizationContext} />;
}
if (flowId === "setup-collaborator") return renderSetupCollaboratorFlow();
if (flowId === "new-collaborator") return renderNewCollaboratorFlow();
if (flowId === "collaborator-schedule-summary") return renderCollaboratorScheduleSummaryFlow();
if (flowId === "public-visibility") return renderPublicVisibilityFlow();
if (flowId === "publish-services" || flowId === "unpublish-services") return renderServicePublicationFlow();
if (flowId === "public-link") return renderPublicLinkFlow();
if (flowId === "notifications") return renderNotificationsFlow();
if (flowId === "whatsapp-bot") return renderWhatsAppBotFlow();
@@ -1952,6 +2452,8 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
);
}
const shouldHideFlowTitle = flowId === "collaborator-schedule-summary" && currentStepIndex === 1;
return (
<div className={style.engineContainer}>
<div className={style.header}>
@@ -1960,7 +2462,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
<span>Volver</span>
</div>
<div className={style.flowTitle}>
Configuración de Negocio
{!shouldHideFlowTitle && "Configuración de Negocio"}
</div>
<div className={style.progressIndicator}>
{/* Placeholder for progress dots or percentage */}
@@ -0,0 +1,62 @@
import React from "react";
type Props = {
employees: any[];
selectedIds?: string[];
selectedId?: string | null;
multiple?: boolean;
disabled?: boolean;
onToggle?: (employee: any) => void;
onSelect?: (employeeId: string) => void;
};
const getEmployeeId = (employee: any) => employee.id || employee._id || employee.employeeId;
export const getCollaboratorLabel = (employee: any) => {
const fullName = employee.fullName || [employee.firstName, employee.lastName].filter(Boolean).join(" ").trim();
return fullName || employee.email || employee.userId?.email || employee.userId?.name || "Colaborador";
};
export default function CollaboratorPicker({ employees, selectedIds = [], selectedId = null, multiple = false, disabled = false, onToggle, onSelect }: Props) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', width: '100%' }}>
{employees.map((employee: any) => {
const employeeId = getEmployeeId(employee);
const isSelected = multiple ? selectedIds.includes(employeeId) : selectedId === employeeId;
const label = getCollaboratorLabel(employee);
return (
<label
key={employeeId}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '12px 14px',
borderRadius: '12px',
border: `1px solid ${isSelected ? 'var(--wine-red)' : 'rgba(255,255,255,0.16)'}`,
background: isSelected ? 'rgba(128, 0, 64, 0.3)' : 'rgba(255,255,255,0.05)',
color: 'white',
cursor: disabled ? 'not-allowed' : 'pointer'
}}
>
<input
type={multiple ? "checkbox" : "radio"}
checked={isSelected}
disabled={disabled}
onChange={() => {
if (disabled) return;
if (multiple) {
onToggle?.(employee);
} else if (employeeId) {
onSelect?.(employeeId);
}
}}
/>
<span style={{ fontSize: '15px' }}>{label}</span>
</label>
);
})}
</div>
);
}
@@ -14,6 +14,8 @@ export default function SuccessScreen(props: Props) {
if (flowId === "override-schedule" || flowId === "disable-schedule") return "El horario especial ha sido guardado exitosamente.";
if (flowId === "new-collaborator") return "El colaborador fue invitado exitosamente.";
if (flowId === "setup-collaborator") return "El colaborador ha sido configurado exitosamente y ya puede comenzar a recibir reservas.";
if (flowId === "publish-services") return "Los servicios seleccionados ya están publicados.";
if (flowId === "unpublish-services") return "Los servicios seleccionados dejaron de estar publicados.";
if (flowId === "notifications") return "La configuración de notificaciones se ha guardado exitosamente.";
if (flowId === "whatsapp-bot") return "Tu bot de WhatsApp está configurado y funcionando. Tus clientes recibirán notificaciones automáticas por WhatsApp.";
if (flowId === "open-reservation-periods") return "El período de reservas fue extendido exitosamente.";
@@ -0,0 +1,234 @@
import React from "react";
import dayjs from "dayjs";
import QuestionCard from "../../QuestionCard/QuestionCard";
import CollaboratorPicker from "../components/CollaboratorPicker";
type ScheduleItem = {
from: string;
to: string;
disabled: boolean;
serviceScope?: "all" | "specific";
serviceIds?: string[];
};
type WeeklySchedule = {
scheduleId: string;
weekDay: number;
schedules: ScheduleItem[];
};
type DisabledSchedule = {
id?: string;
_id?: string;
startDate: Date | string;
endDate: Date | string;
};
type ScheduleOverride = {
id?: string;
_id?: string;
date: Date | string;
schedules: ScheduleItem[];
};
export type CollaboratorScheduleDetails = {
collaborator: {
employeeId: string;
fullName: string;
email: string;
avatar: string;
userId: string;
};
servicesById: Record<string, { id: string; name: string }>;
weeklySchedules: WeeklySchedule[];
futureDisabledSchedules: DisabledSchedule[];
futureOverrides: ScheduleOverride[];
};
type Props = {
currentStepIndex: number;
employees: any[];
selectedEmployeeId: string | null;
setSelectedEmployeeId: (employeeId: string) => void;
details: CollaboratorScheduleDetails | null;
loading: boolean;
message: string;
selectedOrganization?: any;
orgName: string;
loadDetails: () => void;
goToAssistantHome: () => void;
};
const weekDays = ["Domingo", "Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado"];
const weekDayDisplayOrder = [1, 2, 3, 4, 5, 6, 0];
const cardStyle: React.CSSProperties = {
padding: "14px",
borderRadius: "14px",
background: "rgba(255,255,255,0.05)",
border: "1px solid rgba(255,255,255,0.12)",
color: "white",
};
const mutedStyle: React.CSSProperties = {
color: "rgba(255,255,255,0.7)",
fontSize: "14px",
lineHeight: 1.45,
};
const formatDate = (date: Date | string) => dayjs(date).format("DD/MM/YYYY");
const formatSchedule = (schedule: ScheduleItem, servicesById: CollaboratorScheduleDetails["servicesById"] = {}) => {
const selectedServices = (schedule.serviceIds || [])
.map(serviceId => servicesById[serviceId]?.name || `Servicio ${serviceId}`)
.filter(Boolean);
const scope = schedule.serviceScope === "specific"
? selectedServices.length > 0 ? selectedServices.join(", ") : "servicios específicos sin nombre disponible"
: "todos los servicios";
return `${schedule.from} a ${schedule.to}${schedule.disabled ? " (deshabilitado)" : ""} · ${scope}`;
};
function ExpandableItem({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) {
const [open, setOpen] = React.useState(false);
return (
<button
type="button"
onClick={() => setOpen(prev => !prev)}
style={{ ...cardStyle, width: "100%", textAlign: "left", cursor: "pointer" }}
>
<span style={{ display: "flex", justifyContent: "space-between", gap: "12px", alignItems: "center" }}>
<span style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
<strong>{title}</strong>
<span style={mutedStyle}>{subtitle}</span>
</span>
<span style={{ color: "rgba(255,255,255,0.72)", fontSize: "13px" }}>{open ? "Ocultar" : "Ver detalle"}</span>
</span>
{open && (
<span style={{ display: "block", marginTop: "12px", paddingTop: "12px", borderTop: "1px solid rgba(255,255,255,0.1)" }}>
{children}
</span>
)}
</button>
);
}
export default function CollaboratorScheduleSummaryFlow(props: Props) {
const {
currentStepIndex,
employees,
selectedEmployeeId,
setSelectedEmployeeId,
details,
loading,
message,
selectedOrganization,
orgName,
loadDetails,
goToAssistantHome,
} = props;
const configuredWeeklySchedules = details?.weeklySchedules
.filter(day => day.schedules.length > 0)
.sort((a, b) => weekDayDisplayOrder.indexOf(a.weekDay) - weekDayDisplayOrder.indexOf(b.weekDay)) || [];
if (currentStepIndex === 0) {
return (
<QuestionCard
isActive={true}
title="Elegí el colaborador"
description="Seleccioná a quién querés consultar. Esta acción es sólo de lectura."
onNext={loadDetails}
disableNext={!selectedEmployeeId || loading || employees.length === 0}
nextLabel={loading ? "Consultando..." : "Ver horarios"}
>
<div style={{ display: "flex", flexDirection: "column", gap: "14px", width: "100%" }}>
<div style={{ ...mutedStyle, textAlign: "center" }}>
Organización: <strong style={{ color: "white" }}>{selectedOrganization?.name || orgName || "seleccionada"}</strong>
</div>
{message && <div style={cardStyle}>{message}</div>}
{employees.length === 0 ? (
<div style={{ ...cardStyle, ...mutedStyle, textAlign: "center" }}>No encontramos colaboradores activos en esta organización.</div>
) : (
<CollaboratorPicker employees={employees} selectedId={selectedEmployeeId} onSelect={setSelectedEmployeeId} disabled={loading} />
)}
</div>
</QuestionCard>
);
}
return (
<QuestionCard
isActive={true}
title=""
onNext={goToAssistantHome}
nextLabel="Volver al asistente"
>
<div style={{ display: "flex", flexDirection: "column", gap: "16px", width: "100%", paddingTop: "150px" }}>
{message && <div style={cardStyle}>{message}</div>}
{!details ? (
<div style={{ ...cardStyle, ...mutedStyle, textAlign: "center" }}>No hay datos para mostrar.</div>
) : (
<>
<div style={{ ...cardStyle, display: "flex", alignItems: "center", justifyContent: "center", gap: "12px", textAlign: "left", padding: "18px 14px", marginTop: "4px" }}>
<img
src={details.collaborator.avatar}
alt={details.collaborator.fullName}
style={{ width: "46px", height: "46px", borderRadius: "50%", objectFit: "cover", border: "2px solid rgba(255,255,255,0.18)", flex: "0 0 auto" }}
/>
<div>
<strong style={{ display: "block", fontSize: "18px", lineHeight: 1.35 }}>{details.collaborator.fullName}</strong>
<div style={mutedStyle}>{details.collaborator.email || "Sin email registrado"}</div>
</div>
</div>
<section style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
<h3 style={{ margin: 0, fontSize: "16px", color: "white" }}>Horarios semanales</h3>
{configuredWeeklySchedules.length === 0 ? (
<div style={{ ...cardStyle, ...mutedStyle }}>Este colaborador no tiene horarios semanales configurados.</div>
) : configuredWeeklySchedules.map(day => (
<div key={day.scheduleId || day.weekDay} style={cardStyle}>
<strong>{weekDays[day.weekDay] || `Día ${day.weekDay}`}</strong>
<div style={{ display: "flex", flexDirection: "column", gap: "6px", marginTop: "8px" }}>
{day.schedules.map((schedule, index) => (
<span key={`${day.weekDay}-${index}`} style={mutedStyle}>{formatSchedule(schedule, details.servicesById)}</span>
))}
</div>
</div>
))}
</section>
<section style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
<h3 style={{ margin: 0, fontSize: "16px", color: "white" }}>Excepciones futuras</h3>
{details.futureOverrides.length === 0 ? (
<div style={{ ...cardStyle, ...mutedStyle }}>No hay excepciones futuras configuradas.</div>
) : details.futureOverrides.map((override, index) => (
<ExpandableItem key={override.id || override._id || index} title={formatDate(override.date)} subtitle={`${override.schedules.length} bloque(s) configurado(s)`}>
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
{override.schedules.map((schedule, scheduleIndex) => (
<span key={scheduleIndex} style={mutedStyle}>{formatSchedule(schedule, details.servicesById)}</span>
))}
</div>
</ExpandableItem>
))}
</section>
<section style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
<h3 style={{ margin: 0, fontSize: "16px", color: "white" }}>Restricciones / agenda cerrada</h3>
{details.futureDisabledSchedules.length === 0 ? (
<div style={{ ...cardStyle, ...mutedStyle }}>No hay cierres de agenda futuros configurados.</div>
) : details.futureDisabledSchedules.map((disabled, index) => (
<ExpandableItem
key={disabled.id || disabled._id || index}
title={`${formatDate(disabled.startDate)} al ${formatDate(disabled.endDate)}`}
subtitle="Agenda cerrada para este colaborador"
>
<div style={mutedStyle}>Durante este período no se muestran turnos disponibles para el colaborador.</div>
</ExpandableItem>
))}
</section>
</>
)}
</div>
</QuestionCard>
);
}

Some files were not shown because too many files have changed in this diff Show More