18 Commits

Author SHA1 Message Date
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
110 changed files with 5599 additions and 737 deletions
@@ -1,11 +1,17 @@
package com.hdrdevs.turnosxpress; package com.hdrdevs.turnosxpress;
import androidx.appcompat.app.AppCompatActivity; 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.Bundle;
import android.os.Build;
import android.os.Message; import android.os.Message;
import android.content.ActivityNotFoundException; import android.content.ActivityNotFoundException;
import android.content.Intent; import android.content.Intent;
import android.content.pm.PackageManager;
import android.media.MediaScannerConnection;
import android.net.Uri; import android.net.Uri;
import android.util.Log; import android.util.Log;
import android.view.KeyEvent; import android.view.KeyEvent;
@@ -33,6 +39,7 @@ import android.content.ContentResolver;
public class MainActivity extends AppCompatActivity { public class MainActivity extends AppCompatActivity {
private static final int LOGIN_REQUEST_CODE = 1; private static final int LOGIN_REQUEST_CODE = 1;
private static final int STORAGE_PERMISSION_REQUEST_CODE = 2;
private WebView webView; private WebView webView;
private ValueCallback<Uri[]> fileChooserCallback; private ValueCallback<Uri[]> fileChooserCallback;
@@ -43,6 +50,7 @@ public class MainActivity extends AppCompatActivity {
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
tokenResult = ""; tokenResult = "";
requestLegacyStoragePermissionIfNeeded();
// For Android Emulator, use 10.0.2.2 to access localhost on your development machine. // 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. // 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 { public class WebAppInterface {
@JavascriptInterface @JavascriptInterface
public void startGoogleLogin() { public void startGoogleLogin() {
@@ -235,16 +265,20 @@ public class MainActivity extends AppCompatActivity {
@JavascriptInterface @JavascriptInterface
public void savePdf(String base64Data, String filename) { public void savePdf(String base64Data, String filename) {
String safeFilename = safePdfFilename(filename);
try { 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; 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(); ContentResolver resolver = getContentResolver();
ContentValues contentValues = new ContentValues(); 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.MIME_TYPE, "application/pdf");
contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS); 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); Uri uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues);
@@ -253,18 +287,31 @@ public class MainActivity extends AppCompatActivity {
outputStream.write(pdfAsBytes); outputStream.write(pdfAsBytes);
isSaved = true; isSaved = true;
} catch (IOException e) { } 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 { } else {
File path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS); 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)) { try (FileOutputStream os = new FileOutputStream(file)) {
os.write(pdfAsBytes); os.write(pdfAsBytes);
isSaved = true; isSaved = true;
MediaScannerConnection.scanFile(MainActivity.this, new String[]{file.getAbsolutePath()}, new String[]{"application/pdf"}, null);
} catch (IOException e) { } 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) { } 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()); 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/jest": "^30.0.0",
"@types/mongoose": "^5.11.96", "@types/mongoose": "^5.11.96",
"@types/node": "^24.3.3", "@types/node": "^24.3.3",
"cross-env": "^10.1.0",
"jest": "^30.4.2", "jest": "^30.4.2",
"ts-jest": "^29.4.11", "ts-jest": "^29.4.11",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
@@ -600,6 +601,13 @@
"tslib": "^2.4.0" "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": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -2322,6 +2330,24 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+2 -1
View File
@@ -4,7 +4,7 @@
"type": "module", "type": "module",
"main": "index.js", "main": "index.js",
"scripts": { "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", "build": "tsc",
"start": "node dist/index.js", "start": "node dist/index.js",
"test": "jest --config jest.config.cjs" "test": "jest --config jest.config.cjs"
@@ -18,6 +18,7 @@
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/mongoose": "^5.11.96", "@types/mongoose": "^5.11.96",
"@types/node": "^24.3.3", "@types/node": "^24.3.3",
"cross-env": "^10.1.0",
"jest": "^30.4.2", "jest": "^30.4.2",
"ts-jest": "^29.4.11", "ts-jest": "^29.4.11",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
@@ -91,12 +91,17 @@ export async function dispatchEmail(params: {
systemToken: string; systemToken: string;
type: string; type: string;
email: string; email: string;
subject: string; subject?: string;
message: string; message?: string;
templateId?: string;
context?: Record<string, unknown>;
}): Promise<DispatchResult> { }): Promise<DispatchResult> {
try { try {
if (!params.email.trim() || !params.subject.trim() || !params.message.trim()) { const hasContent = Boolean(params.subject?.trim() && params.message?.trim());
return { success: false, error: "Email dispatch requires email, subject, and message" }; 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. // Send worker-rendered content directly so cancellation jobs survive appointment deletion.
@@ -106,8 +111,10 @@ export async function dispatchEmail(params: {
{ {
systemToken: params.systemToken, systemToken: params.systemToken,
email: params.email, email: params.email,
subject: params.subject, ...(params.subject ? { subject: params.subject } : {}),
message: params.message, ...(params.message ? { message: params.message } : {}),
...(params.templateId ? { templateId: params.templateId } : {}),
...(params.context ? { context: params.context } : {}),
}, },
{ {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -202,6 +202,8 @@ export class JobProcessor {
email: content.email || "", email: content.email || "",
subject: content.subject, subject: content.subject,
message: content.message, message: content.message,
templateId: content.emailTemplateId,
context: content.emailContext,
}); });
break; break;
case "system": case "system":
@@ -45,6 +45,8 @@ export interface ResolvedNotificationContent {
companyOwnerId?: string; companyOwnerId?: string;
subject: string; subject: string;
message: string; message: string;
emailTemplateId?: string;
emailContext?: Record<string, unknown>;
} }
const appointmentSchema = new Schema( const appointmentSchema = new Schema(
@@ -169,6 +171,8 @@ export async function resolveNotificationContent(job: INotificationJobDocument):
companyOwnerId: companyOwnerId ? String(companyOwnerId) : undefined, companyOwnerId: companyOwnerId ? String(companyOwnerId) : undefined,
subject: fallbackSubject.trim(), subject: fallbackSubject.trim(),
message: fallbackMessage.trim(), message: fallbackMessage.trim(),
emailTemplateId: job.payload.emailTemplateId?.trim(),
emailContext: job.payload.emailContext,
}; };
} }
} }
@@ -15,6 +15,8 @@ export interface NotificationJobPayload {
message?: string; message?: string;
emailSubject?: string; emailSubject?: string;
emailMessage?: string; emailMessage?: string;
emailTemplateId?: string;
emailContext?: Record<string, unknown>;
wapMessage?: string; wapMessage?: string;
systemSubject?: string; systemSubject?: string;
systemMessage?: 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 () => { it("sends the job type in the WhatsApp system notification payload", async () => {
await dispatchWhatsApp({ await dispatchWhatsApp({
companyId: "company-1", 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_ACCESS_TOKEN = APP_USR-8753424836448507-121412-731599daea1ffa0b278b5dcc78aa1ec8-1611134648
MP_BACK_URL = https://turnosxpress.com.ar/landing/dashboard MP_BACK_URL = https://turnosxpress.com.ar/landing/dashboard
MP_CHECK_PAYMENT_MINUTES = 1 MP_CHECK_PAYMENT_MINUTES = 1
MP_WEBHOOK_URL = https://9300-186-132-200-176.ngrok-free.app/mercadopago/webhook MP_WEBHOOK_URL = https://30e1-186-132-147-13.ngrok-free.app/mercadopago/webhook
DEFAULT_BOT_ADMIN_PORT = 3005 DEFAULT_BOT_ADMIN_PORT = 3005
-82
View File
@@ -6648,38 +6648,6 @@
"url": "https://opencollective.com/mongoose" "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": { "node_modules/mongoose/node_modules/mongodb": {
"version": "6.10.0", "version": "6.10.0",
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.10.0.tgz", "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", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" "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": { "node_modules/mpath": {
"version": "0.9.0", "version": "0.9.0",
"resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz", "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; systemToken?: string;
type: APPOINTMENT_NOTIFICATION_TYPE; type: APPOINTMENT_NOTIFICATION_TYPE;
channel?: "whatsapp" | "email"; channel?: "whatsapp" | "email";
allowClientCancellation?: boolean;
}; };
export type CreateImmediateAppointmentNotificationJobsParams = { export type CreateImmediateAppointmentNotificationJobsParams = {
@@ -203,6 +204,8 @@ export interface AppointmentNotificationIntent {
message: string; message: string;
checkClient: IClientDocument; checkClient: IClientDocument;
companyCheck: ICompanyDocument; companyCheck: ICompanyDocument;
emailTemplateId?: string;
emailContext?: Record<string, unknown>;
} }
export interface IAppointment { export interface IAppointment {
+150 -13
View File
@@ -287,6 +287,23 @@ class AppointmentManager implements IAppointmentsManager {
return parseInt(`${process.env.CANCELLATION_TIME}`) || 24; 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 { private validateCancellationTime(aTime: number | undefined, start: string | Date): boolean {
let cancellationTime = this.getCancellationTime(aTime); let cancellationTime = this.getCancellationTime(aTime);
@@ -517,7 +534,7 @@ class AppointmentManager implements IAppointmentsManager {
await this.updateHeatMap(availabilityConfig); await this.updateHeatMap(availabilityConfig);
await this.tryApplyDiscounts(newAppointment); await this.tryApplyDiscounts(newAppointment, String(companyCheck.ownerId));
if (notification && checkClient.userId) { if (notification && checkClient.userId) {
//Envio la notificacion al cliente. //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 //Intenta aplicar un descuento al turno si esta especificado en DiscountsEmail
// y la fecha coincide con el rango especificado. // y la fecha coincide con el rango especificado.
@@ -615,7 +632,7 @@ class AppointmentManager implements IAppointmentsManager {
appointmentId: String(appointment.id), appointmentId: String(appointment.id),
discountId: String(existDiscount.discountId), discountId: String(existDiscount.discountId),
companyId: String(appointment.companyId), companyId: String(appointment.companyId),
sessionUser: String(appointment.userId), sessionUser: sessionUser,
}); });
} catch (err) { } catch (err) {
console.log(err); console.log(err);
@@ -670,6 +687,47 @@ class AppointmentManager implements IAppointmentsManager {
// Update both heatmaps // Update both heatmaps
await this.updateHeatMap(originalConfig); await this.updateHeatMap(originalConfig);
await this.updateHeatMap(newConfig); 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> { public async changeServiceForce(data: ChangeServiceParams): Promise<void> {
@@ -2168,6 +2226,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
companyOwnerId: string; companyOwnerId: string;
companyName: string; companyName: string;
emailMessage: string; emailMessage: string;
emailTemplateId?: string;
emailContext?: Record<string, unknown>;
wapMessage: string; wapMessage: string;
}): Promise<void> { }): Promise<void> {
const systemSubject = `Turno cancelado en ${data.companyName}`; const systemSubject = `Turno cancelado en ${data.companyName}`;
@@ -2187,6 +2247,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
message: data.emailMessage, message: data.emailMessage,
emailSubject: "TurnosXpress :: Turno cancelado", emailSubject: "TurnosXpress :: Turno cancelado",
emailMessage: data.emailMessage, emailMessage: data.emailMessage,
emailTemplateId: data.emailTemplateId,
emailContext: data.emailContext,
wapMessage: data.wapMessage, wapMessage: data.wapMessage,
systemSubject, systemSubject,
systemMessage: data.emailMessage, systemMessage: data.emailMessage,
@@ -2206,8 +2268,12 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
appointmentStart: Date; appointmentStart: Date;
emailMessage: string; emailMessage: string;
wapMessage: string; wapMessage: string;
emailTemplateId?: string;
emailContext?: Record<string, unknown>;
reminderEmailMessage: string; reminderEmailMessage: string;
reminderWapMessage: string; reminderWapMessage: string;
reminderEmailTemplateId?: string;
reminderEmailContext?: Record<string, unknown>;
}): Promise<void> { }): Promise<void> {
const systemSubject = `Turno reservado en ${data.companyName}`; const systemSubject = `Turno reservado en ${data.companyName}`;
@@ -2220,6 +2286,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
message: data.emailMessage, message: data.emailMessage,
emailSubject: "TurnosXpress :: Alta de turno", emailSubject: "TurnosXpress :: Alta de turno",
emailMessage: data.emailMessage, emailMessage: data.emailMessage,
emailTemplateId: data.emailTemplateId,
emailContext: data.emailContext,
wapMessage: data.wapMessage, wapMessage: data.wapMessage,
systemSubject, systemSubject,
systemMessage: data.emailMessage, systemMessage: data.emailMessage,
@@ -2277,6 +2345,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
companyName: data.companyName, companyName: data.companyName,
appointmentStart: data.appointmentStart, appointmentStart: data.appointmentStart,
reminderEmailMessage: reminderEmailContent.message, reminderEmailMessage: reminderEmailContent.message,
reminderEmailTemplateId: reminderEmailContent.emailTemplateId,
reminderEmailContext: reminderEmailContent.emailContext,
reminderWapMessage: reminderWapContent.message, reminderWapMessage: reminderWapContent.message,
}; };
@@ -2301,6 +2371,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
await this.createCreationNotificationJobs({ await this.createCreationNotificationJobs({
...notificationJobData, ...notificationJobData,
emailMessage: emailContent.message, emailMessage: emailContent.message,
emailTemplateId: emailContent.emailTemplateId,
emailContext: emailContent.emailContext,
wapMessage: wapContent.message, wapMessage: wapContent.message,
}); });
} }
@@ -2317,6 +2389,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
appointmentStart: Date; appointmentStart: Date;
reminderEmailMessage: string; reminderEmailMessage: string;
reminderWapMessage: string; reminderWapMessage: string;
reminderEmailTemplateId?: string;
reminderEmailContext?: Record<string, unknown>;
}): Promise<void> { }): Promise<void> {
const reminderSystemSubject = `Recordatorio de turno en ${data.companyName}`; const reminderSystemSubject = `Recordatorio de turno en ${data.companyName}`;
@@ -2335,6 +2409,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
message: data.reminderEmailMessage, message: data.reminderEmailMessage,
emailSubject: "TurnosXpress :: Recordatorio", emailSubject: "TurnosXpress :: Recordatorio",
emailMessage: data.reminderEmailMessage, emailMessage: data.reminderEmailMessage,
emailTemplateId: data.reminderEmailTemplateId,
emailContext: data.reminderEmailContext,
wapMessage: data.reminderWapMessage, wapMessage: data.reminderWapMessage,
systemSubject: reminderSystemSubject, systemSubject: reminderSystemSubject,
systemMessage: data.reminderEmailMessage, systemMessage: data.reminderEmailMessage,
@@ -2491,12 +2567,14 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
sessionUser: data.sessionUser, sessionUser: data.sessionUser,
type: APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION, type: APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION,
channel: "email", channel: "email",
allowClientCancellation: true,
}); });
const wapContent = await this.tryToSendNotification({ const wapContent = await this.tryToSendNotification({
appointmentId: String(checkAppointment._id), appointmentId: String(checkAppointment._id),
sessionUser: data.sessionUser, sessionUser: data.sessionUser,
type: APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION, type: APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION,
channel: "whatsapp", channel: "whatsapp",
allowClientCancellation: true,
}); });
await this.createCancellationNotificationJobs({ await this.createCancellationNotificationJobs({
@@ -2509,6 +2587,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
companyOwnerId: String(companyCheck.ownerId), companyOwnerId: String(companyCheck.ownerId),
companyName: companyCheck.name, companyName: companyCheck.name,
emailMessage: emailContent.message, emailMessage: emailContent.message,
emailTemplateId: emailContent.emailTemplateId,
emailContext: emailContent.emailContext,
wapMessage: wapContent.message, wapMessage: wapContent.message,
}); });
} }
@@ -2650,6 +2730,14 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
throw new Error("La compañia no existe"); 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); const hasSystemToken = this.hasValidSystemToken(data.systemToken);
if (!hasSystemToken) { 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"); 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()); 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({ const checkEmployee = await EmployeesList.employees.findOne({
_id: String(checkAppointment.employeeId), _id: String(checkAppointment.employeeId),
}); });
@@ -2695,6 +2783,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
} }
let wapMessage = ""; let wapMessage = "";
let emailTemplateId: string | undefined;
let emailContext: Record<string, unknown> | undefined;
if (data.type == APPOINTMENT_NOTIFICATION_TYPE.REMINDER) { if (data.type == APPOINTMENT_NOTIFICATION_TYPE.REMINDER) {
if (companyCheck.templateWapNotifId) { if (companyCheck.templateWapNotifId) {
@@ -2738,6 +2828,19 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name); wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name);
wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n"); 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) { } else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CREATION) {
const templateId = data.channel === "email" const templateId = data.channel === "email"
? companyCheck.templateEmailAltaId || companyCheck.templateWapAltaId ? companyCheck.templateEmailAltaId || companyCheck.templateWapAltaId
@@ -2787,6 +2890,19 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name); wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name);
wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n"); 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) { } else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION || data.type == APPOINTMENT_NOTIFICATION_TYPE.UPDATE) {
const isUpdate = data.type == APPOINTMENT_NOTIFICATION_TYPE.UPDATE; const isUpdate = data.type == APPOINTMENT_NOTIFICATION_TYPE.UPDATE;
const templateId = data.channel === "email" 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("<---PRECIO--->", formatCurrency(checkAppointment.price));
wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name); wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name);
wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n"); 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 { return {
message: wapMessage, message: wapMessage,
checkClient: checkClient, checkClient: checkClient,
companyCheck: companyCheck, 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, ...data,
channel: "email", channel: "email",
}); });
@@ -2903,6 +3036,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
email: clientEmail, email: clientEmail,
subject: subjectEmail, subject: subjectEmail,
message: message, message: message,
templateId: emailTemplateId,
context: emailContext,
}); });
} }
@@ -2954,6 +3089,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
message: emailContent.message, message: emailContent.message,
emailSubject: isCreation ? "TurnosXpress :: Alta de turno" : "TurnosXpress :: Recordatorio", emailSubject: isCreation ? "TurnosXpress :: Alta de turno" : "TurnosXpress :: Recordatorio",
emailMessage: emailContent.message, emailMessage: emailContent.message,
emailTemplateId: emailContent.emailTemplateId,
emailContext: emailContent.emailContext,
wapMessage: wapContent.message, wapMessage: wapContent.message,
systemSubject, systemSubject,
systemMessage: emailContent.message, systemMessage: emailContent.message,
@@ -157,7 +157,10 @@ export class MetricsAdapterMongoose implements IMetricsAdapter {
if (!metrics.repeatsCount) { if (!metrics.repeatsCount) {
metrics.repeatsCount = 0; 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(); await metrics.save();
} }
} }
@@ -37,6 +37,7 @@ export interface IMetricsAdapter {
addService(data: MetricsParams): Promise<void>; addService(data: MetricsParams): Promise<void>;
addAppointment(data: MetricsParams): Promise<void>; addAppointment(data: MetricsParams): Promise<void>;
addClient(data: MetricsParams): Promise<void>; addClient(data: MetricsParams): Promise<void>;
addRepeat(data: MetricsParams): Promise<void>;
} }
export interface IMetricsManager { export interface IMetricsManager {
@@ -50,6 +51,7 @@ export interface IMetricsManager {
reserveAppointment(data: MetricsParams): Promise<boolean>; reserveAppointment(data: MetricsParams): Promise<boolean>;
releaseAppointment(data: MetricsParams): Promise<void>; releaseAppointment(data: MetricsParams): Promise<void>;
addClient(data: MetricsParams): Promise<void>; addClient(data: MetricsParams): Promise<void>;
addRepeat(data: MetricsParams): Promise<void>;
canAddOrganization(userId: string): Promise<boolean>; canAddOrganization(userId: string): Promise<boolean>;
canAddEmployee(userId: string): Promise<boolean>; canAddEmployee(userId: string): Promise<boolean>;
canAddService(userId: string): Promise<boolean>; canAddService(userId: string): Promise<boolean>;
@@ -23,6 +23,8 @@ export interface NotificationJobPayload {
message?: string; message?: string;
emailSubject?: string; emailSubject?: string;
emailMessage?: string; emailMessage?: string;
emailTemplateId?: string;
emailContext?: Record<string, unknown>;
wapMessage?: string; wapMessage?: string;
systemSubject?: string; systemSubject?: string;
systemMessage?: string; systemMessage?: string;
@@ -3,13 +3,37 @@ import { INotificationsAdapter, NotificationDataEmail } from "./Notifications.In
export class NotificationsDonWebAdapter implements INotificationsAdapter<NotificationDataEmail> { export class NotificationsDonWebAdapter implements INotificationsAdapter<NotificationDataEmail> {
async send(data: NotificationDataEmail): Promise<void> { 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", from: "norepply@turnosxpress.com.ar",
to: data.email, 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 = { const apiConfig = {
method: "post", method: "post",
maxBodyLength: Infinity, maxBodyLength: Infinity,
@@ -6,8 +6,11 @@ export enum NOTIFICATION_TYPES {
export type NotificationDataEmail = { export type NotificationDataEmail = {
email: string; email: string;
subject: string; subject?: string;
message: string; message?: string;
templateId?: string;
context?: Record<string, unknown>;
substitutions?: Record<string, string | number | boolean>;
}; };
export type NotificationDataWap = { export type NotificationDataWap = {
+1
View File
@@ -212,6 +212,7 @@ class RepeatsManager implements IRepeatsManager {
} }
await this.repeats.delete(data.repeatId); await this.repeats.delete(data.repeatId);
await MetricsList.addRepeat({ userId: String(companyCheck.ownerId), quantity: -1 });
} }
public async deleteRepeatsByCompany(data: DeleteRepeatsByCompanyParams): Promise<void> { public async deleteRepeatsByCompany(data: DeleteRepeatsByCompanyParams): Promise<void> {
@@ -8,6 +8,34 @@ export type ScheduleItem = {
serviceIds?: string[]; 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 = { export type FindSchedulesParams = {
_id?: string; _id?: string;
companyId?: string; companyId?: string;
@@ -100,6 +128,7 @@ export interface ISchedulesManager {
findAllSchedulesByCollaborator(data: FindSchedulesParams): Promise<CollaboratorSchedulesView>; findAllSchedulesByCollaborator(data: FindSchedulesParams): Promise<CollaboratorSchedulesView>;
delete(data: DeleteScheduleParams): Promise<void>; delete(data: DeleteScheduleParams): Promise<void>;
isAvailable(data: AvailableSchedulesParams): Promise<boolean>; isAvailable(data: AvailableSchedulesParams): Promise<boolean>;
applyScheduleChanges(data: ScheduleApplyParams): Promise<ScheduleApplyResult>;
deleteSchedulesByCompany(data: DeleteScheulesByCompanyParams): Promise<void>; deleteSchedulesByCompany(data: DeleteScheulesByCompanyParams): Promise<void>;
deleteSchedulesByEmployee(data: DeleteSchedulesByEmployeeParams): Promise<void>; deleteSchedulesByEmployee(data: DeleteSchedulesByEmployeeParams): Promise<void>;
} }
+169
View File
@@ -13,6 +13,9 @@ import {
DeleteScheulesByCompanyParams, DeleteScheulesByCompanyParams,
DeleteSchedulesByEmployeeParams, DeleteSchedulesByEmployeeParams,
ScheduleItem, ScheduleItem,
ScheduleApplyParams,
ScheduleApplyResult,
ScheduleConflictDetail,
} from "./Schedules.Interface"; } from "./Schedules.Interface";
import { EmployeeRoles } from "../Employees/Employees.Interface"; import { EmployeeRoles } from "../Employees/Employees.Interface";
import UsersManager from "../Users/Users"; import UsersManager from "../Users/Users";
@@ -24,6 +27,9 @@ import { isNull } from "../../helpers/IsNull";
import getAvatar from "../../helpers/getAvatar"; import getAvatar from "../../helpers/getAvatar";
import { NotificationsManager } from "../Notifications/Notifications"; import { NotificationsManager } from "../Notifications/Notifications";
import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface"; 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 { class SchedulesManager implements ISchedulesManager {
schedules: SchedulesAdapterMongoose; schedules: SchedulesAdapterMongoose;
@@ -173,6 +179,169 @@ class SchedulesManager implements ISchedulesManager {
return await this.schedules.find(filters); 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 { private isScheduleAvailableForService(schedule: ScheduleItem, serviceId?: string): boolean {
if (!serviceId || !schedule.serviceScope || schedule.serviceScope === "all") { if (!serviceId || !schedule.serviceScope || schedule.serviceScope === "all") {
return true; return true;
@@ -32,6 +32,12 @@ export class SchedulesDisabledAdapterMongoose implements ISchedulesDisabledAdapt
await this.schedulesDisabledList.deleteOne({ _id: id }).exec(); 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( public async findOne(
filters: Omit<FindSchedulesDisabledParams, "sessionUser"> filters: Omit<FindSchedulesDisabledParams, "sessionUser">
): Promise<ISchedulesDisabledDocument | null> { ): Promise<ISchedulesDisabledDocument | null> {
@@ -90,6 +90,7 @@ export interface SchedulesDisabledByCollaboratorView {
export interface ISchedulesDisabledAdapter { export interface ISchedulesDisabledAdapter {
create(data: CreateSchedulesDisabledParams): Promise<ISchedulesDisabled>; create(data: CreateSchedulesDisabledParams): Promise<ISchedulesDisabled>;
delete(id: string): Promise<void>; delete(id: string): Promise<void>;
find(filters: Omit<FindSchedulesDisabledParams, "sessionUser">): Promise<ISchedulesDisabled[]>;
findOne(filters: FindSchedulesDisabledParams): Promise<ISchedulesDisabled | null>; findOne(filters: FindSchedulesDisabledParams): Promise<ISchedulesDisabled | null>;
} }
@@ -98,6 +99,7 @@ export interface ISchedulesDisabledManager {
createSchedulesDisabled(data: CreateSchedulesDisabledParams): Promise<ISchedulesDisabled>; createSchedulesDisabled(data: CreateSchedulesDisabledParams): Promise<ISchedulesDisabled>;
disableSchedule(data: DisableScheduleParams): Promise<void>; disableSchedule(data: DisableScheduleParams): Promise<void>;
enableSchedule(data: DisableScheduleParams): Promise<void>; enableSchedule(data: DisableScheduleParams): Promise<void>;
find(data: FindSchedulesDisabledParams): Promise<ISchedulesDisabled[]>;
findOne(data: FindSchedulesDisabledParams): Promise<ISchedulesDisabled | null>; findOne(data: FindSchedulesDisabledParams): Promise<ISchedulesDisabled | null>;
findSchedulesDisabledByCollaborator( findSchedulesDisabledByCollaborator(
data: FindSchedulesDisabledParams data: FindSchedulesDisabledParams
@@ -271,6 +271,17 @@ class SchedulesDisabledManager implements ISchedulesDisabledManager {
return await this.schedulesDisabled.findOne(data); 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> { public async delete(data: DeleteSchedulesDisabledParams): Promise<void> {
await validateSessionUser({ sessionUser: data.sessionUser }); await validateSessionUser({ sessionUser: data.sessionUser });
+54 -6
View File
@@ -351,6 +351,28 @@ class UsersManager implements IUsersManager {
.substring(0, 6); .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> { public async signUp(data: SignUpParams): Promise<IUser> {
// Destructure email and password from the data object // Destructure email and password from the data object
const { const {
@@ -406,12 +428,13 @@ class UsersManager implements IUsersManager {
const newUser = await this.users.create(newUserData); const newUser = await this.users.create(newUserData);
const signUpUrl = "https://turnosxpress.com.ar/landing/login/verification";
await NotificationsManager.sendEmail({ await NotificationsManager.sendEmail({
email: cleanEmail(email), email: cleanEmail(email),
subject: "TurnosXpress :: Bienvenido/a", templateId: "6a85dd644ea98ccfcd05bdc7",
message: `Hola ${firstName}, Gracias por registrarte en turnosXpress. Primero debes activar tu cuenta ingresando en ${signUpUrl} . Tu código de verificación es: ${verificationCode}`, context: {
username: firstName || cleanEmail(email),
verificationcode: verificationCode,
},
}); });
return newUser; return newUser;
@@ -459,8 +482,11 @@ class UsersManager implements IUsersManager {
await NotificationsManager.sendEmail({ await NotificationsManager.sendEmail({
email: cleanEmail(data.email), email: cleanEmail(data.email),
subject: "TurnosXpress :: Recuperación de cuenta", templateId: "6a85c7ad96475f2825015277",
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`, context: {
username: user.firstName || cleanEmail(data.email),
verificationcode: code,
},
}); });
return; return;
@@ -515,10 +541,16 @@ class UsersManager implements IUsersManager {
throw new Error("El código de verificación es incorrecto"); throw new Error("El código de verificación es incorrecto");
} }
const wasVerificated = Boolean(userTest.verificated);
userTest.verificated = true; userTest.verificated = true;
await userTest.save(); await userTest.save();
if (!wasVerificated) {
await this.sendWelcomeEmail(userTest.email, userTest.firstName);
}
try { try {
const freePlan = await PlansList.plans.findOne({ price: 0 }); const freePlan = await PlansList.plans.findOne({ price: 0 });
if (freePlan) { if (freePlan) {
@@ -724,11 +756,17 @@ class UsersManager implements IUsersManager {
}); });
if (userCheck) { if (userCheck) {
const wasVerificated = Boolean(userCheck.verificated);
userCheck.external_id = googleUserId; userCheck.external_id = googleUserId;
userCheck.external_service = "google"; userCheck.external_service = "google";
userCheck.verificated = true; userCheck.verificated = true;
await userCheck.save(); await userCheck.save();
if (!wasVerificated) {
await this.sendWelcomeEmail(userCheck.email, userCheck.firstName);
}
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({ const subscription = await PlanSubscriptionsList.getSubscriptionByUser({
sessionUser: userCheck.id, sessionUser: userCheck.id,
}); });
@@ -770,6 +808,8 @@ class UsersManager implements IUsersManager {
throw new Error("Error creating user"); throw new Error("Error creating user");
} }
await this.sendWelcomeEmail(newUser.email, newUser.firstName);
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({ const subscription = await PlanSubscriptionsList.getSubscriptionByUser({
sessionUser: newUser.id, sessionUser: newUser.id,
}); });
@@ -821,11 +861,17 @@ class UsersManager implements IUsersManager {
}); });
if (userCheck) { if (userCheck) {
const wasVerificated = Boolean(userCheck.verificated);
userCheck.external_id = googleData.id; userCheck.external_id = googleData.id;
userCheck.external_service = "google"; userCheck.external_service = "google";
userCheck.verificated = true; userCheck.verificated = true;
await userCheck.save(); await userCheck.save();
if (!wasVerificated) {
await this.sendWelcomeEmail(userCheck.email, userCheck.firstName);
}
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({ const subscription = await PlanSubscriptionsList.getSubscriptionByUser({
sessionUser: userCheck.id, sessionUser: userCheck.id,
}); });
@@ -867,6 +913,8 @@ class UsersManager implements IUsersManager {
throw new Error("Error creating user"); throw new Error("Error creating user");
} }
await this.sendWelcomeEmail(newUser.email, newUser.firstName);
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({ const subscription = await PlanSubscriptionsList.getSubscriptionByUser({
sessionUser: newUser.id, 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);
}
}
@@ -6,6 +6,9 @@ import dayjs from "dayjs";
import PlanPaymentsList from "../../Models/PlanPayments/PlanPayments"; import PlanPaymentsList from "../../Models/PlanPayments/PlanPayments";
import { IPlanSuscriptionDocument } from "../../Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose"; import { IPlanSuscriptionDocument } from "../../Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose";
import { VerifyPendingPlanPaymentResponse } from "../../Models/PlanSubscriptions/PlanSubscriptions.interface"; 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 = { type MercadoPagoPaymentData = {
id: string | number; id: string | number;
@@ -41,6 +44,66 @@ type MercadoPagoPaymentVerification = {
const REJECTED_PAYMENT_STATUSES = ["rejected", "cancelled", "failed"]; const REJECTED_PAYMENT_STATUSES = ["rejected", "cancelled", "failed"];
export class MercadoPagoWebhookService { 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> { public async handleWebhook(body: any): Promise<void> {
await connect(`${process.env.DATABASE_CONNECTION}`); await connect(`${process.env.DATABASE_CONNECTION}`);
@@ -300,6 +363,9 @@ export class MercadoPagoWebhookService {
const periodStart = isExtensionPayment && dayjs(subscription.endDate).isAfter(approvedAt) const periodStart = isExtensionPayment && dayjs(subscription.endDate).isAfter(approvedAt)
? subscription.endDate ? subscription.endDate
: approvedAt; : approvedAt;
const transactionId = String(paymentData.id);
const existingPayments = await PlanPaymentsList.find({ transactionId });
const shouldSendApprovedPaymentEmail = existingPayments.length === 0;
if (isUpgradePayment && subscription.pendingPaymentPlanId) { if (isUpgradePayment && subscription.pendingPaymentPlanId) {
subscription.planId = subscription.pendingPaymentPlanId; subscription.planId = subscription.pendingPaymentPlanId;
@@ -342,8 +408,12 @@ export class MercadoPagoWebhookService {
paymentDate: approvedAt, paymentDate: approvedAt,
paymentMethod: paymentData.payment_method_id || paymentData.payment_type_id || "mercadopago", paymentMethod: paymentData.payment_method_id || paymentData.payment_type_id || "mercadopago",
status: "completed", status: "completed",
transactionId: String(paymentData.id), transactionId,
}); });
if (shouldSendApprovedPaymentEmail) {
await this.sendApprovedPaidPlanEmail(subscription, paymentData, userId, approvedAt, periodStart);
}
} }
private async applyRejectedPayment( private async applyRejectedPayment(
@@ -7,8 +7,10 @@ import { ApiVoidResult } from "../../Models/Api.VoidResult.type";
interface SendEmailByContentParams { interface SendEmailByContentParams {
systemToken: string; systemToken: string;
email: string; email: string;
subject: string; subject?: string;
message: string; message?: string;
templateId?: string;
context?: Record<string, unknown>;
} }
interface SendWapByContentParams { interface SendWapByContentParams {
@@ -29,6 +31,8 @@ export class SendEmailByContentController extends Controller {
email: requestBody.email, email: requestBody.email,
subject: requestBody.subject, subject: requestBody.subject,
message: requestBody.message, message: requestBody.message,
templateId: requestBody.templateId,
context: requestBody.context,
}); });
this.setStatus(200); this.setStatus(200);
@@ -10,6 +10,8 @@ import {
PaginateSchedulesResults, PaginateSchedulesResults,
FindSchedulesParams, FindSchedulesParams,
CollaboratorSchedulesView, CollaboratorSchedulesView,
ScheduleApplyParams,
ScheduleApplyResult,
} from "../../Models/Schedules/Schedules.Interface"; } from "../../Models/Schedules/Schedules.Interface";
import { authenticateMiddleware } from "../../middleware/authentication"; import { authenticateMiddleware } from "../../middleware/authentication";
import { ApiVoidResult } from "../../Models/Api.VoidResult.type"; import { ApiVoidResult } from "../../Models/Api.VoidResult.type";
@@ -36,6 +38,7 @@ export class CreateScheduleController extends Controller {
} }
@Route("schedules/get-all-by-collaborator") @Route("schedules/get-all-by-collaborator")
@Middlewares(authenticateMiddleware)
export class GetAllSchedulsByCollaboratoreController extends Controller { export class GetAllSchedulsByCollaboratoreController extends Controller {
@Response<ApiValidationError>(500, "Ha ocurrido un error") @Response<ApiValidationError>(500, "Ha ocurrido un error")
@SuccessResponse(200, "El empleado fue creado con exito") @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") @Route("schedules/delete")
@Middlewares(authenticateMiddleware) @Middlewares(authenticateMiddleware)
export class DeleteScheduleController extends Controller { export class DeleteScheduleController extends Controller {
@@ -9,6 +9,8 @@ import {
PaginateSchedulesResults, PaginateSchedulesResults,
CollaboratorSchedulesView, CollaboratorSchedulesView,
FindSchedulesParams, FindSchedulesParams,
ScheduleApplyParams,
ScheduleApplyResult,
} from "../../Models/Schedules/Schedules.Interface"; } from "../../Models/Schedules/Schedules.Interface";
export class SchedulesService { export class SchedulesService {
@@ -26,6 +28,12 @@ export class SchedulesService {
await ScheduleList.updateSchedule(data); 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> { public async deleteSchedule(data: DeleteScheduleParams): Promise<void> {
await connect(`${process.env.DATABASE_CONNECTION}`); await connect(`${process.env.DATABASE_CONNECTION}`);
await ScheduleList.delete(data); await ScheduleList.delete(data);
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.
@@ -204,6 +204,9 @@ export default function OrganizationProfile() {
const [currentDate, setCurrentDate] = useState<dayjs.Dayjs>(appointmentDate); const [currentDate, setCurrentDate] = useState<dayjs.Dayjs>(appointmentDate);
const currentDateRef = useRef(currentDate);
currentDateRef.current = currentDate;
const [calendarEventsCount, setCalendarEventsCount] = useState<number>(0); const [calendarEventsCount, setCalendarEventsCount] = useState<number>(0);
const [calendarFrom, setCalendarFrom] = useState<number>(8); const [calendarFrom, setCalendarFrom] = useState<number>(8);
const [calendarTo, setCalendarTo] = useState<number>(23); const [calendarTo, setCalendarTo] = useState<number>(23);
@@ -421,32 +424,35 @@ export default function OrganizationProfile() {
const handleMoveAppointment = async (newDay: dayjs.Dayjs) => { const handleMoveAppointment = async (newDay: dayjs.Dayjs) => {
if (!copiedAppointment || !copiedAppointment.appointmentId) return; const moveAppointment = useClipboardStore.getState().copiedAppointment;
if (!moveAppointment || !moveAppointment.appointmentId) return;
timePicker.hide(); timePicker.hide();
eventHandler.setEventType(EVENT_TYPES.LOADING); eventHandler.setEventType(EVENT_TYPES.LOADING);
const newStart = dayjs(currentDate).hour(newDay.hour()).minute(newDay.minute()).second(0).millisecond(0).toISOString(); const newStart = currentDateRef.current.hour(newDay.hour()).minute(newDay.minute()).second(0).millisecond(0).toISOString();
const oldStart = dayjs(copiedAppointment.startTime); const oldStart = dayjs(moveAppointment.startTime);
const oldEnd = dayjs(copiedAppointment.endTime); const oldEnd = dayjs(moveAppointment.endTime);
const durationMin = oldEnd.diff(oldStart, 'minute'); const durationMin = oldEnd.diff(oldStart, 'minute');
const newEnd = dayjs(newStart).add(durationMin, 'minute').toISOString(); const newEnd = dayjs(newStart).add(durationMin, 'minute').toISOString();
try { try {
await ApiRequest.post("/appointments/move-appointment-force", { await ApiRequest.post("/appointments/move-appointment-force", {
id: copiedAppointment.appointmentId, id: moveAppointment.appointmentId,
companyId: id, companyId: id,
newStart: newStart, newStart: newStart,
newEnd: newEnd, newEnd: newEnd,
sessionUser: SessionInfo.userId, sessionUser: SessionInfo.userId,
}); });
alert.showSuccess("Turno movido con éxito"); alert.showSuccess("Turno movido con éxito");
setCopiedAppointment(null); useClipboardStore.getState().setCopiedAppointment(null);
loadCalendarData(); loadCalendarData();
} catch (error: unknown) { } catch (error: unknown) {
console.error(error); console.error(error);
alert.showError("Error al mover el turno"); alert.showError("Error al mover el turno");
} finally { } finally {
eventHandler.setEventType(copiedAppointment ? EVENT_TYPES.SLEEP : EVENT_TYPES.NEED_ADD); eventHandler.setEventType(
useClipboardStore.getState().copiedAppointment ? EVENT_TYPES.SLEEP : EVENT_TYPES.NEED_ADD
);
} }
}; };
@@ -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) { } catch (error) {
console.error("Error generating invoice PDF:", error); console.error("Error generating invoice PDF:", error);
alert.showError("No pudimos generar el PDF de la factura."); 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 EventBusyIcon from '@mui/icons-material/EventBusy';
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
import GroupAddIcon from '@mui/icons-material/GroupAdd'; import GroupAddIcon from '@mui/icons-material/GroupAdd';
import ManageSearchIcon from '@mui/icons-material/ManageSearch';
import SearchIcon from "@mui/icons-material/Search"; import SearchIcon from "@mui/icons-material/Search";
import BlockIcon from "@mui/icons-material/Block"; import BlockIcon from "@mui/icons-material/Block";
import EventRepeatIcon from "@mui/icons-material/EventRepeat"; 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 WhatsAppIcon from '@mui/icons-material/WhatsApp';
import QrCode2Icon from '@mui/icons-material/QrCode2'; import QrCode2Icon from '@mui/icons-material/QrCode2';
import ReceiptLongIcon from '@mui/icons-material/ReceiptLong'; 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 API from "@services/Api.Service";
import { ReservationPeriodsSummary } from "@models/SchedulesEnabled.type"; import { ReservationPeriodsSummary } from "@models/SchedulesEnabled.type";
@@ -134,6 +137,24 @@ const AVAILABLE_FLOWS: FlowDefinition[] = [
keywords: ["nuevo servicio", "prestacion", "ofrecer", "crear servicio", "precio", "duracion"], keywords: ["nuevo servicio", "prestacion", "ofrecer", "crear servicio", "precio", "duracion"],
icon: AddCircleOutlineIcon 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", id: "update-schedule",
flowId: "update-schedule", flowId: "update-schedule",
@@ -178,6 +199,15 @@ const AVAILABLE_FLOWS: FlowDefinition[] = [
category: "Colaboradores", category: "Colaboradores",
keywords: ["invitar", "colaborador", "profesional", "empleado", "staff", "equipo"], keywords: ["invitar", "colaborador", "profesional", "empleado", "staff", "equipo"],
icon: GroupAddIcon 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); transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(128, 0, 32, 0.3); 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 { .pressEnterText {
font-size: 13px; font-size: 13px;
color: #6b7280; color: #6b7280;
@@ -34,7 +34,7 @@ export default function QuestionCard({
<div className={classNames(style.cardContainer, style.animateEnter)}> <div className={classNames(style.cardContainer, style.animateEnter)}>
<div className={style.contentWrapper}> <div className={style.contentWrapper}>
{topAccessory && <div className={style.topAccessory}>{topAccessory}</div>} {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>} {description && <p className={style.description}>{description}</p>}
<div className={style.inputArea}> <div className={style.inputArea}>
@@ -14,6 +14,7 @@ dayjs.locale("es");
import { NotificationChannel, ReminderRule } from "@models/NotificationPreferences.model"; import { NotificationChannel, ReminderRule } from "@models/NotificationPreferences.model";
import { OrganizationClientView } from "@models/Clients.model"; import { OrganizationClientView } from "@models/Clients.model";
import { ReservationPeriodDuration, ReservationPeriodsSummary } from "@models/SchedulesEnabled.type"; import { ReservationPeriodDuration, ReservationPeriodsSummary } from "@models/SchedulesEnabled.type";
import { CompanyServiceView, SERVICE_PUBLISHED_STATUS } from "@models/Service.model";
import OnboardingOrgFlow from "./flows/OnboardingOrgFlow"; import OnboardingOrgFlow from "./flows/OnboardingOrgFlow";
import OnboardingServiceFlow from "./flows/OnboardingServiceFlow"; import OnboardingServiceFlow from "./flows/OnboardingServiceFlow";
import OnboardingScheduleFlow from "./flows/OnboardingScheduleFlow"; import OnboardingScheduleFlow from "./flows/OnboardingScheduleFlow";
@@ -23,6 +24,8 @@ import UpdateScheduleFlow from "./flows/UpdateScheduleFlow";
import OverrideScheduleFlow from "./flows/OverrideScheduleFlow"; import OverrideScheduleFlow from "./flows/OverrideScheduleFlow";
import DisableScheduleFlow from "./flows/DisableScheduleFlow"; import DisableScheduleFlow from "./flows/DisableScheduleFlow";
import PublicVisibilityFlow from "./flows/PublicVisibilityFlow"; import PublicVisibilityFlow from "./flows/PublicVisibilityFlow";
import ServicePublicationFlow from "./flows/ServicePublicationFlow";
import CollaboratorScheduleSummaryFlow, { CollaboratorScheduleDetails } from "./flows/CollaboratorScheduleSummaryFlow";
import NotificationsFlow from "./flows/NotificationsFlow"; import NotificationsFlow from "./flows/NotificationsFlow";
import NewCollaboratorFlow from "./flows/NewCollaboratorFlow"; import NewCollaboratorFlow from "./flows/NewCollaboratorFlow";
import SetupCollaboratorFlow from "./flows/SetupCollaboratorFlow"; import SetupCollaboratorFlow from "./flows/SetupCollaboratorFlow";
@@ -31,6 +34,8 @@ import ArcaOnboardingFlow from "./flows/ArcaOnboardingFlow";
import ArcaTestConnectionFlow, { ArcaWsaaTestResult, ArcaWsfeDiagnosticsResult } from "./flows/ArcaTestConnectionFlow"; import ArcaTestConnectionFlow, { ArcaWsaaTestResult, ArcaWsfeDiagnosticsResult } from "./flows/ArcaTestConnectionFlow";
import SuccessScreen from "./components/SuccessScreen"; import SuccessScreen from "./components/SuccessScreen";
import OrganizationPicker from "./components/OrganizationPicker"; import OrganizationPicker from "./components/OrganizationPicker";
import QuestionCard from "../QuestionCard/QuestionCard";
import { getCollaboratorLabel } from "./components/CollaboratorPicker";
import { reservationPeriodOptions } from "./constants/reservationPeriods"; import { reservationPeriodOptions } from "./constants/reservationPeriods";
import { NOTIF_CHANNELS, NOTIF_REMINDER_PRESETS } from "./constants/notifications"; import { NOTIF_CHANNELS, NOTIF_REMINDER_PRESETS } from "./constants/notifications";
import { getEmployeeId, getEmployeeUserId, getTargetEmployees } from "./helpers/employees"; 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 [serviceLimit, setServiceLimit] = useState<number>(1);
const [serviceImage, setServiceImage] = useState<File | null>(null); const [serviceImage, setServiceImage] = useState<File | null>(null);
const [createdServiceId, setCreatedServiceId] = useState<string | 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 [employeeId, setEmployeeId] = useState<string | null>(null);
const [doesProvideServices, setDoesProvideServices] = useState<boolean | null>(null); const [doesProvideServices, setDoesProvideServices] = useState<boolean | null>(null);
// State for Schedule Flow // State for Schedule Flow
const [workingDaysMode, setWorkingDaysMode] = useState<string>(""); // "mon-fri", "mon-sat", "mon-sun", "custom" 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 [openTime, setOpenTime] = useState<string>("09:00");
const [closeTime, setCloseTime] = useState<string>("18:00"); const [closeTime, setCloseTime] = useState<string>("18:00");
const [hasBreak, setHasBreak] = useState<boolean | null>(null); 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 [isSuccess, setIsSuccess] = useState<boolean>(false);
const [isAddingAnotherService, setIsAddingAnotherService] = 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 [headerImage, setHeaderImage] = useState<File | null>(null);
const [headerColor, setHeaderColor] = useState<string>("#250525"); const [headerColor, setHeaderColor] = useState<string>("#250525");
@@ -133,6 +145,13 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
const [servicesShowPublicScores, setServicesShowPublicScores] = useState(true); const [servicesShowPublicScores, setServicesShowPublicScores] = useState(true);
const [servicesShowPublicOpinions, setServicesShowPublicOpinions] = useState(true); const [servicesShowPublicOpinions, setServicesShowPublicOpinions] = useState(true);
const [publicLinkCopied, setPublicLinkCopied] = useState(false); 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 // State for Notifications Flow
const [notificationsScope, setNotificationsScope] = useState<"org" | "client" | null>(null); 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 // State for standalone update schedule flow
const [scheduleScope, setScheduleScope] = useState<"me" | "specific" | "all" | null>(null); const [scheduleScope, setScheduleScope] = useState<"me" | "specific" | "all" | null>(null);
const [targetEmployeeId, setTargetEmployeeId] = useState<string | null>(null); const [targetEmployeeId, setTargetEmployeeId] = useState<string | null>(null);
const [selectedScheduleEmployeeIds, setSelectedScheduleEmployeeIds] = useState<string[]>([]);
const [allEmployees, setAllEmployees] = useState<any[]>([]); const [allEmployees, setAllEmployees] = useState<any[]>([]);
const [overrideDate, setOverrideDate] = useState<string>(""); const [overrideDate, setOverrideDate] = useState<string>("");
const [allOrganizations, setAllOrganizations] = useState<any[]>([]); 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 [reservationPeriodSummary, setReservationPeriodSummary] = useState<ReservationPeriodsSummary | null>(null);
const [reservationPeriodEmployeesLoading, setReservationPeriodEmployeesLoading] = useState(false); const [reservationPeriodEmployeesLoading, setReservationPeriodEmployeesLoading] = useState(false);
const [reservationPeriodEmployeesError, setReservationPeriodEmployeesError] = useState<string | null>(null); 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 // State for New Collaborator Flow
const [inviteEmail, setInviteEmail] = useState(""); const [inviteEmail, setInviteEmail] = useState("");
@@ -208,6 +233,49 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
return !employee.removed && (isCurrentUser || employee.guestOk === true); 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) => { const loadOrganizationContext = (org: any) => {
setCreatedCompanyId(org.id || org._id); setCreatedCompanyId(org.id || org._id);
setSelectedOrganization(org); setSelectedOrganization(org);
@@ -299,7 +367,24 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
}).catch(console.error); }).catch(console.error);
} }
}).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") { if (flowId === "open-reservation-periods") {
setCurrentStepIndex(0); setCurrentStepIndex(0);
setReservationPeriodScope(null); 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 }) API.post<any[]>("employees/get-by-company", { companyId: org.id || org._id })
.then(empRes => { .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((employee: any) => !employee.removed)
: (empRes || []).filter(canManageEmployeeSchedules); : (empRes || []).filter(canManageEmployeeSchedules);
setAllEmployees(availableEmployees); setAllEmployees(availableEmployees);
@@ -360,6 +445,9 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
setServicesShowPublicScores(services.every(service => service.showPublicScores !== false)); setServicesShowPublicScores(services.every(service => service.showPublicScores !== false));
setServicesShowPublicOpinions(services.every(service => service.showPublicOpinions !== false)); setServicesShowPublicOpinions(services.every(service => service.showPublicOpinions !== false));
}).catch(console.error); }).catch(console.error);
} else if (flowId === "publish-services" || flowId === "unpublish-services") {
setCurrentStepIndex(0);
loadPublicationServices(org.id || org._id);
} else if (flowId === "public-link") { } else if (flowId === "public-link") {
setCurrentStepIndex(0); setCurrentStepIndex(0);
setPublicLinkCopied(false); setPublicLinkCopied(false);
@@ -448,7 +536,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
}; };
React.useEffect(() => { 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); setIsResuming(true);
API.post<any[]>("companies/get-by-user", { sessionUser: SessionInfo.userId }) API.post<any[]>("companies/get-by-user", { sessionUser: SessionInfo.userId })
.then(response => { .then(response => {
@@ -469,7 +557,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
.finally(() => setIsResuming(false)); .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); setCurrentStepIndex(0);
} }
}, [flowId, SessionInfo.metrics.organizationsCount, SessionInfo.userId, router]); }, [flowId, SessionInfo.metrics.organizationsCount, SessionInfo.userId, router]);
@@ -1060,15 +1148,55 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
); );
const handleCreateService = async () => { 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); setIsCreating(true);
try { try {
const service = await API.post<any>("services/create", { const service = await API.post<any>("services/create", {
companyId: createdCompanyId, companyId,
name: serviceName, name: serviceName,
description: serviceDescription, description: serviceDescription,
length: serviceLength, length: serviceLength,
originalPrice: Number(servicePrice) || 0, originalPrice: parsedPrice,
limit: serviceLimit, limit: serviceLimit,
sessionUser: SessionInfo.userId sessionUser: SessionInfo.userId
}); });
@@ -1080,7 +1208,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
handleNext(); // Move to Step 4 (Service Image) handleNext(); // Move to Step 4 (Service Image)
} catch (error: any) { } catch (error: any) {
console.error("Error creando servicio:", error); 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 { } finally {
setIsCreating(false); setIsCreating(false);
} }
@@ -1098,6 +1226,8 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
.then(res => { .then(res => {
setPreviewServices(res || []); setPreviewServices(res || []);
}); });
} else if (isStandaloneAction && action === "new-service") {
setCurrentStepIndex(5);
} else if (isStandaloneAction) { } else if (isStandaloneAction) {
handleActionSuccess(); handleActionSuccess();
} else { } else {
@@ -1123,7 +1253,139 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
finalizeService(); finalizeService();
} catch (error: any) { } catch (error: any) {
console.error("Error subiendo imagen del servicio:", error); 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 { } finally {
setIsCreating(false); setIsCreating(false);
} }
@@ -1147,9 +1409,22 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
setServicePrice={setServicePrice} setServicePrice={setServicePrice}
serviceLimit={serviceLimit} serviceLimit={serviceLimit}
setServiceLimit={setServiceLimit} setServiceLimit={setServiceLimit}
serviceCreateError={serviceCreateError}
handleUploadServiceImage={handleUploadServiceImage} handleUploadServiceImage={handleUploadServiceImage}
serviceImage={serviceImage} serviceImage={serviceImage}
setServiceImage={setServiceImage} 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; if (!createdCompanyId || isCreating || !scheduleScope) return;
setIsCreating(true); setIsCreating(true);
setScheduleConflictMessage("");
setPendingScheduleOverwrite(false);
try { try {
const targetEmployees = getTargetEmployees(allEmployees, scheduleScope, targetEmployeeId, SessionInfo.userId); const targetEmployees = getUpdateScheduleTargetEmployees();
if (targetEmployees.length === 0) { if (targetEmployees.length === 0) {
alert("No se encontró ningún colaborador seleccionado."); setScheduleConflictMessage("Seleccioná al menos un colaborador para actualizar sus horarios.");
setIsCreating(false); setIsCreating(false);
return; 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) { if (weekDays.length === 0) {
const empId = getEmployeeId(emp); throw new Error("Seleccioná al menos un día de trabajo.");
if (!empId) continue; }
for (let i = 0; i <= 6; i++) { if (effectiveServiceScope === "specific" && scheduleServiceIds.length === 0) {
const isWorkingDay = workingDays.includes(i); throw new Error("Seleccioná al menos un servicio.");
const schedules = isWorkingDay ? buildScheduleBlocks(openTime, closeTime, hasBreak, breakStart, breakEnd) : []; }
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, companyId: createdCompanyId,
employeeId: empId, employeeIds,
weekDay: i, weekDays,
schedules: schedules, schedules,
sessionUser: SessionInfo.userId sessionUser: SessionInfo.userId,
conflictStrategy
}); });
if (result?.valid === false && !result?.applied) {
setScheduleConflictMessage(formatScheduleConflicts(result.conflicts) || result.message);
setPendingScheduleOverwrite(true);
return;
} }
}
handleActionSuccess(); handleActionSuccess();
} catch (error: any) { } catch (error: any) {
console.error("Error actualizando horarios:", error); 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 { } finally {
setIsCreating(false); setIsCreating(false);
} }
}; };
const renderUpdateScheduleFlow = () => ( const renderUpdateScheduleFlow = () => {
<UpdateScheduleFlow const scheduleTargetEmployees = getUpdateScheduleTargetEmployees();
const scheduleTargetEmployeeIds = scheduleTargetEmployees.map((employee) => String(getEmployeeId(employee))).filter(Boolean);
const canUseSpecificScheduleServices = scheduleTargetEmployeeIds.length > 0;
return <UpdateScheduleFlow
currentStepIndex={currentStepIndex} currentStepIndex={currentStepIndex}
scheduleScope={scheduleScope} scheduleScope={scheduleScope}
setScheduleScope={setScheduleScope} setScheduleScope={handleSetUpdateScheduleScope}
targetEmployeeId={targetEmployeeId} targetEmployeeId={targetEmployeeId}
setTargetEmployeeId={setTargetEmployeeId} setTargetEmployeeId={setTargetEmployeeId}
selectedScheduleEmployeeIds={selectedScheduleEmployeeIds}
handleToggleScheduleEmployee={handleToggleScheduleEmployee}
allEmployees={allEmployees} allEmployees={allEmployees}
handleNext={handleNext} handleNext={handleNext}
workingDaysMode={workingDaysMode} workingDaysMode={workingDaysMode}
setWorkingDaysMode={setWorkingDaysMode} setWorkingDaysMode={setWorkingDaysMode}
specificWeekDay={specificWeekDay}
setSpecificWeekDay={setSpecificWeekDay}
openTime={openTime} openTime={openTime}
setOpenTime={setOpenTime} setOpenTime={setOpenTime}
closeTime={closeTime} closeTime={closeTime}
@@ -1384,8 +1762,20 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
setBreakEnd={setBreakEnd} setBreakEnd={setBreakEnd}
isCreating={isCreating} isCreating={isCreating}
handleUpdateScheduleSubmit={handleUpdateScheduleSubmit} 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 = () => ( const renderPublicVisibilityFlow = () => (
<PublicVisibilityFlow <PublicVisibilityFlow
isSelectedOrganizationPaidPlan={isSelectedOrganizationPaidPlan} 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 = () => { const getSelectedOrganizationSlug = () => {
return buildSelectedOrganizationSlug(selectedOrganization, orgName); return buildSelectedOrganizationSlug(selectedOrganization, orgName);
}; };
@@ -1905,13 +2387,31 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
/> />
); );
const renderFlow = () => { 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") { if (allOrganizations.length > 1 && !createdCompanyId && action !== "new-org") {
return <OrganizationPicker allOrganizations={allOrganizations} loadOrganizationContext={loadOrganizationContext} />; return <OrganizationPicker allOrganizations={allOrganizations} loadOrganizationContext={loadOrganizationContext} />;
} }
if (flowId === "setup-collaborator") return renderSetupCollaboratorFlow(); if (flowId === "setup-collaborator") return renderSetupCollaboratorFlow();
if (flowId === "new-collaborator") return renderNewCollaboratorFlow(); if (flowId === "new-collaborator") return renderNewCollaboratorFlow();
if (flowId === "collaborator-schedule-summary") return renderCollaboratorScheduleSummaryFlow();
if (flowId === "public-visibility") return renderPublicVisibilityFlow(); if (flowId === "public-visibility") return renderPublicVisibilityFlow();
if (flowId === "publish-services" || flowId === "unpublish-services") return renderServicePublicationFlow();
if (flowId === "public-link") return renderPublicLinkFlow(); if (flowId === "public-link") return renderPublicLinkFlow();
if (flowId === "notifications") return renderNotificationsFlow(); if (flowId === "notifications") return renderNotificationsFlow();
if (flowId === "whatsapp-bot") return renderWhatsAppBotFlow(); 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 ( return (
<div className={style.engineContainer}> <div className={style.engineContainer}>
<div className={style.header}> <div className={style.header}>
@@ -1960,7 +2462,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
<span>Volver</span> <span>Volver</span>
</div> </div>
<div className={style.flowTitle}> <div className={style.flowTitle}>
Configuración de Negocio {!shouldHideFlowTitle && "Configuración de Negocio"}
</div> </div>
<div className={style.progressIndicator}> <div className={style.progressIndicator}>
{/* Placeholder for progress dots or percentage */} {/* 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 === "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 === "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 === "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 === "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 === "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."; 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>
);
}
@@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import QuestionCard from "../../QuestionCard/QuestionCard"; import QuestionCard from "../../QuestionCard/QuestionCard";
import QuestionInput from "../../QuestionCard/QuestionInput"; import QuestionInput from "../../QuestionCard/QuestionInput";
import CollaboratorPicker from "../components/CollaboratorPicker";
type OnboardingServiceFlowProps = { type OnboardingServiceFlowProps = {
heatMapFraction: any; heatMapFraction: any;
@@ -19,15 +20,49 @@ type OnboardingServiceFlowProps = {
setServicePrice: any; setServicePrice: any;
serviceLimit: any; serviceLimit: any;
setServiceLimit: any; setServiceLimit: any;
serviceCreateError: any;
handleUploadServiceImage: any; handleUploadServiceImage: any;
serviceImage: any; serviceImage: any;
setServiceImage: any; setServiceImage: any;
isStandaloneNewService: any;
handleAssignCreatedServiceToMe: any;
handleShowCollaboratorAssignment: any;
handleToggleServiceCollaborator: any;
handleAssignCreatedServiceToCollaborators: any;
handleFinishWithoutAssignment: any;
serviceAssignmentError: any;
serviceAssignmentWarning: any;
serviceAssignmentMode: any;
serviceAssignmentCollaborators: any;
selectedServiceCollaboratorIds: any;
isLoadingServiceCollaborators: any;
}; };
export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps) { export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps) {
const { heatMapFraction, currentStepIndex, handleNext, serviceName, setServiceName, serviceDescription, setServiceDescription, handleKeyDown, serviceLength, setServiceLength, handleCreateService, isCreating, servicePrice, setServicePrice, serviceLimit, setServiceLimit, handleUploadServiceImage, serviceImage, setServiceImage } = props; const { heatMapFraction, currentStepIndex, handleNext, serviceName, setServiceName, serviceDescription, setServiceDescription, handleKeyDown, serviceLength, setServiceLength, handleCreateService, isCreating, servicePrice, setServicePrice, serviceLimit, setServiceLimit, serviceCreateError, handleUploadServiceImage, serviceImage, setServiceImage, isStandaloneNewService, handleAssignCreatedServiceToMe, handleShowCollaboratorAssignment, handleToggleServiceCollaborator, handleAssignCreatedServiceToCollaborators, handleFinishWithoutAssignment, serviceAssignmentError, serviceAssignmentWarning, serviceAssignmentMode, serviceAssignmentCollaborators, selectedServiceCollaboratorIds, isLoadingServiceCollaborators } = props;
const [isCustomDuration, setIsCustomDuration] = React.useState(false);
const [customDurationValue, setCustomDurationValue] = React.useState("");
const fraction = heatMapFraction || 60; const fraction = heatMapFraction || 60;
const durationOptions = [fraction, fraction * 2, fraction * 3, fraction * 4]; const durationOptions = [fraction, fraction * 2, fraction * 3, fraction * 4];
const customDurationMinutes = Number(customDurationValue);
const isValidCustomDuration = Number.isInteger(customDurationMinutes) && customDurationMinutes > 0;
const selectDurationOption = (duration: number) => {
setIsCustomDuration(false);
setCustomDurationValue("");
setServiceLength(duration);
};
const selectCustomDuration = () => {
setIsCustomDuration(true);
setServiceLength(isValidCustomDuration ? customDurationMinutes : null);
};
const updateCustomDuration = (value: string) => {
setCustomDurationValue(value);
const parsedValue = Number(value);
setServiceLength(Number.isInteger(parsedValue) && parsedValue > 0 ? parsedValue : null);
};
return ( return (
<> <>
@@ -75,7 +110,7 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
title="2. Duración del Servicio" title="2. Duración del Servicio"
description={`Elige cuánto tiempo tomará. Las opciones están basadas en tu fracción de agenda (${fraction} min).`} description={`Elige cuánto tiempo tomará. Las opciones están basadas en tu fracción de agenda (${fraction} min).`}
onNext={handleNext} onNext={handleNext}
disableNext={!serviceLength} disableNext={!serviceLength || (isCustomDuration && !isValidCustomDuration)}
> >
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap', width: '100%', justifyContent: 'center' }}> <div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap', width: '100%', justifyContent: 'center' }}>
{durationOptions.map(val => { {durationOptions.map(val => {
@@ -88,15 +123,15 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
return ( return (
<div <div
key={val} key={val}
onClick={() => setServiceLength(val)} onClick={() => selectDurationOption(val)}
style={{ style={{
padding: '12px 24px', padding: '12px 24px',
borderRadius: '12px', borderRadius: '12px',
cursor: 'pointer', cursor: 'pointer',
background: serviceLength === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)', background: !isCustomDuration && serviceLength === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)',
border: `2px solid ${serviceLength === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)'}`, border: `2px solid ${!isCustomDuration && serviceLength === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)'}`,
color: 'white', color: 'white',
fontWeight: serviceLength === val ? 'bold' : 'normal', fontWeight: !isCustomDuration && serviceLength === val ? 'bold' : 'normal',
transition: 'all 0.2s ease', transition: 'all 0.2s ease',
fontSize: '16px' fontSize: '16px'
}} }}
@@ -105,7 +140,44 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
</div> </div>
); );
})} })}
<div
onClick={selectCustomDuration}
style={{
padding: '12px 24px',
borderRadius: '12px',
cursor: 'pointer',
background: isCustomDuration ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)',
border: `2px solid ${isCustomDuration ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)'}`,
color: 'white',
fontWeight: isCustomDuration ? 'bold' : 'normal',
transition: 'all 0.2s ease',
fontSize: '16px'
}}
>
Otro
</div> </div>
</div>
{isCustomDuration && (
<div style={{ width: '100%', marginTop: '16px' }}>
<QuestionInput
type="number"
min="1"
step="1"
placeholder="Duración en minutos"
value={customDurationValue}
onChange={(e) => updateCustomDuration(e.target.value)}
onKeyDown={(e) => {
if (isValidCustomDuration) {
handleKeyDown(e, handleNext);
}
}}
autoFocus
/>
<div style={{ color: 'rgba(255,255,255,0.6)', fontSize: '13px', marginTop: '8px', textAlign: 'center' }}>
Ingresá la duración total del servicio expresada en minutos.
</div>
</div>
)}
</QuestionCard> </QuestionCard>
<QuestionCard <QuestionCard
@@ -113,7 +185,7 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
title="3. Precio y Cupos" title="3. Precio y Cupos"
description="¿Cuánto cuesta y a cuántas personas puedes atender a la vez en este servicio?" description="¿Cuánto cuesta y a cuántas personas puedes atender a la vez en este servicio?"
onNext={handleCreateService} onNext={handleCreateService}
disableNext={isCreating || !servicePrice} disableNext={isCreating}
nextLabel={isCreating ? "Guardando..." : "Crear Servicio"} nextLabel={isCreating ? "Guardando..." : "Crear Servicio"}
> >
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px', width: '100%' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '20px', width: '100%' }}>
@@ -122,7 +194,8 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
<div style={{ position: 'relative' }}> <div style={{ position: 'relative' }}>
<span style={{ position: 'absolute', left: '15px', top: '50%', transform: 'translateY(-50%)', color: 'white', fontSize: '18px' }}>$</span> <span style={{ position: 'absolute', left: '15px', top: '50%', transform: 'translateY(-50%)', color: 'white', fontSize: '18px' }}>$</span>
<input <input
type="number" type="text"
inputMode="decimal"
placeholder="0.00" placeholder="0.00"
value={servicePrice} value={servicePrice}
onChange={(e) => setServicePrice(e.target.value)} onChange={(e) => setServicePrice(e.target.value)}
@@ -163,6 +236,20 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
))} ))}
</div> </div>
</div> </div>
{serviceCreateError && (
<div style={{
padding: '14px 16px',
borderRadius: '12px',
background: 'rgba(255, 99, 132, 0.12)',
border: '1px solid rgba(255, 99, 132, 0.35)',
color: '#fecdd3',
fontSize: '14px',
lineHeight: 1.5,
textAlign: 'center'
}}>
{serviceCreateError}
</div>
)}
</div> </div>
</QuestionCard> </QuestionCard>
@@ -172,7 +259,7 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
description="Sube una foto representativa para que los clientes sepan qué ofreces. (Opcional)" description="Sube una foto representativa para que los clientes sepan qué ofreces. (Opcional)"
onNext={handleUploadServiceImage} onNext={handleUploadServiceImage}
disableNext={isCreating} disableNext={isCreating}
nextLabel={isCreating ? "Subiendo..." : (serviceImage ? "Subir y Finalizar" : "Omitir por ahora")} nextLabel={isCreating ? "Subiendo..." : (serviceImage ? (isStandaloneNewService ? "Subir y continuar" : "Subir y Finalizar") : "Omitir por ahora")}
> >
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px', width: '100%', alignItems: 'center' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '20px', width: '100%', alignItems: 'center' }}>
<div style={{ <div style={{
@@ -216,8 +303,151 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
}} }}
/> />
</div> </div>
{serviceCreateError && (
<div style={{
padding: '14px 16px',
borderRadius: '12px',
background: 'rgba(255, 99, 132, 0.12)',
border: '1px solid rgba(255, 99, 132, 0.35)',
color: '#fecdd3',
fontSize: '14px',
lineHeight: 1.5,
textAlign: 'center'
}}>
{serviceCreateError}
</div>
)}
</div> </div>
</QuestionCard> </QuestionCard>
{isStandaloneNewService && (
<QuestionCard
isActive={currentStepIndex === 5}
title="¿Quién brinda este servicio?"
description="Asigná este servicio a un colaborador para que pueda recibir reservas. Si todavía no querés hacerlo, podés configurarlo después."
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '14px', width: '100%' }}>
<button
type="button"
onClick={handleAssignCreatedServiceToMe}
disabled={isCreating}
style={{
padding: '14px 18px',
borderRadius: '12px',
border: '1px solid var(--wine-red)',
background: 'var(--wine-red)',
color: 'white',
fontSize: '16px',
fontWeight: 'bold',
cursor: isCreating ? 'not-allowed' : 'pointer',
opacity: isCreating ? 0.7 : 1
}}
>
{isCreating ? "Asignando..." : "Yo"}
</button>
<button
type="button"
onClick={handleShowCollaboratorAssignment}
disabled={isCreating || isLoadingServiceCollaborators}
style={{
padding: '14px 18px',
borderRadius: '12px',
border: serviceAssignmentMode === "collaborators" ? '1px solid var(--wine-red)' : '1px solid rgba(255,255,255,0.2)',
background: serviceAssignmentMode === "collaborators" ? 'rgba(128, 0, 64, 0.35)' : 'rgba(255,255,255,0.05)',
color: 'white',
fontSize: '16px',
cursor: isCreating || isLoadingServiceCollaborators ? 'not-allowed' : 'pointer',
opacity: isCreating || isLoadingServiceCollaborators ? 0.7 : 1
}}
>
{isLoadingServiceCollaborators ? "Cargando colaboradores..." : "Uno o más colaboradores"}
</button>
{serviceAssignmentMode === "collaborators" && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', width: '100%' }}>
{serviceAssignmentCollaborators.length === 0 && !isLoadingServiceCollaborators && (
<div style={{ color: 'rgba(255,255,255,0.7)', fontSize: '14px', textAlign: 'center' }}>
No encontramos colaboradores activos para esta organización.
</div>
)}
<CollaboratorPicker
employees={serviceAssignmentCollaborators}
selectedIds={selectedServiceCollaboratorIds}
multiple
disabled={isCreating}
onToggle={handleToggleServiceCollaborator}
/>
{serviceAssignmentCollaborators.length > 0 && (
<button
type="button"
onClick={handleAssignCreatedServiceToCollaborators}
disabled={isCreating || selectedServiceCollaboratorIds.length === 0}
style={{
padding: '14px 18px',
borderRadius: '12px',
border: '1px solid var(--wine-red)',
background: 'var(--wine-red)',
color: 'white',
fontSize: '16px',
fontWeight: 'bold',
cursor: isCreating || selectedServiceCollaboratorIds.length === 0 ? 'not-allowed' : 'pointer',
opacity: isCreating || selectedServiceCollaboratorIds.length === 0 ? 0.7 : 1
}}
>
{isCreating ? "Asignando..." : "Asignar servicio"}
</button>
)}
</div>
)}
{!serviceAssignmentWarning && (
<>
<button
type="button"
onClick={handleFinishWithoutAssignment}
disabled={isCreating}
style={{
padding: '14px 18px',
borderRadius: '12px',
border: '1px solid rgba(255,255,255,0.2)',
background: 'rgba(255,255,255,0.05)',
color: 'white',
fontSize: '16px',
cursor: isCreating ? 'not-allowed' : 'pointer',
opacity: isCreating ? 0.7 : 1
}}
>
Lo configuro después
</button>
<div style={{
padding: '12px 14px',
borderRadius: '12px',
background: 'rgba(245, 158, 11, 0.12)',
border: '1px solid rgba(245, 158, 11, 0.35)',
color: '#fde68a',
fontSize: '14px',
lineHeight: 1.5,
textAlign: 'center'
}}>
Si lo configurás después, el servicio no va a estar disponible para reservas hasta que lo asignes a un colaborador con horarios cargados.
</div>
</>
)}
{serviceAssignmentError && (
<div style={{
padding: '12px 14px',
borderRadius: '12px',
background: 'rgba(255, 99, 132, 0.12)',
border: '1px solid rgba(255, 99, 132, 0.35)',
color: '#fecdd3',
fontSize: '14px',
lineHeight: 1.5,
textAlign: 'center'
}}>
{serviceAssignmentError}
</div>
)}
</div>
</QuestionCard>
)}
</> </>
); );
@@ -0,0 +1,99 @@
import React from "react";
import { CompanyServiceView } from "@models/Service.model";
import { getServiceImage } from "@core/helpers/getServiceImage";
import QuestionCard from "../../QuestionCard/QuestionCard";
type Props = {
mode: "publish" | "unpublish";
currentStepIndex: number;
services: CompanyServiceView[];
selectedServiceIds: string[];
toggleService: (serviceId: string) => void;
handleSave: () => void;
isCreating: boolean;
isLoading: boolean;
message: string;
selectedOrganization?: any;
orgName: string;
};
export default function ServicePublicationFlow(props: Props) {
const { mode, currentStepIndex, services, selectedServiceIds, toggleService, handleSave, isCreating, isLoading, message, selectedOrganization, orgName } = props;
const isPublish = mode === "publish";
const title = isPublish ? "Publicá tus servicios" : "Retirá servicios de la página pública";
const description = isPublish
? "Elegí uno o más servicios privados para mostrarlos en tu página pública. Los servicios bloqueados no se pueden publicar desde acá."
: "Elegí uno o más servicios publicados para dejar de mostrarlos en la página pública.";
const emptyMessage = isPublish
? "No encontramos servicios privados disponibles para publicar. Puede que ya estén publicados o que estén bloqueados."
: "No encontramos servicios publicados para retirar.";
const nextLabel = isCreating
? (isPublish ? "Publicando..." : "Quitando publicación...")
: (isPublish ? "Publicar servicios" : "Quitar publicación");
return (
<QuestionCard
isActive={currentStepIndex === 0}
title={title}
description={description}
onNext={handleSave}
disableNext={isLoading || isCreating || selectedServiceIds.length === 0 || services.length === 0}
nextLabel={isLoading ? "Cargando servicios..." : nextLabel}
>
<div style={{ display: "flex", flexDirection: "column", gap: "14px", width: "100%" }}>
<div style={{ color: "rgba(255,255,255,0.72)", fontSize: "14px", textAlign: "center" }}>
Organización: <strong style={{ color: "white" }}>{selectedOrganization?.name || orgName || "seleccionada"}</strong>
</div>
{message && (
<div style={{ padding: "12px 14px", borderRadius: "12px", background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.12)", color: "rgba(255,255,255,0.82)", textAlign: "center", lineHeight: 1.4 }}>
{message}
</div>
)}
{!isLoading && services.length === 0 && (
<div style={{ padding: "18px", borderRadius: "14px", background: "rgba(255,255,255,0.05)", border: "1px solid rgba(255,255,255,0.1)", color: "rgba(255,255,255,0.75)", textAlign: "center", lineHeight: 1.5 }}>
{emptyMessage}
</div>
)}
{services.map(service => {
const selected = selectedServiceIds.includes(service.id);
return (
<button
key={service.id}
type="button"
onClick={() => toggleService(service.id)}
disabled={isCreating}
style={{
display: "flex",
gap: "14px",
alignItems: "center",
width: "100%",
padding: "12px",
borderRadius: "14px",
border: `2px solid ${selected ? "var(--wine-red)" : "rgba(255,255,255,0.1)"}`,
background: selected ? "rgba(255,42,127,0.18)" : "rgba(255,255,255,0.05)",
color: "white",
cursor: isCreating ? "not-allowed" : "pointer",
textAlign: "left",
transition: "all 0.2s ease"
}}
>
<img
src={getServiceImage(service.image)}
alt={service.name}
style={{ width: "62px", height: "62px", objectFit: "cover", borderRadius: "12px", background: "rgba(255,255,255,0.08)", flex: "0 0 auto" }}
/>
<span style={{ display: "flex", flexDirection: "column", gap: "4px", flex: 1 }}>
<span style={{ fontWeight: 800, fontSize: "15px" }}>{service.name}</span>
<span style={{ color: "rgba(255,255,255,0.68)", fontSize: "13px", lineHeight: 1.35 }}>{service.description || "Sin descripción"}</span>
</span>
<span style={{ width: "22px", height: "22px", borderRadius: "50%", border: `2px solid ${selected ? "var(--wine-red)" : "rgba(255,255,255,0.35)"}`, background: selected ? "var(--wine-red)" : "transparent", flex: "0 0 auto" }} />
</button>
);
})}
</div>
</QuestionCard>
);
}
@@ -1,234 +1,241 @@
import React from "react";
import QuestionCard from "../../QuestionCard/QuestionCard"; import QuestionCard from "../../QuestionCard/QuestionCard";
import CollaboratorPicker from "../components/CollaboratorPicker";
type Props = any; type Props = any;
const weekDays = [
{ id: 1, label: "Lunes" },
{ id: 2, label: "Martes" },
{ id: 3, label: "Miércoles" },
{ id: 4, label: "Jueves" },
{ id: 5, label: "Viernes" },
{ id: 6, label: "Sábado" },
{ id: 0, label: "Domingo" },
];
const optionStyle = (active: boolean) => ({
padding: "15px",
borderRadius: "12px",
cursor: "pointer",
background: active ? "var(--wine-red)" : "rgba(255,255,255,0.05)",
border: `2px solid ${active ? "var(--wine-red)" : "transparent"}`,
color: "white",
textAlign: "center" as const,
transition: "all 0.2s ease",
});
export default function UpdateScheduleFlow(props: Props) { export default function UpdateScheduleFlow(props: Props) {
const { currentStepIndex, scheduleScope, setScheduleScope, targetEmployeeId, setTargetEmployeeId, allEmployees, handleNext, workingDaysMode, setWorkingDaysMode, openTime, setOpenTime, closeTime, setCloseTime, hasBreak, setHasBreak, breakStart, setBreakStart, breakEnd, setBreakEnd, isCreating, handleUpdateScheduleSubmit, handleOverrideScheduleSubmit, disableDuration, setDisableDuration, disableStartDate, setDisableStartDate, disableEndDate, setDisableEndDate, isFullDayDisabled, setIsFullDayDisabled, disableStartTime, setDisableStartTime, disableEndTime, setDisableEndTime, handleDisableScheduleSubmit } = props; const {
currentStepIndex,
scheduleScope,
setScheduleScope,
selectedScheduleEmployeeIds,
handleToggleScheduleEmployee,
allEmployees,
handleNext,
workingDaysMode,
setWorkingDaysMode,
specificWeekDay,
setSpecificWeekDay,
openTime,
setOpenTime,
closeTime,
setCloseTime,
hasBreak,
setHasBreak,
breakStart,
setBreakStart,
breakEnd,
setBreakEnd,
isCreating,
handleUpdateScheduleSubmit,
scheduleServiceScope,
setScheduleServiceScope,
scheduleServiceIds,
setScheduleServiceIds,
scheduleAvailableServices,
loadScheduleServicesForEmployees,
scheduleTargetEmployeeIds,
canUseSpecificScheduleServices,
scheduleConflictMessage,
pendingScheduleOverwrite,
setScheduleConflictMessage,
setPendingScheduleOverwrite,
} = props;
const isSpecificDay = workingDaysMode === "specific-day";
const weekDayStep = 2;
const timeStep = isSpecificDay ? 3 : 2;
const breakStep = isSpecificDay ? 4 : 3;
const breakTimeStep = isSpecificDay ? 5 : 4;
const serviceScopeStep = hasBreak ? 6 : 5;
const hasNoCommonServices = scheduleServiceScope === "specific" && scheduleAvailableServices.length === 0;
const continueAfterBreak = () => {
if (isSpecificDay) {
handleNext();
return;
}
handleUpdateScheduleSubmit();
};
const handleServiceScopeSelect = async (scope: "all" | "specific") => {
setScheduleConflictMessage("");
setScheduleServiceScope(scope);
if (scope === "specific" && canUseSpecificScheduleServices) {
await loadScheduleServicesForEmployees(scheduleTargetEmployeeIds);
}
};
const toggleService = (serviceId: string) => {
setScheduleServiceIds(
scheduleServiceIds.includes(serviceId)
? scheduleServiceIds.filter((id: string) => id !== serviceId)
: [...scheduleServiceIds, serviceId]
);
};
const renderConflictActions = () => {
if (!scheduleConflictMessage) return null;
return (
<>
<div style={{ background: "rgba(255, 193, 7, 0.14)", border: "1px solid rgba(255, 193, 7, 0.45)", borderRadius: "12px", padding: "12px", color: "#fff4cf", whiteSpace: "pre-line" }}>
{scheduleConflictMessage}
</div>
{pendingScheduleOverwrite && (
<div style={{ display: "flex", gap: "10px", justifyContent: "center" }}>
<button type="button" onClick={() => { setScheduleConflictMessage(""); setPendingScheduleOverwrite(false); }} style={{ padding: "10px 14px", borderRadius: "10px", border: "1px solid rgba(255,255,255,0.25)", background: "transparent", color: "white" }}>Cancelar / Editar</button>
<button type="button" onClick={() => handleUpdateScheduleSubmit("overwrite-conflicts")} style={{ padding: "10px 14px", borderRadius: "10px", border: "0", background: "var(--wine-red)", color: "white" }}>Sobrescribir conflictos</button>
</div>
)}
</>
);
};
return ( return (
<> <>
<QuestionCard <QuestionCard
isActive={currentStepIndex === 0} isActive={currentStepIndex === 0}
title="Actualizar Horarios" title="Actualizar Horarios"
description="¿A quiénes quieres aplicar esta nueva rutina de horarios?" description="¿A quiénes querés aplicar esta nueva rutina de horarios?"
onNext={() => { onNext={handleNext}
if (scheduleScope === "specific" && !targetEmployeeId) { disableNext={!scheduleScope || (scheduleScope === "specific" && selectedScheduleEmployeeIds.length === 0)}
alert("Por favor selecciona un colaborador.");
return;
}
handleNext();
}}
disableNext={!scheduleScope || (scheduleScope === "specific" && !targetEmployeeId)}
> >
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', width: '100%' }}> <div style={{ display: "flex", flexDirection: "column", gap: "10px", width: "100%" }}>
<div <div onClick={() => setScheduleScope("me")} style={optionStyle(scheduleScope === "me")}>
onClick={() => setScheduleScope("me")} <h4 style={{ margin: "0 0 5px 0" }}>Solo a </h4>
style={{ <p style={{ margin: 0, fontSize: "12px", opacity: 0.7 }}>Aplica esta rutina únicamente a tus horarios.</p>
padding: '15px', borderRadius: '12px', cursor: 'pointer',
background: scheduleScope === "me" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)',
border: `2px solid ${scheduleScope === "me" ? 'var(--wine-red)' : 'transparent'}`,
color: 'white', textAlign: 'center', transition: 'all 0.2s ease'
}}
>
<h4 style={{ margin: '0 0 5px 0' }}>Solo a </h4>
<p style={{ margin: 0, fontSize: '12px', opacity: 0.7 }}>Aplica esta rutina únicamente a tus horarios.</p>
</div> </div>
<div <div onClick={() => setScheduleScope("specific")} style={optionStyle(scheduleScope === "specific")}>
onClick={() => setScheduleScope("specific")} <h4 style={{ margin: "0 0 5px 0" }}>A uno o más colaboradores</h4>
style={{ <p style={{ margin: 0, fontSize: "12px", opacity: 0.7 }}>Aplica esta rutina a uno o varios integrantes específicos de tu equipo.</p>
padding: '15px', borderRadius: '12px', cursor: 'pointer',
background: scheduleScope === "specific" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)',
border: `2px solid ${scheduleScope === "specific" ? 'var(--wine-red)' : 'transparent'}`,
color: 'white', textAlign: 'center', transition: 'all 0.2s ease'
}}
>
<h4 style={{ margin: '0 0 5px 0' }}>A un colaborador en particular</h4>
<p style={{ margin: 0, fontSize: '12px', opacity: 0.7 }}>Aplica esta rutina a un integrante específico de tu equipo.</p>
</div> </div>
<div <div onClick={() => setScheduleScope("all")} style={optionStyle(scheduleScope === "all")}>
onClick={() => setScheduleScope("all")} <h4 style={{ margin: "0 0 5px 0" }}>A todos mis colaboradores</h4>
style={{ <p style={{ margin: 0, fontSize: "12px", opacity: 0.7 }}>Actualiza masivamente los horarios de todo tu equipo.</p>
padding: '15px', borderRadius: '12px', cursor: 'pointer',
background: scheduleScope === "all" ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)',
border: `2px solid ${scheduleScope === "all" ? 'var(--wine-red)' : 'transparent'}`,
color: 'white', textAlign: 'center', transition: 'all 0.2s ease'
}}
>
<h4 style={{ margin: '0 0 5px 0' }}>A todos mis colaboradores</h4>
<p style={{ margin: 0, fontSize: '12px', opacity: 0.7 }}>Sobrescribe masivamente los horarios de todo tu equipo.</p>
</div> </div>
{scheduleScope === "specific" && ( {scheduleScope === "specific" && (
<div style={{ marginTop: '15px', width: '100%' }}> <div style={{ marginTop: "15px", width: "100%" }}>
<label style={{ color: 'white', display: 'block', marginBottom: '8px', fontSize: '14px' }}>Selecciona el colaborador:</label> <label style={{ color: "white", display: "block", marginBottom: "8px", fontSize: "14px" }}>Seleccioná uno o más colaboradores:</label>
<select <CollaboratorPicker employees={allEmployees} multiple selectedIds={selectedScheduleEmployeeIds} onToggle={handleToggleScheduleEmployee} />
value={targetEmployeeId || ""}
onChange={(e) => setTargetEmployeeId(e.target.value)}
style={{
width: '100%', padding: '12px', borderRadius: '8px',
background: 'rgba(255,255,255,0.1)', color: 'white', border: '1px solid rgba(255,255,255,0.2)',
outline: 'none', fontSize: '16px'
}}
>
<option value="" disabled style={{ color: 'black' }}>-- Seleccionar --</option>
{allEmployees.map((emp: any) => (
<option key={emp.id || emp._id || emp.employeeId} value={emp.id || emp._id || emp.employeeId} style={{ color: 'black' }}>
{emp.firstName} {emp.lastName}
</option>
))}
</select>
</div> </div>
)} )}
</div> </div>
</QuestionCard> </QuestionCard>
<QuestionCard <QuestionCard isActive={currentStepIndex === 1} title="Días de Trabajo" description="¿Qué días aplicará esta rutina?" onNext={handleNext} disableNext={!workingDaysMode}>
isActive={currentStepIndex === 1} <div style={{ display: "flex", flexDirection: "column", gap: "10px", width: "100%" }}>
title="Días de Trabajo"
description="¿Qué días aplicará esta rutina?"
onNext={handleNext}
disableNext={!workingDaysMode}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', width: '100%' }}>
{[ {[
{ id: 'mon-fri', label: 'Lunes a Viernes' }, { id: "mon-fri", label: "Lunes a Viernes" },
{ id: 'mon-sat', label: 'Lunes a Sábado' }, { id: "mon-sat", label: "Lunes a Sábado" },
{ id: 'mon-sun', label: 'Todos los días' } { id: "mon-sun", label: "Todos los días" },
].map(mode => ( { id: "specific-day", label: "Día específico" },
<div ].map((mode) => (
key={mode.id} <div key={mode.id} onClick={() => setWorkingDaysMode(mode.id)} style={optionStyle(workingDaysMode === mode.id)}>
onClick={() => setWorkingDaysMode(mode.id)}
style={{
padding: '15px',
borderRadius: '12px',
cursor: 'pointer',
background: workingDaysMode === mode.id ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)',
border: `2px solid ${workingDaysMode === mode.id ? 'var(--wine-red)' : 'transparent'}`,
color: 'white',
textAlign: 'center',
transition: 'all 0.2s ease'
}}
>
{mode.label} {mode.label}
</div> </div>
))} ))}
</div> </div>
</QuestionCard> </QuestionCard>
<QuestionCard <QuestionCard isActive={currentStepIndex === weekDayStep && isSpecificDay} title="Día específico" description="Elegí el día que querés configurar." onNext={handleNext} disableNext={specificWeekDay === null}>
isActive={currentStepIndex === 2} <div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(130px, 1fr))", gap: "10px", width: "100%" }}>
title="Horario Comercial" {weekDays.map((day) => (
description="¿A qué hora empieza y termina la jornada laboral?" <div key={day.id} onClick={() => setSpecificWeekDay(day.id)} style={optionStyle(specificWeekDay === day.id)}>
onNext={handleNext} {day.label}
disableNext={!openTime || !closeTime}
>
<div style={{ display: 'flex', gap: '20px', width: '100%', justifyContent: 'center' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
<label style={{ color: 'white', fontSize: '12px', opacity: 0.7 }}>Apertura</label>
<input
type="time"
value={openTime}
onChange={(e) => setOpenTime(e.target.value)}
style={{
padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)',
background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none',
fontSize: '18px', fontFamily: 'monospace'
}}
/>
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}> ))}
<label style={{ color: 'white', fontSize: '12px', opacity: 0.7 }}>Cierre</label> </div>
<input </QuestionCard>
type="time"
value={closeTime} <QuestionCard isActive={currentStepIndex === timeStep} title="Horario Comercial" description="¿A qué hora empieza y termina la jornada laboral?" onNext={handleNext} disableNext={!openTime || !closeTime}>
onChange={(e) => setCloseTime(e.target.value)} <div style={{ display: "flex", gap: "20px", width: "100%", justifyContent: "center" }}>
style={{ <div style={{ display: "flex", flexDirection: "column", gap: "5px" }}>
padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)', <label style={{ color: "white", fontSize: "12px", opacity: 0.7 }}>Apertura</label>
background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none', <input type="time" value={openTime} onChange={(e) => setOpenTime(e.target.value)} style={{ padding: "10px", borderRadius: "8px", border: "1px solid rgba(255,255,255,0.2)", background: "rgba(255,255,255,0.1)", color: "white", outline: "none", fontSize: "18px", fontFamily: "monospace" }} />
fontSize: '18px', fontFamily: 'monospace' </div>
}} <div style={{ display: "flex", flexDirection: "column", gap: "5px" }}>
/> <label style={{ color: "white", fontSize: "12px", opacity: 0.7 }}>Cierre</label>
<input type="time" value={closeTime} onChange={(e) => setCloseTime(e.target.value)} style={{ padding: "10px", borderRadius: "8px", border: "1px solid rgba(255,255,255,0.2)", background: "rgba(255,255,255,0.1)", color: "white", outline: "none", fontSize: "18px", fontFamily: "monospace" }} />
</div> </div>
</div> </div>
</QuestionCard> </QuestionCard>
<QuestionCard <QuestionCard isActive={currentStepIndex === breakStep} title="Descansos" description="¿Tienen un horario de corte al mediodía o descanso?" onNext={continueAfterBreak} disableNext={hasBreak === null} nextLabel={hasBreak ? "Siguiente" : (isSpecificDay ? "Siguiente" : isCreating ? "Guardando..." : "Guardar Horarios")}>
isActive={currentStepIndex === 3} <div style={{ display: "flex", flexDirection: "column", gap: "12px", width: "100%" }}>
title="Descansos" <div style={{ display: "flex", gap: "15px", width: "100%", justifyContent: "center" }}>
description="¿Tienen un horario de corte al mediodía o descanso?" <div onClick={() => setHasBreak(true)} style={{ ...optionStyle(hasBreak === true), flex: 1 }}>, hay descanso</div>
onNext={() => { <div onClick={() => setHasBreak(false)} style={{ ...optionStyle(hasBreak === false), flex: 1 }}>No, horario corrido</div>
if (hasBreak) {
handleNext();
} else {
handleUpdateScheduleSubmit();
}
}}
disableNext={hasBreak === null}
nextLabel={hasBreak ? "Siguiente" : (isCreating ? "Guardando..." : "Guardar Horarios")}
>
<div style={{ display: 'flex', gap: '15px', width: '100%', justifyContent: 'center' }}>
<div
onClick={() => setHasBreak(true)}
style={{
flex: 1, padding: '15px', borderRadius: '12px', cursor: 'pointer',
background: hasBreak === true ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)',
border: `2px solid ${hasBreak === true ? 'var(--wine-red)' : 'transparent'}`,
color: 'white', textAlign: 'center'
}}
>
, hay descanso
</div>
<div
onClick={() => setHasBreak(false)}
style={{
flex: 1, padding: '15px', borderRadius: '12px', cursor: 'pointer',
background: hasBreak === false ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)',
border: `2px solid ${hasBreak === false ? 'var(--wine-red)' : 'transparent'}`,
color: 'white', textAlign: 'center'
}}
>
No, horario corrido
</div> </div>
{renderConflictActions()}
</div> </div>
</QuestionCard> </QuestionCard>
<QuestionCard <QuestionCard isActive={currentStepIndex === breakTimeStep && hasBreak === true} title="Horario de Descanso" description="¿De qué hora a qué hora no estarán disponibles?" onNext={isSpecificDay ? handleNext : handleUpdateScheduleSubmit} disableNext={!breakStart || !breakEnd || isCreating} nextLabel={isSpecificDay ? "Siguiente" : isCreating ? "Guardando..." : "Guardar Horarios"}>
isActive={currentStepIndex === 4} <div style={{ display: "flex", flexDirection: "column", gap: "12px", width: "100%" }}>
title="Horario de Descanso" <div style={{ display: "flex", gap: "20px", width: "100%", justifyContent: "center" }}>
description="¿De qué hora a qué hora no estarán disponibles?" <div style={{ display: "flex", flexDirection: "column", gap: "5px" }}>
onNext={handleUpdateScheduleSubmit} <label style={{ color: "white", fontSize: "12px", opacity: 0.7 }}>Inicio descanso</label>
disableNext={!breakStart || !breakEnd || isCreating} <input type="time" value={breakStart} onChange={(e) => setBreakStart(e.target.value)} style={{ padding: "10px", borderRadius: "8px", border: "1px solid rgba(255,255,255,0.2)", background: "rgba(255,255,255,0.1)", color: "white", outline: "none", fontSize: "18px", fontFamily: "monospace" }} />
nextLabel={isCreating ? "Guardando..." : "Guardar Horarios"}
>
<div style={{ display: 'flex', gap: '20px', width: '100%', justifyContent: 'center' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
<label style={{ color: 'white', fontSize: '12px', opacity: 0.7 }}>Inicio descanso</label>
<input
type="time"
value={breakStart}
onChange={(e) => setBreakStart(e.target.value)}
style={{
padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)',
background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none',
fontSize: '18px', fontFamily: 'monospace'
}}
/>
</div> </div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}> <div style={{ display: "flex", flexDirection: "column", gap: "5px" }}>
<label style={{ color: 'white', fontSize: '12px', opacity: 0.7 }}>Fin descanso</label> <label style={{ color: "white", fontSize: "12px", opacity: 0.7 }}>Fin descanso</label>
<input <input type="time" value={breakEnd} onChange={(e) => setBreakEnd(e.target.value)} style={{ padding: "10px", borderRadius: "8px", border: "1px solid rgba(255,255,255,0.2)", background: "rgba(255,255,255,0.1)", color: "white", outline: "none", fontSize: "18px", fontFamily: "monospace" }} />
type="time"
value={breakEnd}
onChange={(e) => setBreakEnd(e.target.value)}
style={{
padding: '10px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.2)',
background: 'rgba(255,255,255,0.1)', color: 'white', outline: 'none',
fontSize: '18px', fontFamily: 'monospace'
}}
/>
</div> </div>
</div> </div>
{renderConflictActions()}
</div>
</QuestionCard>
<QuestionCard isActive={currentStepIndex === serviceScopeStep && isSpecificDay} title="Servicios del horario" description="¿Este horario aplica a todos los servicios o solo a algunos?" onNext={() => handleUpdateScheduleSubmit()} disableNext={isCreating || (scheduleServiceScope === "specific" && (scheduleServiceIds.length === 0 || hasNoCommonServices))} nextLabel={isCreating ? "Guardando..." : "Guardar Horarios"}>
<div style={{ display: "flex", flexDirection: "column", gap: "10px", width: "100%" }}>
{!canUseSpecificScheduleServices && (
<p style={{ color: "#ffd8a8", margin: 0, fontSize: "14px" }}>Seleccioná al menos un colaborador para configurar servicios específicos.</p>
)}
<div onClick={() => handleServiceScopeSelect("all")} style={optionStyle(scheduleServiceScope === "all")}>Todos los servicios</div>
<div onClick={() => canUseSpecificScheduleServices && handleServiceScopeSelect("specific")} style={{ ...optionStyle(scheduleServiceScope === "specific"), opacity: canUseSpecificScheduleServices ? 1 : 0.5, cursor: canUseSpecificScheduleServices ? "pointer" : "not-allowed" }}>Servicios específicos</div>
{scheduleServiceScope === "specific" && canUseSpecificScheduleServices && (
<div style={{ display: "flex", flexDirection: "column", gap: "8px" }}>
{hasNoCommonServices && (
<p style={{ color: "#ffd8a8", margin: 0, fontSize: "14px" }}>Los colaboradores seleccionados no tienen servicios activos en común. Usá todos los servicios o cambiá la selección.</p>
)}
{scheduleAvailableServices.map((service: any) => (
<label key={service.id || service._id} style={{ color: "white", display: "flex", gap: "10px", alignItems: "center" }}>
<input type="checkbox" checked={scheduleServiceIds.includes(service.id || service._id)} onChange={() => toggleService(service.id || service._id)} />
{service.name}
</label>
))}
</div>
)}
{renderConflictActions()}
</div>
</QuestionCard> </QuestionCard>
</> </>
); );
} }
@@ -1,4 +1,10 @@
type ScheduleBlock = { from: string; to: string; disabled: boolean }; type ScheduleBlock = {
from: string;
to: string;
disabled: boolean;
serviceScope?: "all" | "specific";
serviceIds?: string[];
};
export const getWorkingDays = (workingDaysMode: string, customDays: number[] = []) => { export const getWorkingDays = (workingDaysMode: string, customDays: number[] = []) => {
if (workingDaysMode === "mon-fri") return [1, 2, 3, 4, 5]; if (workingDaysMode === "mon-fri") return [1, 2, 3, 4, 5];
@@ -13,14 +19,18 @@ export const buildScheduleBlocks = (
closeTime: string, closeTime: string,
hasBreak: boolean | null, hasBreak: boolean | null,
breakStart: string, breakStart: string,
breakEnd: string breakEnd: string,
serviceScope: "all" | "specific" = "all",
serviceIds: string[] = []
): ScheduleBlock[] => { ): ScheduleBlock[] => {
const serviceData = serviceScope === "specific" ? { serviceScope, serviceIds } : { serviceScope: "all" as const, serviceIds: [] };
if (hasBreak) { if (hasBreak) {
return [ return [
{ from: openTime, to: breakStart, disabled: false }, { from: openTime, to: breakStart, disabled: false, ...serviceData },
{ from: breakEnd, to: closeTime, disabled: false } { from: breakEnd, to: closeTime, disabled: false, ...serviceData }
]; ];
} }
return [{ from: openTime, to: closeTime, disabled: false }]; return [{ from: openTime, to: closeTime, disabled: false, ...serviceData }];
}; };
@@ -21,6 +21,7 @@ import {
} from "@core/Models/SystemNotifications.model"; } from "@core/Models/SystemNotifications.model";
import { import {
findSystemNotifications, findSystemNotifications,
setStateNotificationByUser,
updateSystemNotification, updateSystemNotification,
} from "@core/app/user/profile/notifications/Notifications.Service"; } from "@core/app/user/profile/notifications/Notifications.Service";
import { import {
@@ -214,6 +215,29 @@ export default function Header(): React.ReactElement {
goTo(NOTIFICATIONS_ROUTE); goTo(NOTIFICATIONS_ROUTE);
}; };
const markNotificationsAsRead = async () => {
if (!SessionInfo.userId || headerState.notificationsCount === 0) {
return;
}
try {
await setStateNotificationByUser({
state: true,
sessionUser: SessionInfo.userId,
});
setNotifications((currentNotifications) =>
currentNotifications.map((notification) => ({
...notification,
readed: true,
}))
);
headerState.setNotificationsCount(0);
} catch (error) {
console.error("Error marking notifications as read", error);
}
};
const openNotification = async (notification: ISystemNotification) => { const openNotification = async (notification: ISystemNotification) => {
setNotificationsOpen(false); setNotificationsOpen(false);
@@ -392,6 +416,15 @@ export default function Header(): React.ReactElement {
))} ))}
</div> </div>
<div className={style.notificationsFooterActions}>
<button
type="button"
className={style.notificationsFooter}
onClick={markNotificationsAsRead}
disabled={headerState.notificationsCount === 0}
>
Marcar como leídas
</button>
<button <button
type="button" type="button"
className={style.notificationsFooter} className={style.notificationsFooter}
@@ -400,6 +433,7 @@ export default function Header(): React.ReactElement {
Ver todas las notificaciones Ver todas las notificaciones
</button> </button>
</div> </div>
</div>
)} )}
</div> </div>
)} )}
@@ -185,11 +185,16 @@
font-weight: 600; font-weight: 600;
} }
.notificationsFooterActions {
display: flex;
border-top: 1px solid var(--gray-lighter);
background: var(--white);
}
.notificationsFooter { .notificationsFooter {
width: 100%; width: 100%;
padding: 14px 18px; padding: 14px 18px;
border: 0; border: 0;
border-top: 1px solid var(--gray-lighter);
background: var(--white); background: var(--white);
color: var(--wine-dark); color: var(--wine-dark);
cursor: pointer; cursor: pointer;
@@ -198,11 +203,21 @@
transition: background-color 0.18s ease, color 0.18s ease; transition: background-color 0.18s ease, color 0.18s ease;
} }
.notificationsFooter:hover { .notificationsFooter + .notificationsFooter {
border-left: 1px solid var(--gray-lighter);
}
.notificationsFooter:hover:not(:disabled) {
background: var(--wine-lighterX2); background: var(--wine-lighterX2);
color: var(--wine-darkest); color: var(--wine-darkest);
} }
.notificationsFooter:disabled {
color: var(--gray-dark);
cursor: default;
opacity: 0.65;
}
@media (max-width: 600px) { @media (max-width: 600px) {
.header { .header {
padding-left: 18px; padding-left: 18px;
@@ -212,4 +227,13 @@
.notificationsMenu { .notificationsMenu {
right: -48px; right: -48px;
} }
.notificationsFooterActions {
flex-direction: column;
}
.notificationsFooter + .notificationsFooter {
border-left: 0;
border-top: 1px solid var(--gray-lighter);
}
} }
@@ -74,6 +74,9 @@ export default function Footer() {
<Link href="/admin/org" style={{ display: "block" }}> <Link href="/admin/org" style={{ display: "block" }}>
Mis organizaciones Mis organizaciones
</Link> </Link>
<Link href="/landing/help" style={{ display: "block" }}>
Obtener ayuda
</Link>
<h1 style={{ marginTop: "5px" }}>Contacto</h1> <h1 style={{ marginTop: "5px" }}>Contacto</h1>
<Link href="mailto:info@turnosxpress.com.ar" style={{ display: "block" }}> <Link href="mailto:info@turnosxpress.com.ar" style={{ display: "block" }}>
info@turnosxpress.com.ar info@turnosxpress.com.ar
+158 -28
View File
@@ -44,6 +44,7 @@ export default function Login(props: LoginProps) {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [verificationCode, setVerificationCode] = useState(""); const [verificationCode, setVerificationCode] = useState("");
const [checkReadTerms, setCheckReadTerms] = useState(false); const [checkReadTerms, setCheckReadTerms] = useState(false);
const [loginMethod, setLoginMethod] = useState<"google" | "password">("google");
const clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || ""; const clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
@@ -323,8 +324,164 @@ export default function Login(props: LoginProps) {
</> </>
)} )}
{!verification && !recovery && !recoveryCode && ( {!verification && !recovery && !recoveryCode && (
<>
<h1 className="homeTitleHeader">Ingresar</h1> <h1 className="homeTitleHeader">Ingresar</h1>
<div
style={{
display: "grid",
gap: "10px",
marginBottom: "24px",
textAlign: "left",
}}
>
<p
style={{
margin: 0,
color: "#777777",
fontSize: "14px",
textAlign: "center",
}}
>
Elegí cómo querés acceder
</p>
<button
type="button"
onClick={() => setLoginMethod("google")}
style={{
border:
loginMethod === "google"
? "2px solid var(--wine-dark)"
: "1px solid #e1e1e1",
borderRadius: "14px",
background:
loginMethod === "google"
? "rgba(111, 16, 50, 0.08)"
: "#ffffff",
color:
loginMethod === "google"
? "var(--wine-dark)"
: "#3f3f3f",
cursor: "pointer",
fontWeight: loginMethod === "google" ? 700 : 500,
padding: "14px 16px",
textAlign: "left",
boxShadow:
loginMethod === "google"
? "0 8px 18px rgba(111, 16, 50, 0.12)"
: "none",
}}
>
<span
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: "12px",
}}
>
<span>Continuar con Google</span>
<span
style={{
borderRadius: "999px",
background: "var(--wine-dark)",
color: "#ffffff",
fontSize: "11px",
fontWeight: 700,
padding: "4px 8px",
}}
>
Recomendado
</span>
</span>
<span
style={{
display: "block",
marginTop: "4px",
color: "#777777",
fontSize: "13px",
fontWeight: 400,
}}
>
Más rápido, sin recordar otra contraseña.
</span>
</button>
<button
type="button"
onClick={() => setLoginMethod("password")}
style={{
border:
loginMethod === "password"
? "2px solid var(--wine-dark)"
: "1px solid #e1e1e1",
borderRadius: "14px",
background:
loginMethod === "password"
? "rgba(111, 16, 50, 0.08)"
: "#ffffff",
color:
loginMethod === "password"
? "var(--wine-dark)"
: "#3f3f3f",
cursor: "pointer",
fontWeight: loginMethod === "password" ? 700 : 500,
padding: "14px 16px",
textAlign: "left",
boxShadow:
loginMethod === "password"
? "0 8px 18px rgba(111, 16, 50, 0.12)"
: "none",
}}
>
<span>Email y clave</span>
<span
style={{
display: "block",
marginTop: "4px",
color: "#777777",
fontSize: "13px",
fontWeight: 400,
}}
>
Usá tu cuenta tradicional de TurnosXpress.
</span>
</button>
</div>
</>
)} )}
{!verification && !recovery && !recoveryCode && loginMethod === "google" && (
<div>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "flex-start",
}}
>
<Checkbox
checked={checkReadTerms}
onClick={() => setCheckReadTerms(!checkReadTerms)}
/>
<div style={{ marginTop: "8px" }}>
Declaro que acepto los siguientes&nbsp;
<Link href="/landing/terms">
Términos y condiciones
</Link>{" "}
y{" "}
<Link href="/landing/privacy">
Políticas de privacidad
</Link>
</div>
</div>
<ButtonGoogle termsReaded={checkReadTerms} />
</div>
)}
{(!verification && !recovery && !recoveryCode
? loginMethod === "password"
: true) && (
<>
<Textbox <Textbox
name="name" name="name"
placeholder="Email" placeholder="Email"
@@ -417,34 +574,7 @@ export default function Login(props: LoginProps) {
type="submit" type="submit"
/> />
)} )}
</>
{!verification && !recovery && !recoveryCode && (
<div>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "flex-start",
justifyContent: "flex-start",
}}
>
<Checkbox
checked={checkReadTerms}
onClick={() => setCheckReadTerms(!checkReadTerms)}
/>
<div style={{ marginTop: "8px" }}>
Declaro que acepto los siguientes&nbsp;
<Link href="/landing/terms">
Términos y condiciones
</Link>{" "}
y{" "}
<Link href="/landing/privacy">
Políticas de privacidad
</Link>
</div>
</div>
<ButtonGoogle termsReaded={checkReadTerms} />
</div>
)} )}
{!recoveryCode && ( {!recoveryCode && (
<p style={{ marginTop: "20px" }}> <p style={{ marginTop: "20px" }}>
@@ -0,0 +1,217 @@
.page {
width: 100vw;
min-height: 100dvh;
margin-left: calc(50% - 50vw);
margin-right: calc(50% - 50vw);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background:
radial-gradient(circle at 20% 20%, rgba(255, 42, 127, 0.18), transparent 34%),
linear-gradient(180deg, var(--wine-darkest) 0%, var(--wine-dark) 100%);
}
.card {
width: min(860px, calc(100% - 48px));
display: grid;
grid-template-columns: minmax(260px, 0.9fr) minmax(300px, 1fr);
gap: 28px;
align-items: center;
padding: clamp(22px, 3vw, 36px);
border-radius: 28px;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 18px 54px rgba(0, 0, 0, 0.2);
border: 1px solid rgba(255, 255, 255, 0.42);
backdrop-filter: blur(8px);
}
.illustrationPanel {
display: flex;
align-items: center;
justify-content: center;
min-height: 300px;
border-radius: 24px;
background: transparent;
}
.illustration {
width: min(100%, 360px);
height: auto;
object-fit: contain;
}
.content {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16px;
max-width: 430px;
}
.badge {
display: inline-flex;
padding: 8px 14px;
border-radius: 999px;
color: var(--wine-red);
background: rgba(255, 42, 127, 0.1);
font-size: 0.85rem;
font-weight: 800;
letter-spacing: 0.02em;
}
.content h1 {
margin: 0;
color: var(--wine-darkest);
font-size: clamp(2rem, 4vw, 3.15rem);
line-height: 1.04;
letter-spacing: -0.04em;
}
.content p {
margin: 0;
color: rgba(37, 5, 37, 0.72);
font-size: 1.05rem;
line-height: 1.65;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 8px;
}
.primaryAction,
.secondaryAction {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 46px;
padding: 0 22px;
border-radius: 999px;
font-weight: 800;
text-decoration: none;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.primaryAction {
color: white;
background: var(--wine-red);
box-shadow: 0 12px 26px rgba(255, 42, 127, 0.28);
}
.secondaryAction {
color: var(--wine-red);
background: rgba(255, 42, 127, 0.08);
}
.primaryAction:hover,
.secondaryAction:hover {
transform: translateY(-2px);
}
@media (max-width: 760px) {
.page {
align-items: flex-start;
padding: 88px 16px 24px;
}
.card {
grid-template-columns: 1fr;
gap: 18px;
text-align: center;
width: min(420px, calc(100% - 32px));
padding: 22px 20px 24px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 14px 38px rgba(0, 0, 0, 0.18);
}
.content {
align-items: center;
max-width: 100%;
}
.illustrationPanel {
min-height: 0;
padding: 16px;
border-radius: 20px;
}
.illustration {
width: min(100%, 260px);
max-height: 230px;
}
.content {
gap: 12px;
}
.content h1 {
max-width: 12ch;
font-size: clamp(1.8rem, 8vw, 2.35rem);
line-height: 1.05;
}
.content p {
max-width: 27ch;
font-size: 0.98rem;
line-height: 1.55;
}
.actions {
width: 100%;
flex-direction: column;
align-items: center;
justify-content: center;
margin-top: 4px;
}
.primaryAction,
.secondaryAction {
width: min(100%, 220px);
}
}
@media (max-width: 420px) {
.page {
padding: 78px 12px 18px;
}
.card {
width: min(390px, calc(100% - 24px));
padding: 18px 18px 22px;
gap: 14px;
border-radius: 22px;
}
.illustrationPanel {
padding: 12px;
}
.illustration {
width: min(100%, 230px);
max-height: 205px;
}
.badge {
padding: 7px 12px;
font-size: 0.78rem;
}
.content h1 {
font-size: clamp(1.65rem, 7.5vw, 2rem);
}
.content p {
font-size: 0.92rem;
}
.primaryAction,
.secondaryAction {
min-height: 42px;
padding: 0 18px;
font-size: 0.92rem;
}
}

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