Compare commits
22 Commits
ARCA
...
ad44e775e2
| Author | SHA1 | Date | |
|---|---|---|---|
| ad44e775e2 | |||
| cebb34c88a | |||
| 0095bd884a | |||
| c238f28179 | |||
| 57c5deb3bd | |||
| c5217aa7c8 | |||
| 48ed64eb87 | |||
| 95b3e3698b | |||
| ee9da60b3b | |||
| f00e258819 | |||
| 33743f12b9 | |||
| b6399d28fc | |||
| e4441fa476 | |||
| c8029a6762 | |||
| 18fa523fbe | |||
| 63a85e8aab | |||
| e82d2a0ece | |||
| ff733296c8 | |||
| ef044522b4 | |||
| e6686597f0 | |||
| 9e090c465a | |||
| b7bbef8035 |
@@ -0,0 +1,33 @@
|
|||||||
|
---
|
||||||
|
name: engram-cloud-sync
|
||||||
|
description: "Trigger: sync engram cloud, sincronizar memoria, push engram, engram cloud sync. Synchronize local Engram project to remote cloud server."
|
||||||
|
license: Apache-2.0
|
||||||
|
metadata:
|
||||||
|
author: "horacio"
|
||||||
|
version: "1.0"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Activation Contract
|
||||||
|
|
||||||
|
Load this skill whenever the user explicitly asks to synchronize, push, or sync their Engram memory with the cloud.
|
||||||
|
|
||||||
|
## Hard Rules
|
||||||
|
|
||||||
|
- **Never skip the sync** when requested. Execute `engram sync --cloud --project turnosxpress` every time.
|
||||||
|
- **Always verify** the cloud server is reachable and the project is enrolled before syncing.
|
||||||
|
- **Report results to the user**: sessions, observations, prompts, mutations exported.
|
||||||
|
- **Fail fast** on 403/forbidden errors — that means the project needs enrollment first.
|
||||||
|
|
||||||
|
## Execution Steps
|
||||||
|
|
||||||
|
1. **Check cloud status:** `engram cloud status --cwd <repo>` (default: current project directory). If `Auth status: ready` and `Sync readiness: pending`, continue.
|
||||||
|
2. **If project is not enrolled** (`403: forbidden: project is not allowed` or similar), run `engram cloud enroll turnosxpress --cwd <repo>` then re-check status.
|
||||||
|
3. **Execute sync:** `engram sync --cloud --project turnosxpress --cwd <repo>`.
|
||||||
|
4. **Report results** — sessions, observations, prompts, mutations count. If sync fails, surface the exact error and suggest running `engram serve` for autosync mode.
|
||||||
|
|
||||||
|
## Output Contract
|
||||||
|
|
||||||
|
Return:
|
||||||
|
- Cloud server URL and enrollment status.
|
||||||
|
- Export counts: sessions, observations, prompts, mutations.
|
||||||
|
- Confirmation: "Cloud sync complete" or actionable error if it failed.
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
package com.hdrdevs.turnosxpress;
|
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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Frx8us2W@
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
ngrok config add-authtoken 3I94dMHXyh3wjRjGBu0oGQ8D8h4_2yBusck6ZWHJBx7WcbBFf
|
||||||
|
|
||||||
|
ngrok http 3000
|
||||||
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
@@ -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://5f09-186-58-32-217.ngrok-free.app/mercadopago/webhook
|
||||||
|
|
||||||
DEFAULT_BOT_ADMIN_PORT = 3005
|
DEFAULT_BOT_ADMIN_PORT = 3005
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -112,7 +112,6 @@ export class PlanUsageCycleAdapterMongoose implements IPlanUsageCycleAdapter {
|
|||||||
planId: data.planId,
|
planId: data.planId,
|
||||||
cycleStart: data.cycleStart,
|
cycleStart: data.cycleStart,
|
||||||
cycleEnd: data.cycleEnd,
|
cycleEnd: data.cycleEnd,
|
||||||
appointmentsCount: 0,
|
|
||||||
creationDate: now,
|
creationDate: now,
|
||||||
},
|
},
|
||||||
$inc: { appointmentsCount: data.quantity },
|
$inc: { appointmentsCount: data.quantity },
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
|
After Width: | Height: | Size: 23 KiB |
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 9.0 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
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>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
plugins {
|
||||||
|
id 'com.android.application' version '8.9.1' apply false
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
@@ -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
|
||||||
@@ -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" "$@"
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -37,7 +37,7 @@ const schemaAppointmentUpdate = Yup.object().shape({
|
|||||||
start: Yup.string().required("Debe seleccionar una fecha."),
|
start: Yup.string().required("Debe seleccionar una fecha."),
|
||||||
price: Yup.number().positive().required("Debe introducir un precio valido."),
|
price: Yup.number().positive().required("Debe introducir un precio valido."),
|
||||||
length: Yup.number().positive().required("Debe introducir una duración."),
|
length: Yup.number().positive().required("Debe introducir una duración."),
|
||||||
comments: Yup.string().required("Debe introducir un comentario."),
|
comments: Yup.string().notRequired(),
|
||||||
sessionUser: Yup.string().required("Para crear un turno debe iniciar sesión."),
|
sessionUser: Yup.string().required("Para crear un turno debe iniciar sesión."),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -204,6 +204,9 @@ export default function OrganizationProfile() {
|
|||||||
|
|
||||||
const [currentDate, setCurrentDate] = useState<dayjs.Dayjs>(appointmentDate);
|
const [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
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
.movementsPage {
|
.movementsPage {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
padding-top: 200px;
|
||||||
padding-bottom: 32px;
|
padding-bottom: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -844,7 +844,16 @@ export default function CashFlowMovementsList() {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
(pdfMake as any).createPdf({ ...documentDefinition, content }).download(`factura-c-${invoice.pointOfSale || fiscalProfile.pointOfSale || 0}-${invoice.voucherNumber || 0}.pdf`);
|
const filename = `factura-c-${invoice.pointOfSale || fiscalProfile.pointOfSale || 0}-${invoice.voucherNumber || 0}.pdf`;
|
||||||
|
const pdf = (pdfMake as any).createPdf({ ...documentDefinition, content });
|
||||||
|
|
||||||
|
if ((window as unknown as { Android: any }).Android && (window as unknown as { Android: any }).Android.savePdf) {
|
||||||
|
pdf.getBase64((data: string) => {
|
||||||
|
(window as unknown as { Android: any }).Android?.savePdf(data, filename);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
pdf.download(filename);
|
||||||
|
}
|
||||||
} catch (error) {
|
} 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 mí</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 mí</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 }}>Sí, 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'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Sí, 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||