Compare commits
16 Commits
ARCA
..
48ed64eb87
| Author | SHA1 | Date | |
|---|---|---|---|
| 48ed64eb87 | |||
| 95b3e3698b | |||
| ee9da60b3b | |||
| f00e258819 | |||
| 33743f12b9 | |||
| b6399d28fc | |||
| e4441fa476 | |||
| c8029a6762 | |||
| 18fa523fbe | |||
| 63a85e8aab | |||
| e82d2a0ece | |||
| ff733296c8 | |||
| ef044522b4 | |||
| e6686597f0 | |||
| 9e090c465a | |||
| b7bbef8035 |
@@ -1,11 +1,17 @@
|
||||
package com.hdrdevs.turnosxpress;
|
||||
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import android.Manifest;
|
||||
import android.os.Bundle;
|
||||
import android.os.Build;
|
||||
import android.os.Message;
|
||||
import android.content.ActivityNotFoundException;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.media.MediaScannerConnection;
|
||||
import android.net.Uri;
|
||||
import android.util.Log;
|
||||
import android.view.KeyEvent;
|
||||
@@ -33,6 +39,7 @@ import android.content.ContentResolver;
|
||||
public class MainActivity extends AppCompatActivity {
|
||||
|
||||
private static final int LOGIN_REQUEST_CODE = 1;
|
||||
private static final int STORAGE_PERMISSION_REQUEST_CODE = 2;
|
||||
private WebView webView;
|
||||
private ValueCallback<Uri[]> fileChooserCallback;
|
||||
|
||||
@@ -43,6 +50,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
tokenResult = "";
|
||||
requestLegacyStoragePermissionIfNeeded();
|
||||
|
||||
// For Android Emulator, use 10.0.2.2 to access localhost on your development machine.
|
||||
// If testing on a physical device, replace 10.0.2.2 with your development machine's actual local IP address.
|
||||
@@ -220,6 +228,28 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
}
|
||||
|
||||
private void requestLegacyStoragePermissionIfNeeded() {
|
||||
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P
|
||||
&& ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.WRITE_EXTERNAL_STORAGE}, STORAGE_PERMISSION_REQUEST_CODE);
|
||||
}
|
||||
}
|
||||
|
||||
private String safePdfFilename(String filename) {
|
||||
String safeName = filename == null ? "reporte.pdf" : filename.trim();
|
||||
safeName = safeName.replaceAll("[\\\\/:*?\"<>|]", "_");
|
||||
|
||||
if (safeName.length() == 0) {
|
||||
safeName = "reporte.pdf";
|
||||
}
|
||||
|
||||
if (!safeName.toLowerCase().endsWith(".pdf")) {
|
||||
safeName += ".pdf";
|
||||
}
|
||||
|
||||
return safeName;
|
||||
}
|
||||
|
||||
public class WebAppInterface {
|
||||
@JavascriptInterface
|
||||
public void startGoogleLogin() {
|
||||
@@ -235,16 +265,20 @@ public class MainActivity extends AppCompatActivity {
|
||||
|
||||
@JavascriptInterface
|
||||
public void savePdf(String base64Data, String filename) {
|
||||
String safeFilename = safePdfFilename(filename);
|
||||
|
||||
try {
|
||||
byte[] pdfAsBytes = Base64.decode(base64Data, 0);
|
||||
String normalizedBase64 = base64Data == null ? "" : base64Data.replaceFirst("^data:application/pdf;base64,", "");
|
||||
byte[] pdfAsBytes = Base64.decode(normalizedBase64, Base64.DEFAULT);
|
||||
boolean isSaved = false;
|
||||
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
ContentResolver resolver = getContentResolver();
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, filename);
|
||||
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, safeFilename);
|
||||
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, "application/pdf");
|
||||
contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS);
|
||||
contentValues.put(MediaStore.MediaColumns.IS_PENDING, 1);
|
||||
|
||||
Uri uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues);
|
||||
|
||||
@@ -253,18 +287,31 @@ public class MainActivity extends AppCompatActivity {
|
||||
outputStream.write(pdfAsBytes);
|
||||
isSaved = true;
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
Log.e("WEBVIEW", "Error writing PDF to MediaStore: " + safeFilename, e);
|
||||
resolver.delete(uri, null, null);
|
||||
}
|
||||
|
||||
if (isSaved) {
|
||||
ContentValues completedValues = new ContentValues();
|
||||
completedValues.put(MediaStore.MediaColumns.IS_PENDING, 0);
|
||||
resolver.update(uri, completedValues, null, null);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
File path = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
|
||||
File file = new File(path, filename);
|
||||
|
||||
if (!path.exists() && !path.mkdirs()) {
|
||||
Log.e("WEBVIEW", "Could not create Downloads directory: " + path.getAbsolutePath());
|
||||
} else {
|
||||
File file = new File(path, safeFilename);
|
||||
|
||||
try (FileOutputStream os = new FileOutputStream(file)) {
|
||||
os.write(pdfAsBytes);
|
||||
isSaved = true;
|
||||
MediaScannerConnection.scanFile(MainActivity.this, new String[]{file.getAbsolutePath()}, new String[]{"application/pdf"}, null);
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
Log.e("WEBVIEW", "Error writing PDF to Downloads: " + safeFilename, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,7 +322,7 @@ public class MainActivity extends AppCompatActivity {
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
Log.e("WEBVIEW", "Error saving PDF: " + safeFilename, e);
|
||||
runOnUiThread(() -> Toast.makeText(MainActivity.this, "Error: " + e.getMessage(), Toast.LENGTH_LONG).show());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/mongoose": "^5.11.96",
|
||||
"@types/node": "^24.3.3",
|
||||
"cross-env": "^10.1.0",
|
||||
"jest": "^30.4.2",
|
||||
"ts-jest": "^29.4.11",
|
||||
"ts-node": "^10.9.2",
|
||||
@@ -600,6 +601,13 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@epic-web/invariant": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
|
||||
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@isaacs/cliui": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||
@@ -2322,6 +2330,24 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
|
||||
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@epic-web/invariant": "^1.0.0",
|
||||
"cross-spawn": "^7.0.6"
|
||||
},
|
||||
"bin": {
|
||||
"cross-env": "dist/bin/cross-env.js",
|
||||
"cross-env-shell": "dist/bin/cross-env-shell.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"type": "module",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "NODE_NO_WARNINGS=1 DOTENV_DISABLE_TELEMETRY=1 node --loader ts-node/esm src/index.ts",
|
||||
"dev": "cross-env NODE_NO_WARNINGS=1 DOTENV_DISABLE_TELEMETRY=1 node --loader ts-node/esm src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"test": "jest --config jest.config.cjs"
|
||||
@@ -18,6 +18,7 @@
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/mongoose": "^5.11.96",
|
||||
"@types/node": "^24.3.3",
|
||||
"cross-env": "^10.1.0",
|
||||
"jest": "^30.4.2",
|
||||
"ts-jest": "^29.4.11",
|
||||
"ts-node": "^10.9.2",
|
||||
|
||||
@@ -91,12 +91,17 @@ export async function dispatchEmail(params: {
|
||||
systemToken: string;
|
||||
type: string;
|
||||
email: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
subject?: string;
|
||||
message?: string;
|
||||
templateId?: string;
|
||||
context?: Record<string, unknown>;
|
||||
}): Promise<DispatchResult> {
|
||||
try {
|
||||
if (!params.email.trim() || !params.subject.trim() || !params.message.trim()) {
|
||||
return { success: false, error: "Email dispatch requires email, subject, and message" };
|
||||
const hasContent = Boolean(params.subject?.trim() && params.message?.trim());
|
||||
const hasTemplate = Boolean(params.templateId?.trim() && params.context);
|
||||
|
||||
if (!params.email.trim() || (!hasContent && !hasTemplate)) {
|
||||
return { success: false, error: "Email dispatch requires email and either subject/message or templateId/context" };
|
||||
}
|
||||
|
||||
// Send worker-rendered content directly so cancellation jobs survive appointment deletion.
|
||||
@@ -106,8 +111,10 @@ export async function dispatchEmail(params: {
|
||||
{
|
||||
systemToken: params.systemToken,
|
||||
email: params.email,
|
||||
subject: params.subject,
|
||||
message: params.message,
|
||||
...(params.subject ? { subject: params.subject } : {}),
|
||||
...(params.message ? { message: params.message } : {}),
|
||||
...(params.templateId ? { templateId: params.templateId } : {}),
|
||||
...(params.context ? { context: params.context } : {}),
|
||||
},
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
|
||||
@@ -202,6 +202,8 @@ export class JobProcessor {
|
||||
email: content.email || "",
|
||||
subject: content.subject,
|
||||
message: content.message,
|
||||
templateId: content.emailTemplateId,
|
||||
context: content.emailContext,
|
||||
});
|
||||
break;
|
||||
case "system":
|
||||
|
||||
@@ -45,6 +45,8 @@ export interface ResolvedNotificationContent {
|
||||
companyOwnerId?: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
emailTemplateId?: string;
|
||||
emailContext?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const appointmentSchema = new Schema(
|
||||
@@ -169,6 +171,8 @@ export async function resolveNotificationContent(job: INotificationJobDocument):
|
||||
companyOwnerId: companyOwnerId ? String(companyOwnerId) : undefined,
|
||||
subject: fallbackSubject.trim(),
|
||||
message: fallbackMessage.trim(),
|
||||
emailTemplateId: job.payload.emailTemplateId?.trim(),
|
||||
emailContext: job.payload.emailContext,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface NotificationJobPayload {
|
||||
message?: string;
|
||||
emailSubject?: string;
|
||||
emailMessage?: string;
|
||||
emailTemplateId?: string;
|
||||
emailContext?: Record<string, unknown>;
|
||||
wapMessage?: string;
|
||||
systemSubject?: string;
|
||||
systemMessage?: string;
|
||||
|
||||
@@ -43,6 +43,30 @@ describe("ChannelDispatchers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("sends email template data when provided", async () => {
|
||||
await dispatchEmail({
|
||||
appointmentId: "appointment-1",
|
||||
systemToken: "system-token",
|
||||
type: "reminder",
|
||||
email: "client@example.com",
|
||||
templateId: "template-1",
|
||||
context: { username: "Client Name" },
|
||||
});
|
||||
|
||||
expect(mockedAxios.post).toHaveBeenCalledWith(
|
||||
"https://api.example.com/notifications/send-email",
|
||||
{
|
||||
systemToken: "system-token",
|
||||
email: "client@example.com",
|
||||
templateId: "template-1",
|
||||
context: { username: "Client Name" },
|
||||
},
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("sends the job type in the WhatsApp system notification payload", async () => {
|
||||
await dispatchWhatsApp({
|
||||
companyId: "company-1",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
mO9PXuuYR05od8cMVCIXEw
|
||||
@@ -0,0 +1 @@
|
||||
Frx8us2W@
|
||||
@@ -0,0 +1,3 @@
|
||||
ngrok config add-authtoken 3I94dMHXyh3wjRjGBu0oGQ8D8h4_2yBusck6ZWHJBx7WcbBFf
|
||||
|
||||
ngrok http 3000
|
||||
@@ -45,7 +45,7 @@ MP_SUBSCRIPTION_FIND_URL = https://api.mercadopago.com/preapproval/
|
||||
MP_ACCESS_TOKEN = APP_USR-8753424836448507-121412-731599daea1ffa0b278b5dcc78aa1ec8-1611134648
|
||||
MP_BACK_URL = https://turnosxpress.com.ar/landing/dashboard
|
||||
MP_CHECK_PAYMENT_MINUTES = 1
|
||||
MP_WEBHOOK_URL = https://9300-186-132-200-176.ngrok-free.app/mercadopago/webhook
|
||||
MP_WEBHOOK_URL = https://30e1-186-132-147-13.ngrok-free.app/mercadopago/webhook
|
||||
|
||||
DEFAULT_BOT_ADMIN_PORT = 3005
|
||||
|
||||
|
||||
@@ -6648,38 +6648,6 @@
|
||||
"url": "https://opencollective.com/mongoose"
|
||||
}
|
||||
},
|
||||
"node_modules/mongoose/node_modules/gaxios": {
|
||||
"version": "5.1.3",
|
||||
"resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz",
|
||||
"integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"extend": "^3.0.2",
|
||||
"https-proxy-agent": "^5.0.0",
|
||||
"is-stream": "^2.0.0",
|
||||
"node-fetch": "^2.6.9"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/mongoose/node_modules/gcp-metadata": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz",
|
||||
"integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"gaxios": "^5.0.0",
|
||||
"json-bigint": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/mongoose/node_modules/mongodb": {
|
||||
"version": "6.10.0",
|
||||
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.10.0.tgz",
|
||||
@@ -6731,56 +6699,6 @@
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"node_modules/mongoose/node_modules/node-fetch": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/mongoose/node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/mongoose/node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/mongoose/node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mpath": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { ScheduleItem } from "../Schedules/Schedules.Interface";
|
||||
import { ISchedulesDisabled } from "../SchedulesDisabled/SchedulesDisabled.Interface";
|
||||
import { ISchedulesOverrides } from "../SchedulesOverrides/SchedulesOverrides.Interface";
|
||||
|
||||
export type CollaboratorScheduleDetailsParams = {
|
||||
companyId: string;
|
||||
employeeId: string;
|
||||
sessionUser: string;
|
||||
fromDate?: Date;
|
||||
};
|
||||
|
||||
export type CollaboratorScheduleDetailsCollaborator = {
|
||||
employeeId: string;
|
||||
fullName: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
export type CollaboratorWeeklySchedule = {
|
||||
companyId: string;
|
||||
employeeId: string;
|
||||
weekDay: number;
|
||||
scheduleId: string;
|
||||
schedules: ScheduleItem[];
|
||||
};
|
||||
|
||||
export type CollaboratorScheduleServiceSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type CollaboratorScheduleDetailsView = {
|
||||
collaborator: CollaboratorScheduleDetailsCollaborator;
|
||||
servicesById: Record<string, CollaboratorScheduleServiceSummary>;
|
||||
weeklySchedules: CollaboratorWeeklySchedule[];
|
||||
futureDisabledSchedules: ISchedulesDisabled[];
|
||||
futureOverrides: ISchedulesOverrides[];
|
||||
};
|
||||
|
||||
export interface IAdminAssistantManager {
|
||||
getCollaboratorScheduleDetails(
|
||||
data: CollaboratorScheduleDetailsParams
|
||||
): Promise<CollaboratorScheduleDetailsView>;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import dayjs from "dayjs";
|
||||
import EmployeesList from "../Employees/Employee";
|
||||
import ServicesList from "../Services/Service";
|
||||
import SchedulesList from "../Schedules/Schedules";
|
||||
import SchedulesDisabledList from "../SchedulesDisabled/SchedulesDisabled";
|
||||
import SchedulesOverridesList from "../SchedulesOverrides/SchedulesOverrides";
|
||||
import UsersManager from "../Users/Users";
|
||||
import getAvatar from "../../helpers/getAvatar";
|
||||
import { joinStrings } from "../../helpers/String";
|
||||
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
|
||||
import {
|
||||
CollaboratorScheduleDetailsParams,
|
||||
CollaboratorScheduleDetailsView,
|
||||
IAdminAssistantManager,
|
||||
} from "./AdminAssistant.Interface";
|
||||
|
||||
class AdminAssistantManager implements IAdminAssistantManager {
|
||||
public async getCollaboratorScheduleDetails(
|
||||
data: CollaboratorScheduleDetailsParams
|
||||
): Promise<CollaboratorScheduleDetailsView> {
|
||||
await validateSessionUser({ sessionUser: data.sessionUser });
|
||||
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
|
||||
|
||||
const employee = await EmployeesList.employees.findOne({
|
||||
_id: data.employeeId,
|
||||
companyId: data.companyId,
|
||||
});
|
||||
|
||||
if (!employee) {
|
||||
throw new Error("El colaborador no existe o no pertenece a la organización.");
|
||||
}
|
||||
|
||||
const user = await UsersManager.users.findOne({ _id: String(employee.userId) });
|
||||
const snapshot = employee.profileSnapshot || {};
|
||||
const fullName = user
|
||||
? joinStrings([user.firstName, user.lastName], " ")
|
||||
: joinStrings([snapshot.firstName, snapshot.lastName], " ");
|
||||
const email = user?.email || snapshot.email || "";
|
||||
const avatar = user
|
||||
? getAvatar(String(user.id), user.avatar, fullName)
|
||||
: getAvatar(String(employee.userId), snapshot.avatar || "", fullName);
|
||||
const fromDate = data.fromDate ? dayjs(data.fromDate).startOf("day").toDate() : dayjs().startOf("day").toDate();
|
||||
|
||||
const weeklySchedules = await SchedulesList.find({
|
||||
companyId: data.companyId,
|
||||
employeeId: data.employeeId,
|
||||
});
|
||||
const futureDisabledSchedules = await SchedulesDisabledList.find({
|
||||
companyId: data.companyId,
|
||||
employeeId: data.employeeId,
|
||||
endDate: { $gte: fromDate } as any,
|
||||
sessionUser: data.sessionUser,
|
||||
});
|
||||
const futureOverrides = await SchedulesOverridesList.find({
|
||||
companyId: data.companyId,
|
||||
employeeId: data.employeeId,
|
||||
date: { $gte: fromDate } as any,
|
||||
});
|
||||
const companyServices = await ServicesList.findByCompanyId({ companyId: data.companyId });
|
||||
const servicesById = companyServices.reduce((index, service) => {
|
||||
index[service.id] = {
|
||||
id: service.id,
|
||||
name: service.name,
|
||||
};
|
||||
return index;
|
||||
}, {} as CollaboratorScheduleDetailsView["servicesById"]);
|
||||
|
||||
return {
|
||||
collaborator: {
|
||||
employeeId: String(employee.id || employee._id),
|
||||
fullName: fullName || email || "Colaborador",
|
||||
email,
|
||||
avatar,
|
||||
userId: String(employee.userId),
|
||||
},
|
||||
servicesById,
|
||||
weeklySchedules: weeklySchedules
|
||||
.sort((a, b) => a.weekDay - b.weekDay)
|
||||
.map((schedule) => ({
|
||||
companyId: String(schedule.companyId),
|
||||
employeeId: String(schedule.employeeId),
|
||||
scheduleId: String(schedule.id || (schedule as any)._id || ""),
|
||||
weekDay: schedule.weekDay,
|
||||
schedules: schedule.schedules || [],
|
||||
})),
|
||||
futureDisabledSchedules: futureDisabledSchedules.sort((a, b) =>
|
||||
new Date(a.startDate).getTime() - new Date(b.startDate).getTime()
|
||||
),
|
||||
futureOverrides: futureOverrides.sort((a, b) =>
|
||||
new Date(a.date).getTime() - new Date(b.date).getTime()
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const AdminAssistantList = new AdminAssistantManager();
|
||||
|
||||
export default AdminAssistantList;
|
||||
@@ -46,6 +46,7 @@ export type SendAppointmentNotificationParams = {
|
||||
systemToken?: string;
|
||||
type: APPOINTMENT_NOTIFICATION_TYPE;
|
||||
channel?: "whatsapp" | "email";
|
||||
allowClientCancellation?: boolean;
|
||||
};
|
||||
|
||||
export type CreateImmediateAppointmentNotificationJobsParams = {
|
||||
@@ -203,6 +204,8 @@ export interface AppointmentNotificationIntent {
|
||||
message: string;
|
||||
checkClient: IClientDocument;
|
||||
companyCheck: ICompanyDocument;
|
||||
emailTemplateId?: string;
|
||||
emailContext?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface IAppointment {
|
||||
|
||||
@@ -287,6 +287,23 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
return parseInt(`${process.env.CANCELLATION_TIME}`) || 24;
|
||||
}
|
||||
|
||||
private formatAppointmentDuration(startHour: number, endHour: number): string {
|
||||
const durationHours = endHour >= startHour ? endHour - startHour : endHour + 24 - startHour;
|
||||
const durationMinutes = Math.round(durationHours * 60);
|
||||
const hours = Math.floor(durationMinutes / 60);
|
||||
const minutes = durationMinutes % 60;
|
||||
|
||||
if (hours > 0 && minutes > 0) {
|
||||
return `${hours} ${hours === 1 ? "hora" : "horas"} y ${minutes} minutos`;
|
||||
}
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours} ${hours === 1 ? "hora" : "horas"}`;
|
||||
}
|
||||
|
||||
return `${minutes} minutos`;
|
||||
}
|
||||
|
||||
private validateCancellationTime(aTime: number | undefined, start: string | Date): boolean {
|
||||
let cancellationTime = this.getCancellationTime(aTime);
|
||||
|
||||
@@ -670,6 +687,47 @@ class AppointmentManager implements IAppointmentsManager {
|
||||
// Update both heatmaps
|
||||
await this.updateHeatMap(originalConfig);
|
||||
await this.updateHeatMap(newConfig);
|
||||
|
||||
// Reprogram pending notification jobs for the new date/time. The appointment
|
||||
// was already saved, so tryToSendNotification reads the new start date and
|
||||
// builds the reminder content (email template included) from it.
|
||||
try {
|
||||
// Cancel the jobs scheduled for the original date/time so they don't fire late.
|
||||
await this.jobService.cancelByAppointment(String(appointment._id));
|
||||
|
||||
const reminderEmailContent = await this.tryToSendNotification({
|
||||
appointmentId: String(appointment._id),
|
||||
sessionUser: data.sessionUser || "",
|
||||
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
|
||||
channel: "email",
|
||||
});
|
||||
const reminderWapContent = await this.tryToSendNotification({
|
||||
appointmentId: String(appointment._id),
|
||||
sessionUser: data.sessionUser || "",
|
||||
type: APPOINTMENT_NOTIFICATION_TYPE.REMINDER,
|
||||
channel: "whatsapp",
|
||||
});
|
||||
|
||||
await this.createReminderNotificationJobs({
|
||||
appointmentId: String(appointment._id),
|
||||
companyId: String(appointment.companyId),
|
||||
clientId: String(appointment.clientId),
|
||||
clientUserId: reminderEmailContent.checkClient.userId
|
||||
? String(reminderEmailContent.checkClient.userId)
|
||||
: undefined,
|
||||
clientEmail: reminderEmailContent.checkClient.email,
|
||||
clientPhoneNumber: await this.getOptionalClientWapNumber(reminderEmailContent.checkClient),
|
||||
companyOwnerId: String(reminderEmailContent.companyCheck.ownerId),
|
||||
companyName: reminderEmailContent.companyCheck.name,
|
||||
appointmentStart: appointment.start,
|
||||
reminderEmailMessage: reminderEmailContent.message,
|
||||
reminderWapMessage: reminderWapContent.message,
|
||||
reminderEmailTemplateId: reminderEmailContent.emailTemplateId,
|
||||
reminderEmailContext: reminderEmailContent.emailContext,
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error reprogramming notification jobs after move:", error);
|
||||
}
|
||||
}
|
||||
|
||||
public async changeServiceForce(data: ChangeServiceParams): Promise<void> {
|
||||
@@ -2168,6 +2226,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
companyOwnerId: string;
|
||||
companyName: string;
|
||||
emailMessage: string;
|
||||
emailTemplateId?: string;
|
||||
emailContext?: Record<string, unknown>;
|
||||
wapMessage: string;
|
||||
}): Promise<void> {
|
||||
const systemSubject = `Turno cancelado en ${data.companyName}`;
|
||||
@@ -2187,6 +2247,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
message: data.emailMessage,
|
||||
emailSubject: "TurnosXpress :: Turno cancelado",
|
||||
emailMessage: data.emailMessage,
|
||||
emailTemplateId: data.emailTemplateId,
|
||||
emailContext: data.emailContext,
|
||||
wapMessage: data.wapMessage,
|
||||
systemSubject,
|
||||
systemMessage: data.emailMessage,
|
||||
@@ -2206,8 +2268,12 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
appointmentStart: Date;
|
||||
emailMessage: string;
|
||||
wapMessage: string;
|
||||
emailTemplateId?: string;
|
||||
emailContext?: Record<string, unknown>;
|
||||
reminderEmailMessage: string;
|
||||
reminderWapMessage: string;
|
||||
reminderEmailTemplateId?: string;
|
||||
reminderEmailContext?: Record<string, unknown>;
|
||||
}): Promise<void> {
|
||||
const systemSubject = `Turno reservado en ${data.companyName}`;
|
||||
|
||||
@@ -2220,6 +2286,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
message: data.emailMessage,
|
||||
emailSubject: "TurnosXpress :: Alta de turno",
|
||||
emailMessage: data.emailMessage,
|
||||
emailTemplateId: data.emailTemplateId,
|
||||
emailContext: data.emailContext,
|
||||
wapMessage: data.wapMessage,
|
||||
systemSubject,
|
||||
systemMessage: data.emailMessage,
|
||||
@@ -2277,6 +2345,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
companyName: data.companyName,
|
||||
appointmentStart: data.appointmentStart,
|
||||
reminderEmailMessage: reminderEmailContent.message,
|
||||
reminderEmailTemplateId: reminderEmailContent.emailTemplateId,
|
||||
reminderEmailContext: reminderEmailContent.emailContext,
|
||||
reminderWapMessage: reminderWapContent.message,
|
||||
};
|
||||
|
||||
@@ -2301,6 +2371,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
await this.createCreationNotificationJobs({
|
||||
...notificationJobData,
|
||||
emailMessage: emailContent.message,
|
||||
emailTemplateId: emailContent.emailTemplateId,
|
||||
emailContext: emailContent.emailContext,
|
||||
wapMessage: wapContent.message,
|
||||
});
|
||||
}
|
||||
@@ -2317,6 +2389,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
appointmentStart: Date;
|
||||
reminderEmailMessage: string;
|
||||
reminderWapMessage: string;
|
||||
reminderEmailTemplateId?: string;
|
||||
reminderEmailContext?: Record<string, unknown>;
|
||||
}): Promise<void> {
|
||||
const reminderSystemSubject = `Recordatorio de turno en ${data.companyName}`;
|
||||
|
||||
@@ -2335,6 +2409,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
message: data.reminderEmailMessage,
|
||||
emailSubject: "TurnosXpress :: Recordatorio",
|
||||
emailMessage: data.reminderEmailMessage,
|
||||
emailTemplateId: data.reminderEmailTemplateId,
|
||||
emailContext: data.reminderEmailContext,
|
||||
wapMessage: data.reminderWapMessage,
|
||||
systemSubject: reminderSystemSubject,
|
||||
systemMessage: data.reminderEmailMessage,
|
||||
@@ -2491,12 +2567,14 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
sessionUser: data.sessionUser,
|
||||
type: APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION,
|
||||
channel: "email",
|
||||
allowClientCancellation: true,
|
||||
});
|
||||
const wapContent = await this.tryToSendNotification({
|
||||
appointmentId: String(checkAppointment._id),
|
||||
sessionUser: data.sessionUser,
|
||||
type: APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION,
|
||||
channel: "whatsapp",
|
||||
allowClientCancellation: true,
|
||||
});
|
||||
|
||||
await this.createCancellationNotificationJobs({
|
||||
@@ -2509,6 +2587,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
companyOwnerId: String(companyCheck.ownerId),
|
||||
companyName: companyCheck.name,
|
||||
emailMessage: emailContent.message,
|
||||
emailTemplateId: emailContent.emailTemplateId,
|
||||
emailContext: emailContent.emailContext,
|
||||
wapMessage: wapContent.message,
|
||||
});
|
||||
}
|
||||
@@ -2650,6 +2730,14 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
throw new Error("La compañia no existe");
|
||||
}
|
||||
|
||||
const checkClient = await ClientsManager.clients.findOne({
|
||||
_id: String(checkAppointment.clientId),
|
||||
});
|
||||
|
||||
if (!checkClient) {
|
||||
throw new Error("El cliente no existe");
|
||||
}
|
||||
|
||||
const hasSystemToken = this.hasValidSystemToken(data.systemToken);
|
||||
|
||||
if (!hasSystemToken) {
|
||||
@@ -2657,19 +2745,19 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
throw new Error("No se ha encontrado el usuario o no tiene permisos para realizar esta acción");
|
||||
}
|
||||
|
||||
if (!(await EmployeesList.checkPermission(companyCheck, data.sessionUser, EmployeeRoles.ADMIN))) {
|
||||
const isAuthorizedClientCancellation =
|
||||
data.allowClientCancellation === true &&
|
||||
data.type === APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION &&
|
||||
String(checkClient.userId) === data.sessionUser;
|
||||
|
||||
if (
|
||||
!isAuthorizedClientCancellation &&
|
||||
!(await EmployeesList.checkPermission(companyCheck, data.sessionUser, EmployeeRoles.ADMIN))
|
||||
) {
|
||||
throw new Error(NoPermissionMessage());
|
||||
}
|
||||
}
|
||||
|
||||
const checkClient = await ClientsManager.clients.findOne({
|
||||
_id: String(checkAppointment.clientId),
|
||||
});
|
||||
|
||||
if (!checkClient) {
|
||||
throw new Error("El cliente no existe");
|
||||
}
|
||||
|
||||
const checkEmployee = await EmployeesList.employees.findOne({
|
||||
_id: String(checkAppointment.employeeId),
|
||||
});
|
||||
@@ -2695,6 +2783,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
}
|
||||
|
||||
let wapMessage = "";
|
||||
let emailTemplateId: string | undefined;
|
||||
let emailContext: Record<string, unknown> | undefined;
|
||||
|
||||
if (data.type == APPOINTMENT_NOTIFICATION_TYPE.REMINDER) {
|
||||
if (companyCheck.templateWapNotifId) {
|
||||
@@ -2738,6 +2828,19 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
|
||||
wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name);
|
||||
wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n");
|
||||
|
||||
emailTemplateId = "6a86ef631898c6948d0f5308";
|
||||
emailContext = {
|
||||
username: ClientsManager.getClientFullName(checkClient),
|
||||
nombre_organizacion: companyCheck.name,
|
||||
fecha_turno: dayjs(checkAppointment.start).format("DD/MM/YYYY"),
|
||||
hora_turno: dayjs(checkAppointment.start).format("HH:mm") + "hs.",
|
||||
nombre_servicio: checkService.name,
|
||||
nombre_profesional: UsersManager.getUserFullName(checkEmployeeUser),
|
||||
duracion: this.formatAppointmentDuration(checkAppointment.startHour, checkAppointment.endHour),
|
||||
precio: formatCurrency(checkAppointment.price),
|
||||
direccion: CompaniesManager.getCompanyAddress(companyCheck),
|
||||
};
|
||||
} else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CREATION) {
|
||||
const templateId = data.channel === "email"
|
||||
? companyCheck.templateEmailAltaId || companyCheck.templateWapAltaId
|
||||
@@ -2787,6 +2890,19 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
|
||||
wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name);
|
||||
wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n");
|
||||
|
||||
emailTemplateId = "6a86f55e3edd051e8a04c711";
|
||||
emailContext = {
|
||||
username: ClientsManager.getClientFullName(checkClient),
|
||||
nombre_organizacion: companyCheck.name,
|
||||
fecha_turno: dayjs(checkAppointment.start).format("DD/MM/YYYY"),
|
||||
hora_turno: dayjs(checkAppointment.start).format("HH:mm") + "hs.",
|
||||
nombre_servicio: checkService.name,
|
||||
nombre_profesional: UsersManager.getUserFullName(checkEmployeeUser),
|
||||
duracion: this.formatAppointmentDuration(checkAppointment.startHour, checkAppointment.endHour),
|
||||
precio: formatCurrency(checkAppointment.price),
|
||||
direccion: CompaniesManager.getCompanyAddress(companyCheck),
|
||||
};
|
||||
} else if (data.type == APPOINTMENT_NOTIFICATION_TYPE.CANCELLATION || data.type == APPOINTMENT_NOTIFICATION_TYPE.UPDATE) {
|
||||
const isUpdate = data.type == APPOINTMENT_NOTIFICATION_TYPE.UPDATE;
|
||||
const templateId = data.channel === "email"
|
||||
@@ -2842,12 +2958,29 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
wapMessage = wapMessage.replaceAll("<---PRECIO--->", formatCurrency(checkAppointment.price));
|
||||
wapMessage = wapMessage.replaceAll("<---ORGANIZACION--->", companyCheck.name);
|
||||
wapMessage = wapMessage.replaceAll("<---BR--->", "\r\n");
|
||||
|
||||
emailTemplateId = isUpdate
|
||||
? "6a86febf6713e0bdd104c7ca"
|
||||
: "6a86f77d376a7371170b21c5";
|
||||
emailContext = {
|
||||
username: ClientsManager.getClientFullName(checkClient),
|
||||
nombre_organizacion: companyCheck.name,
|
||||
fecha_turno: dayjs(checkAppointment.start).format("DD/MM/YYYY"),
|
||||
hora_turno: dayjs(checkAppointment.start).format("HH:mm") + "hs.",
|
||||
nombre_servicio: checkService.name,
|
||||
nombre_profesional: UsersManager.getUserFullName(checkEmployeeUser),
|
||||
duracion: this.formatAppointmentDuration(checkAppointment.startHour, checkAppointment.endHour),
|
||||
precio: formatCurrency(checkAppointment.price),
|
||||
direccion: CompaniesManager.getCompanyAddress(companyCheck),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: wapMessage,
|
||||
checkClient: checkClient,
|
||||
companyCheck: companyCheck,
|
||||
emailTemplateId,
|
||||
emailContext,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2866,7 +2999,7 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const { message, checkClient, companyCheck } = await this.tryToSendNotification({
|
||||
const { message, checkClient, companyCheck, emailTemplateId, emailContext } = await this.tryToSendNotification({
|
||||
...data,
|
||||
channel: "email",
|
||||
});
|
||||
@@ -2903,6 +3036,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
email: clientEmail,
|
||||
subject: subjectEmail,
|
||||
message: message,
|
||||
templateId: emailTemplateId,
|
||||
context: emailContext,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2954,6 +3089,8 @@ public async updateAppointment(data: UpdateAppointmentParams): Promise<void> {
|
||||
message: emailContent.message,
|
||||
emailSubject: isCreation ? "TurnosXpress :: Alta de turno" : "TurnosXpress :: Recordatorio",
|
||||
emailMessage: emailContent.message,
|
||||
emailTemplateId: emailContent.emailTemplateId,
|
||||
emailContext: emailContent.emailContext,
|
||||
wapMessage: wapContent.message,
|
||||
systemSubject,
|
||||
systemMessage: emailContent.message,
|
||||
|
||||
@@ -157,7 +157,10 @@ export class MetricsAdapterMongoose implements IMetricsAdapter {
|
||||
if (!metrics.repeatsCount) {
|
||||
metrics.repeatsCount = 0;
|
||||
}
|
||||
metrics.repeatsCount += isNull<number>(data.quantity, 1);
|
||||
metrics.repeatsCount = Math.max(
|
||||
0,
|
||||
isNull<number>(metrics.repeatsCount, 0) + isNull<number>(data.quantity, 1)
|
||||
);
|
||||
await metrics.save();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ export interface IMetricsAdapter {
|
||||
addService(data: MetricsParams): Promise<void>;
|
||||
addAppointment(data: MetricsParams): Promise<void>;
|
||||
addClient(data: MetricsParams): Promise<void>;
|
||||
addRepeat(data: MetricsParams): Promise<void>;
|
||||
}
|
||||
|
||||
export interface IMetricsManager {
|
||||
@@ -50,6 +51,7 @@ export interface IMetricsManager {
|
||||
reserveAppointment(data: MetricsParams): Promise<boolean>;
|
||||
releaseAppointment(data: MetricsParams): Promise<void>;
|
||||
addClient(data: MetricsParams): Promise<void>;
|
||||
addRepeat(data: MetricsParams): Promise<void>;
|
||||
canAddOrganization(userId: string): Promise<boolean>;
|
||||
canAddEmployee(userId: string): Promise<boolean>;
|
||||
canAddService(userId: string): Promise<boolean>;
|
||||
|
||||
@@ -23,6 +23,8 @@ export interface NotificationJobPayload {
|
||||
message?: string;
|
||||
emailSubject?: string;
|
||||
emailMessage?: string;
|
||||
emailTemplateId?: string;
|
||||
emailContext?: Record<string, unknown>;
|
||||
wapMessage?: string;
|
||||
systemSubject?: string;
|
||||
systemMessage?: string;
|
||||
|
||||
@@ -3,13 +3,37 @@ import { INotificationsAdapter, NotificationDataEmail } from "./Notifications.In
|
||||
|
||||
export class NotificationsDonWebAdapter implements INotificationsAdapter<NotificationDataEmail> {
|
||||
async send(data: NotificationDataEmail): Promise<void> {
|
||||
const dataEmail = {
|
||||
if (data.context && data.substitutions) {
|
||||
throw new Error("No se puede enviar un email con context y substitutions al mismo tiempo.");
|
||||
}
|
||||
|
||||
if (!data.templateId && !data.message) {
|
||||
throw new Error("Debe indicarse el contenido del email mediante message o templateId.");
|
||||
}
|
||||
|
||||
const dataEmail: Record<string, unknown> = {
|
||||
from: "norepply@turnosxpress.com.ar",
|
||||
to: data.email,
|
||||
subject: data.subject,
|
||||
html: data.message,
|
||||
};
|
||||
|
||||
if (data.templateId) {
|
||||
dataEmail.templateID = data.templateId;
|
||||
} else {
|
||||
dataEmail.html = data.message;
|
||||
}
|
||||
|
||||
if (data.subject) {
|
||||
dataEmail.subject = data.subject;
|
||||
}
|
||||
|
||||
if (data.context) {
|
||||
dataEmail.context = data.context;
|
||||
}
|
||||
|
||||
if (data.substitutions) {
|
||||
dataEmail.substitutions = data.substitutions;
|
||||
}
|
||||
|
||||
const apiConfig = {
|
||||
method: "post",
|
||||
maxBodyLength: Infinity,
|
||||
|
||||
@@ -6,8 +6,11 @@ export enum NOTIFICATION_TYPES {
|
||||
|
||||
export type NotificationDataEmail = {
|
||||
email: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
subject?: string;
|
||||
message?: string;
|
||||
templateId?: string;
|
||||
context?: Record<string, unknown>;
|
||||
substitutions?: Record<string, string | number | boolean>;
|
||||
};
|
||||
|
||||
export type NotificationDataWap = {
|
||||
|
||||
@@ -212,6 +212,7 @@ class RepeatsManager implements IRepeatsManager {
|
||||
}
|
||||
|
||||
await this.repeats.delete(data.repeatId);
|
||||
await MetricsList.addRepeat({ userId: String(companyCheck.ownerId), quantity: -1 });
|
||||
}
|
||||
|
||||
public async deleteRepeatsByCompany(data: DeleteRepeatsByCompanyParams): Promise<void> {
|
||||
|
||||
@@ -8,6 +8,34 @@ export type ScheduleItem = {
|
||||
serviceIds?: string[];
|
||||
};
|
||||
|
||||
export type ScheduleConflictStrategy = "reject" | "overwrite-conflicts";
|
||||
|
||||
export type ScheduleConflictDetail = {
|
||||
employeeId: string;
|
||||
employeeName: string;
|
||||
weekDay: number;
|
||||
weekDayLabel: string;
|
||||
attempted: ScheduleItem;
|
||||
existing: ScheduleItem;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type ScheduleApplyParams = {
|
||||
companyId: string;
|
||||
employeeIds: string[];
|
||||
weekDays: number[];
|
||||
schedules: ScheduleItem[];
|
||||
sessionUser: string;
|
||||
conflictStrategy: ScheduleConflictStrategy;
|
||||
};
|
||||
|
||||
export type ScheduleApplyResult = {
|
||||
valid: boolean;
|
||||
applied: boolean;
|
||||
conflicts: ScheduleConflictDetail[];
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type FindSchedulesParams = {
|
||||
_id?: string;
|
||||
companyId?: string;
|
||||
@@ -100,6 +128,7 @@ export interface ISchedulesManager {
|
||||
findAllSchedulesByCollaborator(data: FindSchedulesParams): Promise<CollaboratorSchedulesView>;
|
||||
delete(data: DeleteScheduleParams): Promise<void>;
|
||||
isAvailable(data: AvailableSchedulesParams): Promise<boolean>;
|
||||
applyScheduleChanges(data: ScheduleApplyParams): Promise<ScheduleApplyResult>;
|
||||
deleteSchedulesByCompany(data: DeleteScheulesByCompanyParams): Promise<void>;
|
||||
deleteSchedulesByEmployee(data: DeleteSchedulesByEmployeeParams): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ import {
|
||||
DeleteScheulesByCompanyParams,
|
||||
DeleteSchedulesByEmployeeParams,
|
||||
ScheduleItem,
|
||||
ScheduleApplyParams,
|
||||
ScheduleApplyResult,
|
||||
ScheduleConflictDetail,
|
||||
} from "./Schedules.Interface";
|
||||
import { EmployeeRoles } from "../Employees/Employees.Interface";
|
||||
import UsersManager from "../Users/Users";
|
||||
@@ -24,6 +27,9 @@ import { isNull } from "../../helpers/IsNull";
|
||||
import getAvatar from "../../helpers/getAvatar";
|
||||
import { NotificationsManager } from "../Notifications/Notifications";
|
||||
import { NotificationType } from "../../Models/SystemNotifications/SystemNotification.Interface";
|
||||
import { validatePermissionsByCompany, validateSessionUser } from "../../helpers/check";
|
||||
|
||||
const WEEK_DAY_LABELS = ["Domingo", "Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado"];
|
||||
|
||||
class SchedulesManager implements ISchedulesManager {
|
||||
schedules: SchedulesAdapterMongoose;
|
||||
@@ -173,6 +179,169 @@ class SchedulesManager implements ISchedulesManager {
|
||||
return await this.schedules.find(filters);
|
||||
}
|
||||
|
||||
private parseTimeToMinutes(time: string): number {
|
||||
const [hours, minutes] = time.split(":").map((part) => Number(part));
|
||||
|
||||
if (!Number.isInteger(hours) || !Number.isInteger(minutes)) {
|
||||
throw new Error("El horario indicado no es válido");
|
||||
}
|
||||
|
||||
return hours * 60 + minutes;
|
||||
}
|
||||
|
||||
private hasTimeOverlap(first: ScheduleItem, second: ScheduleItem): boolean {
|
||||
return this.parseTimeToMinutes(first.from) < this.parseTimeToMinutes(second.to)
|
||||
&& this.parseTimeToMinutes(second.from) < this.parseTimeToMinutes(first.to);
|
||||
}
|
||||
|
||||
private getServiceScope(schedule: ScheduleItem): "all" | "specific" {
|
||||
return schedule.serviceScope || "all";
|
||||
}
|
||||
|
||||
private hasServiceOverlap(first: ScheduleItem, second: ScheduleItem): boolean {
|
||||
const firstScope = this.getServiceScope(first);
|
||||
const secondScope = this.getServiceScope(second);
|
||||
|
||||
if (firstScope === "all" || secondScope === "all") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const secondServices = new Set(second.serviceIds || []);
|
||||
return (first.serviceIds || []).some((serviceId) => secondServices.has(serviceId));
|
||||
}
|
||||
|
||||
private hasScheduleConflict(first: ScheduleItem, second: ScheduleItem): boolean {
|
||||
if (first.disabled || second.disabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.hasTimeOverlap(first, second) && this.hasServiceOverlap(first, second);
|
||||
}
|
||||
|
||||
private formatSchedule(schedule: ScheduleItem): string {
|
||||
if (this.getServiceScope(schedule) === "all") {
|
||||
return `${schedule.from} a ${schedule.to} para todos los servicios`;
|
||||
}
|
||||
|
||||
return `${schedule.from} a ${schedule.to} para servicios específicos`;
|
||||
}
|
||||
|
||||
private getEmployeeName(employee: any): string {
|
||||
const snapshotName = [employee.profileSnapshot?.firstName, employee.profileSnapshot?.lastName].filter(Boolean).join(" ").trim();
|
||||
return snapshotName || employee.fullName || employee.name || String(employee._id);
|
||||
}
|
||||
|
||||
private buildConflict(employee: any, weekDay: number, attempted: ScheduleItem, existing: ScheduleItem): ScheduleConflictDetail {
|
||||
const employeeId = String(employee._id);
|
||||
const employeeName = this.getEmployeeName(employee);
|
||||
const weekDayLabel = WEEK_DAY_LABELS[weekDay] || String(weekDay);
|
||||
|
||||
return {
|
||||
employeeId,
|
||||
employeeName,
|
||||
weekDay,
|
||||
weekDayLabel,
|
||||
attempted,
|
||||
existing,
|
||||
message: `${employeeName} ya tiene un horario el ${weekDayLabel} de ${this.formatSchedule(existing)} que se superpone con ${this.formatSchedule(attempted)}.`,
|
||||
};
|
||||
}
|
||||
|
||||
private async findScheduleDocument(companyId: string, employeeId: string, weekDay: number) {
|
||||
return this.schedules.findOne({ companyId, employeeId, weekDay });
|
||||
}
|
||||
|
||||
public async applyScheduleChanges(data: ScheduleApplyParams): Promise<ScheduleApplyResult> {
|
||||
await validateSessionUser({ sessionUser: data.sessionUser });
|
||||
await validatePermissionsByCompany({ companyId: data.companyId, sessionUser: data.sessionUser });
|
||||
|
||||
if (!Array.isArray(data.employeeIds) || data.employeeIds.length === 0) {
|
||||
throw new Error("Tenés que seleccionar al menos un colaborador.");
|
||||
}
|
||||
|
||||
if (!Array.isArray(data.weekDays) || data.weekDays.length === 0 || data.weekDays.some((day) => !Number.isInteger(day) || day < 0 || day > 6)) {
|
||||
throw new Error("Tenés que seleccionar días válidos entre 0 y 6.");
|
||||
}
|
||||
|
||||
if (data.conflictStrategy !== "reject" && data.conflictStrategy !== "overwrite-conflicts") {
|
||||
throw new Error("La estrategia de conflictos no es válida.");
|
||||
}
|
||||
|
||||
const employees = await Promise.all(data.employeeIds.map(async (employeeId) => {
|
||||
const employee = await EmployeesList.employees.findOne({ companyId: data.companyId, _id: employeeId });
|
||||
|
||||
if (!employee) {
|
||||
throw new Error("El empleado no existe o no pertenece a la compañia.");
|
||||
}
|
||||
|
||||
return employee;
|
||||
}));
|
||||
|
||||
const conflicts: ScheduleConflictDetail[] = [];
|
||||
|
||||
for (const employee of employees) {
|
||||
const employeeId = String(employee._id);
|
||||
|
||||
for (const weekDay of data.weekDays) {
|
||||
const scheduleDocument = await this.findScheduleDocument(data.companyId, employeeId, weekDay);
|
||||
const existingSchedules = scheduleDocument?.schedules || [];
|
||||
|
||||
for (const attempted of data.schedules) {
|
||||
const existing = existingSchedules.find((schedule) => this.hasScheduleConflict(attempted, schedule));
|
||||
|
||||
if (existing) {
|
||||
conflicts.push(this.buildConflict(employee, weekDay, attempted, existing));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (conflicts.length > 0 && data.conflictStrategy === "reject") {
|
||||
return {
|
||||
valid: false,
|
||||
applied: false,
|
||||
conflicts,
|
||||
message: "Encontramos horarios superpuestos. Revisalos antes de guardar o elegí sobrescribir solo esos conflictos.",
|
||||
};
|
||||
}
|
||||
|
||||
for (const employee of employees) {
|
||||
const employeeId = String(employee._id);
|
||||
|
||||
for (const weekDay of data.weekDays) {
|
||||
const scheduleDocument = await this.findScheduleDocument(data.companyId, employeeId, weekDay);
|
||||
const existingSchedules = scheduleDocument?.schedules || [];
|
||||
const nextSchedules = data.conflictStrategy === "overwrite-conflicts"
|
||||
? existingSchedules.filter((existing) => !data.schedules.some((attempted) => this.hasScheduleConflict(attempted, existing)))
|
||||
: existingSchedules;
|
||||
|
||||
const schedules = [...nextSchedules, ...data.schedules];
|
||||
|
||||
if (scheduleDocument) {
|
||||
scheduleDocument.schedules = schedules;
|
||||
await scheduleDocument.save();
|
||||
} else {
|
||||
await this.schedules.create({
|
||||
companyId: data.companyId,
|
||||
employeeId,
|
||||
weekDay,
|
||||
schedules,
|
||||
sessionUser: data.sessionUser,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: conflicts.length === 0,
|
||||
applied: true,
|
||||
conflicts,
|
||||
message: conflicts.length > 0
|
||||
? "Se sobrescribieron solo los horarios en conflicto."
|
||||
: "Los horarios se guardaron correctamente.",
|
||||
};
|
||||
}
|
||||
|
||||
private isScheduleAvailableForService(schedule: ScheduleItem, serviceId?: string): boolean {
|
||||
if (!serviceId || !schedule.serviceScope || schedule.serviceScope === "all") {
|
||||
return true;
|
||||
|
||||
@@ -32,6 +32,12 @@ export class SchedulesDisabledAdapterMongoose implements ISchedulesDisabledAdapt
|
||||
await this.schedulesDisabledList.deleteOne({ _id: id }).exec();
|
||||
}
|
||||
|
||||
public async find(
|
||||
filters: Omit<FindSchedulesDisabledParams, "sessionUser">
|
||||
): Promise<ISchedulesDisabledDocument[]> {
|
||||
return this.schedulesDisabledList.find(filters).exec();
|
||||
}
|
||||
|
||||
public async findOne(
|
||||
filters: Omit<FindSchedulesDisabledParams, "sessionUser">
|
||||
): Promise<ISchedulesDisabledDocument | null> {
|
||||
|
||||
@@ -90,6 +90,7 @@ export interface SchedulesDisabledByCollaboratorView {
|
||||
export interface ISchedulesDisabledAdapter {
|
||||
create(data: CreateSchedulesDisabledParams): Promise<ISchedulesDisabled>;
|
||||
delete(id: string): Promise<void>;
|
||||
find(filters: Omit<FindSchedulesDisabledParams, "sessionUser">): Promise<ISchedulesDisabled[]>;
|
||||
findOne(filters: FindSchedulesDisabledParams): Promise<ISchedulesDisabled | null>;
|
||||
}
|
||||
|
||||
@@ -98,6 +99,7 @@ export interface ISchedulesDisabledManager {
|
||||
createSchedulesDisabled(data: CreateSchedulesDisabledParams): Promise<ISchedulesDisabled>;
|
||||
disableSchedule(data: DisableScheduleParams): Promise<void>;
|
||||
enableSchedule(data: DisableScheduleParams): Promise<void>;
|
||||
find(data: FindSchedulesDisabledParams): Promise<ISchedulesDisabled[]>;
|
||||
findOne(data: FindSchedulesDisabledParams): Promise<ISchedulesDisabled | null>;
|
||||
findSchedulesDisabledByCollaborator(
|
||||
data: FindSchedulesDisabledParams
|
||||
|
||||
@@ -271,6 +271,17 @@ class SchedulesDisabledManager implements ISchedulesDisabledManager {
|
||||
return await this.schedulesDisabled.findOne(data);
|
||||
}
|
||||
|
||||
public async find(data: FindSchedulesDisabledParams): Promise<ISchedulesDisabled[]> {
|
||||
await validateSessionUser({ sessionUser: data.sessionUser });
|
||||
await validatePermissionsByCompany({
|
||||
sessionUser: data.sessionUser,
|
||||
companyId: data.companyId,
|
||||
});
|
||||
|
||||
const { sessionUser, ...filters } = data;
|
||||
return await this.schedulesDisabled.find(filters);
|
||||
}
|
||||
|
||||
public async delete(data: DeleteSchedulesDisabledParams): Promise<void> {
|
||||
await validateSessionUser({ sessionUser: data.sessionUser });
|
||||
|
||||
|
||||
@@ -351,6 +351,28 @@ class UsersManager implements IUsersManager {
|
||||
.substring(0, 6);
|
||||
}
|
||||
|
||||
private async sendWelcomeEmail(email: string, firstName?: string): Promise<void> {
|
||||
try {
|
||||
await NotificationsManager.sendEmail({
|
||||
email: cleanEmail(email),
|
||||
templateId: "68139f23f3e003139b08b4f0",
|
||||
context: {
|
||||
username: firstName || cleanEmail(email),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
console.error("Error enviando email de bienvenida:", {
|
||||
status: e.response?.status,
|
||||
data: e.response?.data,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("Error enviando email de bienvenida:", e);
|
||||
}
|
||||
}
|
||||
|
||||
public async signUp(data: SignUpParams): Promise<IUser> {
|
||||
// Destructure email and password from the data object
|
||||
const {
|
||||
@@ -406,12 +428,13 @@ class UsersManager implements IUsersManager {
|
||||
|
||||
const newUser = await this.users.create(newUserData);
|
||||
|
||||
const signUpUrl = "https://turnosxpress.com.ar/landing/login/verification";
|
||||
|
||||
await NotificationsManager.sendEmail({
|
||||
email: cleanEmail(email),
|
||||
subject: "TurnosXpress :: Bienvenido/a",
|
||||
message: `Hola ${firstName}, Gracias por registrarte en turnosXpress. Primero debes activar tu cuenta ingresando en ${signUpUrl} . Tu código de verificación es: ${verificationCode}`,
|
||||
templateId: "6a85dd644ea98ccfcd05bdc7",
|
||||
context: {
|
||||
username: firstName || cleanEmail(email),
|
||||
verificationcode: verificationCode,
|
||||
},
|
||||
});
|
||||
|
||||
return newUser;
|
||||
@@ -459,8 +482,11 @@ class UsersManager implements IUsersManager {
|
||||
|
||||
await NotificationsManager.sendEmail({
|
||||
email: cleanEmail(data.email),
|
||||
subject: "TurnosXpress :: Recuperación de cuenta",
|
||||
message: `Hola ${user.firstName}, Te enviamos un código de verificación para recuperar tu cuenta. Tu código de verificación es: ${code}. Ingresa https://turnosxpress.com.ar/landing/recover-account/verify para establecer una nueva clave`,
|
||||
templateId: "6a85c7ad96475f2825015277",
|
||||
context: {
|
||||
username: user.firstName || cleanEmail(data.email),
|
||||
verificationcode: code,
|
||||
},
|
||||
});
|
||||
|
||||
return;
|
||||
@@ -515,10 +541,16 @@ class UsersManager implements IUsersManager {
|
||||
throw new Error("El código de verificación es incorrecto");
|
||||
}
|
||||
|
||||
const wasVerificated = Boolean(userTest.verificated);
|
||||
|
||||
userTest.verificated = true;
|
||||
|
||||
await userTest.save();
|
||||
|
||||
if (!wasVerificated) {
|
||||
await this.sendWelcomeEmail(userTest.email, userTest.firstName);
|
||||
}
|
||||
|
||||
try {
|
||||
const freePlan = await PlansList.plans.findOne({ price: 0 });
|
||||
if (freePlan) {
|
||||
@@ -724,11 +756,17 @@ class UsersManager implements IUsersManager {
|
||||
});
|
||||
|
||||
if (userCheck) {
|
||||
const wasVerificated = Boolean(userCheck.verificated);
|
||||
|
||||
userCheck.external_id = googleUserId;
|
||||
userCheck.external_service = "google";
|
||||
userCheck.verificated = true;
|
||||
await userCheck.save();
|
||||
|
||||
if (!wasVerificated) {
|
||||
await this.sendWelcomeEmail(userCheck.email, userCheck.firstName);
|
||||
}
|
||||
|
||||
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({
|
||||
sessionUser: userCheck.id,
|
||||
});
|
||||
@@ -770,6 +808,8 @@ class UsersManager implements IUsersManager {
|
||||
throw new Error("Error creating user");
|
||||
}
|
||||
|
||||
await this.sendWelcomeEmail(newUser.email, newUser.firstName);
|
||||
|
||||
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({
|
||||
sessionUser: newUser.id,
|
||||
});
|
||||
@@ -821,11 +861,17 @@ class UsersManager implements IUsersManager {
|
||||
});
|
||||
|
||||
if (userCheck) {
|
||||
const wasVerificated = Boolean(userCheck.verificated);
|
||||
|
||||
userCheck.external_id = googleData.id;
|
||||
userCheck.external_service = "google";
|
||||
userCheck.verificated = true;
|
||||
await userCheck.save();
|
||||
|
||||
if (!wasVerificated) {
|
||||
await this.sendWelcomeEmail(userCheck.email, userCheck.firstName);
|
||||
}
|
||||
|
||||
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({
|
||||
sessionUser: userCheck.id,
|
||||
});
|
||||
@@ -867,6 +913,8 @@ class UsersManager implements IUsersManager {
|
||||
throw new Error("Error creating user");
|
||||
}
|
||||
|
||||
await this.sendWelcomeEmail(newUser.email, newUser.firstName);
|
||||
|
||||
const subscription = await PlanSubscriptionsList.getSubscriptionByUser({
|
||||
sessionUser: newUser.id,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Body, Controller, Middlewares, Post, Response, Route, SuccessResponse } from "tsoa";
|
||||
import { ApiValidationError } from "../../Models/Server.Error.model";
|
||||
import {
|
||||
CollaboratorScheduleDetailsParams,
|
||||
CollaboratorScheduleDetailsView,
|
||||
} from "../../Models/AdminAssistant/AdminAssistant.Interface";
|
||||
import { authenticateMiddleware } from "../../middleware/authentication";
|
||||
import { AdminAssistantService } from "./AdminAssistant.Service";
|
||||
|
||||
@Route("admin-assistant/collaborator-schedule-details")
|
||||
@Middlewares(authenticateMiddleware)
|
||||
export class GetCollaboratorScheduleDetailsController extends Controller {
|
||||
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post()
|
||||
public async getCollaboratorScheduleDetails(
|
||||
@Body() requestBody: CollaboratorScheduleDetailsParams
|
||||
): Promise<CollaboratorScheduleDetailsView | ApiValidationError> {
|
||||
try {
|
||||
const details = await new AdminAssistantService().getCollaboratorScheduleDetails(requestBody);
|
||||
this.setStatus(200);
|
||||
return details;
|
||||
} catch (e) {
|
||||
const errorOccurred: Error = e as Error;
|
||||
this.setStatus(500);
|
||||
return new ApiValidationError(500, errorOccurred.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { connect } from "mongoose";
|
||||
import AdminAssistantList from "../../Models/AdminAssistant/AdminAssistant";
|
||||
import {
|
||||
CollaboratorScheduleDetailsParams,
|
||||
CollaboratorScheduleDetailsView,
|
||||
} from "../../Models/AdminAssistant/AdminAssistant.Interface";
|
||||
|
||||
export class AdminAssistantService {
|
||||
public async getCollaboratorScheduleDetails(
|
||||
data: CollaboratorScheduleDetailsParams
|
||||
): Promise<CollaboratorScheduleDetailsView> {
|
||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||
|
||||
return await AdminAssistantList.getCollaboratorScheduleDetails(data);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,9 @@ import dayjs from "dayjs";
|
||||
import PlanPaymentsList from "../../Models/PlanPayments/PlanPayments";
|
||||
import { IPlanSuscriptionDocument } from "../../Models/PlanSubscriptions/PlanSubscriptions.Adapter.Mongoose";
|
||||
import { VerifyPendingPlanPaymentResponse } from "../../Models/PlanSubscriptions/PlanSubscriptions.interface";
|
||||
import PlansList from "../../Models/Plans/Plans";
|
||||
import UserList from "../../Models/Users/Users";
|
||||
import { NotificationsManager } from "../../Models/Notifications/Notifications";
|
||||
|
||||
type MercadoPagoPaymentData = {
|
||||
id: string | number;
|
||||
@@ -41,6 +44,66 @@ type MercadoPagoPaymentVerification = {
|
||||
const REJECTED_PAYMENT_STATUSES = ["rejected", "cancelled", "failed"];
|
||||
|
||||
export class MercadoPagoWebhookService {
|
||||
private formatTemplateDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat("es-AR", {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
private formatTemplateAmount(amount: number): string {
|
||||
return new Intl.NumberFormat("es-AR", {
|
||||
style: "currency",
|
||||
currency: "ARS",
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
private async sendApprovedPaidPlanEmail(
|
||||
subscription: IPlanSuscriptionDocument,
|
||||
paymentData: MercadoPagoPaymentData,
|
||||
userId: string,
|
||||
approvedAt: Date,
|
||||
periodStart: Date
|
||||
): Promise<void> {
|
||||
try {
|
||||
const plan = await PlansList.findOne({ _id: String(subscription.planId) });
|
||||
|
||||
if (plan.price <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await UserList.users.findOne({ _id: userId });
|
||||
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
await NotificationsManager.sendEmail({
|
||||
email: user.email,
|
||||
templateId: "6a85ebb1323720a53d0cfd3f",
|
||||
context: {
|
||||
username: user.firstName || user.email,
|
||||
nombre_plan: plan.name,
|
||||
importe: this.formatTemplateAmount(paymentData.transaction_amount || 0),
|
||||
fecha_pago: this.formatTemplateDate(approvedAt),
|
||||
periodo_desde: this.formatTemplateDate(periodStart),
|
||||
periodo_hasta: this.formatTemplateDate(subscription.endDate),
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
console.error("Error enviando email de pago de plan aprobado:", {
|
||||
status: e.response?.status,
|
||||
data: e.response?.data,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("Error enviando email de pago de plan aprobado:", e);
|
||||
}
|
||||
}
|
||||
|
||||
public async handleWebhook(body: any): Promise<void> {
|
||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||
|
||||
@@ -300,6 +363,9 @@ export class MercadoPagoWebhookService {
|
||||
const periodStart = isExtensionPayment && dayjs(subscription.endDate).isAfter(approvedAt)
|
||||
? subscription.endDate
|
||||
: approvedAt;
|
||||
const transactionId = String(paymentData.id);
|
||||
const existingPayments = await PlanPaymentsList.find({ transactionId });
|
||||
const shouldSendApprovedPaymentEmail = existingPayments.length === 0;
|
||||
|
||||
if (isUpgradePayment && subscription.pendingPaymentPlanId) {
|
||||
subscription.planId = subscription.pendingPaymentPlanId;
|
||||
@@ -342,8 +408,12 @@ export class MercadoPagoWebhookService {
|
||||
paymentDate: approvedAt,
|
||||
paymentMethod: paymentData.payment_method_id || paymentData.payment_type_id || "mercadopago",
|
||||
status: "completed",
|
||||
transactionId: String(paymentData.id),
|
||||
transactionId,
|
||||
});
|
||||
|
||||
if (shouldSendApprovedPaymentEmail) {
|
||||
await this.sendApprovedPaidPlanEmail(subscription, paymentData, userId, approvedAt, periodStart);
|
||||
}
|
||||
}
|
||||
|
||||
private async applyRejectedPayment(
|
||||
|
||||
@@ -7,8 +7,10 @@ import { ApiVoidResult } from "../../Models/Api.VoidResult.type";
|
||||
interface SendEmailByContentParams {
|
||||
systemToken: string;
|
||||
email: string;
|
||||
subject: string;
|
||||
message: string;
|
||||
subject?: string;
|
||||
message?: string;
|
||||
templateId?: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface SendWapByContentParams {
|
||||
@@ -29,6 +31,8 @@ export class SendEmailByContentController extends Controller {
|
||||
email: requestBody.email,
|
||||
subject: requestBody.subject,
|
||||
message: requestBody.message,
|
||||
templateId: requestBody.templateId,
|
||||
context: requestBody.context,
|
||||
});
|
||||
|
||||
this.setStatus(200);
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
PaginateSchedulesResults,
|
||||
FindSchedulesParams,
|
||||
CollaboratorSchedulesView,
|
||||
ScheduleApplyParams,
|
||||
ScheduleApplyResult,
|
||||
} from "../../Models/Schedules/Schedules.Interface";
|
||||
import { authenticateMiddleware } from "../../middleware/authentication";
|
||||
import { ApiVoidResult } from "../../Models/Api.VoidResult.type";
|
||||
@@ -36,6 +38,7 @@ export class CreateScheduleController extends Controller {
|
||||
}
|
||||
|
||||
@Route("schedules/get-all-by-collaborator")
|
||||
@Middlewares(authenticateMiddleware)
|
||||
export class GetAllSchedulsByCollaboratoreController extends Controller {
|
||||
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||
@SuccessResponse(200, "El empleado fue creado con exito")
|
||||
@@ -81,6 +84,27 @@ export class UpdateScheduleController extends Controller {
|
||||
}
|
||||
}
|
||||
|
||||
@Route("schedules/apply")
|
||||
@Middlewares(authenticateMiddleware)
|
||||
export class ApplyScheduleController extends Controller {
|
||||
@Response<ApiValidationError>(500, "Ha ocurrido un error")
|
||||
@SuccessResponse(200, "Done")
|
||||
@Post()
|
||||
public async applyScheduleChanges(
|
||||
@Body() requestBody: ScheduleApplyParams
|
||||
): Promise<ScheduleApplyResult | ApiValidationError> {
|
||||
try {
|
||||
const result = await new SchedulesService().applyScheduleChanges(requestBody);
|
||||
this.setStatus(200);
|
||||
return result;
|
||||
} catch (e) {
|
||||
const errorOccurred: Error = e as Error;
|
||||
this.setStatus(500);
|
||||
return new ApiValidationError(500, errorOccurred.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Route("schedules/delete")
|
||||
@Middlewares(authenticateMiddleware)
|
||||
export class DeleteScheduleController extends Controller {
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
PaginateSchedulesResults,
|
||||
CollaboratorSchedulesView,
|
||||
FindSchedulesParams,
|
||||
ScheduleApplyParams,
|
||||
ScheduleApplyResult,
|
||||
} from "../../Models/Schedules/Schedules.Interface";
|
||||
|
||||
export class SchedulesService {
|
||||
@@ -26,6 +28,12 @@ export class SchedulesService {
|
||||
await ScheduleList.updateSchedule(data);
|
||||
}
|
||||
|
||||
public async applyScheduleChanges(data: ScheduleApplyParams): Promise<ScheduleApplyResult> {
|
||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||
|
||||
return await ScheduleList.applyScheduleChanges(data);
|
||||
}
|
||||
|
||||
public async deleteSchedule(data: DeleteScheduleParams): Promise<void> {
|
||||
await connect(`${process.env.DATABASE_CONNECTION}`);
|
||||
await ScheduleList.delete(data);
|
||||
|
||||
|
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"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -204,6 +204,9 @@ export default function OrganizationProfile() {
|
||||
|
||||
const [currentDate, setCurrentDate] = useState<dayjs.Dayjs>(appointmentDate);
|
||||
|
||||
const currentDateRef = useRef(currentDate);
|
||||
currentDateRef.current = currentDate;
|
||||
|
||||
const [calendarEventsCount, setCalendarEventsCount] = useState<number>(0);
|
||||
const [calendarFrom, setCalendarFrom] = useState<number>(8);
|
||||
const [calendarTo, setCalendarTo] = useState<number>(23);
|
||||
@@ -421,32 +424,35 @@ export default function OrganizationProfile() {
|
||||
|
||||
const handleMoveAppointment = async (newDay: dayjs.Dayjs) => {
|
||||
|
||||
if (!copiedAppointment || !copiedAppointment.appointmentId) return;
|
||||
const moveAppointment = useClipboardStore.getState().copiedAppointment;
|
||||
if (!moveAppointment || !moveAppointment.appointmentId) return;
|
||||
timePicker.hide();
|
||||
eventHandler.setEventType(EVENT_TYPES.LOADING);
|
||||
|
||||
const newStart = dayjs(currentDate).hour(newDay.hour()).minute(newDay.minute()).second(0).millisecond(0).toISOString();
|
||||
const oldStart = dayjs(copiedAppointment.startTime);
|
||||
const oldEnd = dayjs(copiedAppointment.endTime);
|
||||
const newStart = currentDateRef.current.hour(newDay.hour()).minute(newDay.minute()).second(0).millisecond(0).toISOString();
|
||||
const oldStart = dayjs(moveAppointment.startTime);
|
||||
const oldEnd = dayjs(moveAppointment.endTime);
|
||||
const durationMin = oldEnd.diff(oldStart, 'minute');
|
||||
const newEnd = dayjs(newStart).add(durationMin, 'minute').toISOString();
|
||||
|
||||
try {
|
||||
await ApiRequest.post("/appointments/move-appointment-force", {
|
||||
id: copiedAppointment.appointmentId,
|
||||
id: moveAppointment.appointmentId,
|
||||
companyId: id,
|
||||
newStart: newStart,
|
||||
newEnd: newEnd,
|
||||
sessionUser: SessionInfo.userId,
|
||||
});
|
||||
alert.showSuccess("Turno movido con éxito");
|
||||
setCopiedAppointment(null);
|
||||
useClipboardStore.getState().setCopiedAppointment(null);
|
||||
loadCalendarData();
|
||||
} catch (error: unknown) {
|
||||
console.error(error);
|
||||
alert.showError("Error al mover el turno");
|
||||
} finally {
|
||||
eventHandler.setEventType(copiedAppointment ? EVENT_TYPES.SLEEP : EVENT_TYPES.NEED_ADD);
|
||||
eventHandler.setEventType(
|
||||
useClipboardStore.getState().copiedAppointment ? EVENT_TYPES.SLEEP : EVENT_TYPES.NEED_ADD
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -844,7 +844,16 @@ export default function CashFlowMovementsList() {
|
||||
},
|
||||
};
|
||||
|
||||
(pdfMake as any).createPdf({ ...documentDefinition, content }).download(`factura-c-${invoice.pointOfSale || fiscalProfile.pointOfSale || 0}-${invoice.voucherNumber || 0}.pdf`);
|
||||
const filename = `factura-c-${invoice.pointOfSale || fiscalProfile.pointOfSale || 0}-${invoice.voucherNumber || 0}.pdf`;
|
||||
const pdf = (pdfMake as any).createPdf({ ...documentDefinition, content });
|
||||
|
||||
if ((window as unknown as { Android: any }).Android && (window as unknown as { Android: any }).Android.savePdf) {
|
||||
pdf.getBase64((data: string) => {
|
||||
(window as unknown as { Android: any }).Android?.savePdf(data, filename);
|
||||
});
|
||||
} else {
|
||||
pdf.download(filename);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error generating invoice PDF:", error);
|
||||
alert.showError("No pudimos generar el PDF de la factura.");
|
||||
|
||||
@@ -9,6 +9,7 @@ import RocketLaunchIcon from '@mui/icons-material/RocketLaunch';
|
||||
import EventBusyIcon from '@mui/icons-material/EventBusy';
|
||||
import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline';
|
||||
import GroupAddIcon from '@mui/icons-material/GroupAdd';
|
||||
import ManageSearchIcon from '@mui/icons-material/ManageSearch';
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import BlockIcon from "@mui/icons-material/Block";
|
||||
import EventRepeatIcon from "@mui/icons-material/EventRepeat";
|
||||
@@ -20,6 +21,8 @@ import NotificationsIcon from '@mui/icons-material/Notifications';
|
||||
import WhatsAppIcon from '@mui/icons-material/WhatsApp';
|
||||
import QrCode2Icon from '@mui/icons-material/QrCode2';
|
||||
import ReceiptLongIcon from '@mui/icons-material/ReceiptLong';
|
||||
import PublicIcon from '@mui/icons-material/Public';
|
||||
import PublicOffIcon from '@mui/icons-material/PublicOff';
|
||||
import API from "@services/Api.Service";
|
||||
import { ReservationPeriodsSummary } from "@models/SchedulesEnabled.type";
|
||||
|
||||
@@ -134,6 +137,24 @@ const AVAILABLE_FLOWS: FlowDefinition[] = [
|
||||
keywords: ["nuevo servicio", "prestacion", "ofrecer", "crear servicio", "precio", "duracion"],
|
||||
icon: AddCircleOutlineIcon
|
||||
},
|
||||
{
|
||||
id: "publish-services",
|
||||
flowId: "publish-services",
|
||||
title: "Publicar Servicios",
|
||||
description: "Hacer visibles uno o más servicios en la página pública.",
|
||||
category: "Servicios",
|
||||
keywords: ["publicar", "servicios", "visible", "publico", "mostrar", "habilitar reservas"],
|
||||
icon: PublicIcon
|
||||
},
|
||||
{
|
||||
id: "unpublish-services",
|
||||
flowId: "unpublish-services",
|
||||
title: "Retirar Servicios",
|
||||
description: "Quitar uno o más servicios de la página pública.",
|
||||
category: "Servicios",
|
||||
keywords: ["despublicar", "ocultar", "servicios", "privado", "no mostrar", "quitar publicacion"],
|
||||
icon: PublicOffIcon
|
||||
},
|
||||
{
|
||||
id: "update-schedule",
|
||||
flowId: "update-schedule",
|
||||
@@ -178,6 +199,15 @@ const AVAILABLE_FLOWS: FlowDefinition[] = [
|
||||
category: "Colaboradores",
|
||||
keywords: ["invitar", "colaborador", "profesional", "empleado", "staff", "equipo"],
|
||||
icon: GroupAddIcon
|
||||
},
|
||||
{
|
||||
id: "collaborator-schedule-summary",
|
||||
flowId: "collaborator-schedule-summary",
|
||||
title: "Ver horarios de colaborador",
|
||||
description: "Consultá horarios, excepciones y restricciones configuradas.",
|
||||
category: "Colaboradores",
|
||||
keywords: ["horarios", "colaborador", "agenda", "restricciones", "excepciones", "cerrada", "consulta"],
|
||||
icon: ManageSearchIcon
|
||||
}
|
||||
];
|
||||
|
||||
|
||||
@@ -72,6 +72,19 @@
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(128, 0, 32, 0.3);
|
||||
}
|
||||
|
||||
.nextButton:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.nextButton:disabled:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.pressEnterText {
|
||||
font-size: 13px;
|
||||
color: #6b7280;
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function QuestionCard({
|
||||
<div className={classNames(style.cardContainer, style.animateEnter)}>
|
||||
<div className={style.contentWrapper}>
|
||||
{topAccessory && <div className={style.topAccessory}>{topAccessory}</div>}
|
||||
<h1 className={style.title}>{title}</h1>
|
||||
{title && <h1 className={style.title}>{title}</h1>}
|
||||
{description && <p className={style.description}>{description}</p>}
|
||||
|
||||
<div className={style.inputArea}>
|
||||
|
||||
@@ -14,6 +14,7 @@ dayjs.locale("es");
|
||||
import { NotificationChannel, ReminderRule } from "@models/NotificationPreferences.model";
|
||||
import { OrganizationClientView } from "@models/Clients.model";
|
||||
import { ReservationPeriodDuration, ReservationPeriodsSummary } from "@models/SchedulesEnabled.type";
|
||||
import { CompanyServiceView, SERVICE_PUBLISHED_STATUS } from "@models/Service.model";
|
||||
import OnboardingOrgFlow from "./flows/OnboardingOrgFlow";
|
||||
import OnboardingServiceFlow from "./flows/OnboardingServiceFlow";
|
||||
import OnboardingScheduleFlow from "./flows/OnboardingScheduleFlow";
|
||||
@@ -23,6 +24,8 @@ import UpdateScheduleFlow from "./flows/UpdateScheduleFlow";
|
||||
import OverrideScheduleFlow from "./flows/OverrideScheduleFlow";
|
||||
import DisableScheduleFlow from "./flows/DisableScheduleFlow";
|
||||
import PublicVisibilityFlow from "./flows/PublicVisibilityFlow";
|
||||
import ServicePublicationFlow from "./flows/ServicePublicationFlow";
|
||||
import CollaboratorScheduleSummaryFlow, { CollaboratorScheduleDetails } from "./flows/CollaboratorScheduleSummaryFlow";
|
||||
import NotificationsFlow from "./flows/NotificationsFlow";
|
||||
import NewCollaboratorFlow from "./flows/NewCollaboratorFlow";
|
||||
import SetupCollaboratorFlow from "./flows/SetupCollaboratorFlow";
|
||||
@@ -31,6 +34,8 @@ import ArcaOnboardingFlow from "./flows/ArcaOnboardingFlow";
|
||||
import ArcaTestConnectionFlow, { ArcaWsaaTestResult, ArcaWsfeDiagnosticsResult } from "./flows/ArcaTestConnectionFlow";
|
||||
import SuccessScreen from "./components/SuccessScreen";
|
||||
import OrganizationPicker from "./components/OrganizationPicker";
|
||||
import QuestionCard from "../QuestionCard/QuestionCard";
|
||||
import { getCollaboratorLabel } from "./components/CollaboratorPicker";
|
||||
import { reservationPeriodOptions } from "./constants/reservationPeriods";
|
||||
import { NOTIF_CHANNELS, NOTIF_REMINDER_PRESETS } from "./constants/notifications";
|
||||
import { getEmployeeId, getEmployeeUserId, getTargetEmployees } from "./helpers/employees";
|
||||
@@ -96,11 +101,18 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
const [serviceLimit, setServiceLimit] = useState<number>(1);
|
||||
const [serviceImage, setServiceImage] = useState<File | null>(null);
|
||||
const [createdServiceId, setCreatedServiceId] = useState<string | null>(null);
|
||||
const [serviceCreateError, setServiceCreateError] = useState<string>("");
|
||||
const [serviceAssignmentError, setServiceAssignmentError] = useState<string>("");
|
||||
const [serviceAssignmentMode, setServiceAssignmentMode] = useState<"options" | "collaborators">("options");
|
||||
const [serviceAssignmentWarning, setServiceAssignmentWarning] = useState<string>("");
|
||||
const [isLoadingServiceCollaborators, setIsLoadingServiceCollaborators] = useState(false);
|
||||
const [selectedServiceCollaboratorIds, setSelectedServiceCollaboratorIds] = useState<string[]>([]);
|
||||
const [employeeId, setEmployeeId] = useState<string | null>(null);
|
||||
const [doesProvideServices, setDoesProvideServices] = useState<boolean | null>(null);
|
||||
|
||||
// State for Schedule Flow
|
||||
const [workingDaysMode, setWorkingDaysMode] = useState<string>(""); // "mon-fri", "mon-sat", "mon-sun", "custom"
|
||||
const [specificWeekDay, setSpecificWeekDay] = useState<number | null>(null);
|
||||
const [openTime, setOpenTime] = useState<string>("09:00");
|
||||
const [closeTime, setCloseTime] = useState<string>("18:00");
|
||||
const [hasBreak, setHasBreak] = useState<boolean | null>(null);
|
||||
@@ -116,7 +128,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
const [isSuccess, setIsSuccess] = useState<boolean>(false);
|
||||
const [isAddingAnotherService, setIsAddingAnotherService] = useState<boolean>(false);
|
||||
|
||||
const [isResuming, setIsResuming] = useState(false);
|
||||
const [isResuming, setIsResuming] = useState(true);
|
||||
|
||||
const [headerImage, setHeaderImage] = useState<File | null>(null);
|
||||
const [headerColor, setHeaderColor] = useState<string>("#250525");
|
||||
@@ -133,6 +145,13 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
const [servicesShowPublicScores, setServicesShowPublicScores] = useState(true);
|
||||
const [servicesShowPublicOpinions, setServicesShowPublicOpinions] = useState(true);
|
||||
const [publicLinkCopied, setPublicLinkCopied] = useState(false);
|
||||
const [publicationServices, setPublicationServices] = useState<CompanyServiceView[]>([]);
|
||||
const [selectedPublicationServiceIds, setSelectedPublicationServiceIds] = useState<string[]>([]);
|
||||
const [publicationLoading, setPublicationLoading] = useState(false);
|
||||
const [publicationMessage, setPublicationMessage] = useState("");
|
||||
const [collaboratorScheduleDetails, setCollaboratorScheduleDetails] = useState<CollaboratorScheduleDetails | null>(null);
|
||||
const [collaboratorScheduleLoading, setCollaboratorScheduleLoading] = useState(false);
|
||||
const [collaboratorScheduleMessage, setCollaboratorScheduleMessage] = useState("");
|
||||
|
||||
// State for Notifications Flow
|
||||
const [notificationsScope, setNotificationsScope] = useState<"org" | "client" | null>(null);
|
||||
@@ -149,6 +168,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
// State for standalone update schedule flow
|
||||
const [scheduleScope, setScheduleScope] = useState<"me" | "specific" | "all" | null>(null);
|
||||
const [targetEmployeeId, setTargetEmployeeId] = useState<string | null>(null);
|
||||
const [selectedScheduleEmployeeIds, setSelectedScheduleEmployeeIds] = useState<string[]>([]);
|
||||
const [allEmployees, setAllEmployees] = useState<any[]>([]);
|
||||
const [overrideDate, setOverrideDate] = useState<string>("");
|
||||
const [allOrganizations, setAllOrganizations] = useState<any[]>([]);
|
||||
@@ -163,6 +183,11 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
const [reservationPeriodSummary, setReservationPeriodSummary] = useState<ReservationPeriodsSummary | null>(null);
|
||||
const [reservationPeriodEmployeesLoading, setReservationPeriodEmployeesLoading] = useState(false);
|
||||
const [reservationPeriodEmployeesError, setReservationPeriodEmployeesError] = useState<string | null>(null);
|
||||
const [scheduleServiceScope, setScheduleServiceScope] = useState<"all" | "specific">("all");
|
||||
const [scheduleServiceIds, setScheduleServiceIds] = useState<string[]>([]);
|
||||
const [scheduleAvailableServices, setScheduleAvailableServices] = useState<any[]>([]);
|
||||
const [scheduleConflictMessage, setScheduleConflictMessage] = useState<string>("");
|
||||
const [pendingScheduleOverwrite, setPendingScheduleOverwrite] = useState(false);
|
||||
|
||||
// State for New Collaborator Flow
|
||||
const [inviteEmail, setInviteEmail] = useState("");
|
||||
@@ -208,6 +233,49 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
return !employee.removed && (isCurrentUser || employee.guestOk === true);
|
||||
};
|
||||
|
||||
const requiresOrganizationContext = () => {
|
||||
return flowId === "onboarding-service"
|
||||
|| flowId === "onboarding-schedule"
|
||||
|| flowId === "setup-collaborator"
|
||||
|| flowId === "public-visibility"
|
||||
|| flowId === "publish-services"
|
||||
|| flowId === "unpublish-services"
|
||||
|| flowId === "collaborator-schedule-summary"
|
||||
|| flowId === "public-link"
|
||||
|| flowId === "notifications"
|
||||
|| flowId === "whatsapp-bot"
|
||||
|| flowId === "arca-onboarding"
|
||||
|| flowId === "arca-test-connection"
|
||||
|| flowId === "update-schedule"
|
||||
|| flowId === "override-schedule"
|
||||
|| flowId === "disable-schedule"
|
||||
|| flowId === "open-reservation-periods";
|
||||
};
|
||||
|
||||
const loadPublicationServices = async (companyId: string) => {
|
||||
const publishMode = flowId === "publish-services";
|
||||
setPublicationLoading(true);
|
||||
setPublicationMessage("");
|
||||
setSelectedPublicationServiceIds([]);
|
||||
|
||||
try {
|
||||
const services = await API.post<CompanyServiceView[]>("services/get-by-company", { companyId });
|
||||
const filteredServices = (services || []).filter(service => {
|
||||
if (publishMode) {
|
||||
return service.published !== SERVICE_PUBLISHED_STATUS.PUBLISHED && !service.banned;
|
||||
}
|
||||
return service.published === SERVICE_PUBLISHED_STATUS.PUBLISHED;
|
||||
});
|
||||
setPublicationServices(filteredServices);
|
||||
} catch (error: any) {
|
||||
console.error("Error cargando servicios para publicación:", error);
|
||||
setPublicationServices([]);
|
||||
setPublicationMessage(error?.message || "No pudimos cargar los servicios. Reintentá en unos segundos.");
|
||||
} finally {
|
||||
setPublicationLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadOrganizationContext = (org: any) => {
|
||||
setCreatedCompanyId(org.id || org._id);
|
||||
setSelectedOrganization(org);
|
||||
@@ -299,7 +367,24 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
}).catch(console.error);
|
||||
}
|
||||
}).catch(console.error);
|
||||
} else if (flowId === "update-schedule" || flowId === "override-schedule" || flowId === "disable-schedule" || flowId === "open-reservation-periods") {
|
||||
} else if (flowId === "update-schedule" || flowId === "override-schedule" || flowId === "disable-schedule" || flowId === "open-reservation-periods" || flowId === "collaborator-schedule-summary") {
|
||||
if (flowId === "collaborator-schedule-summary") {
|
||||
setCurrentStepIndex(0);
|
||||
setAllEmployees([]);
|
||||
setTargetEmployeeId(null);
|
||||
setCollaboratorScheduleDetails(null);
|
||||
setCollaboratorScheduleMessage("");
|
||||
}
|
||||
|
||||
if (flowId === "update-schedule") {
|
||||
setSelectedScheduleEmployeeIds([]);
|
||||
setScheduleServiceScope("all");
|
||||
setScheduleServiceIds([]);
|
||||
setScheduleAvailableServices([]);
|
||||
setScheduleConflictMessage("");
|
||||
setPendingScheduleOverwrite(false);
|
||||
}
|
||||
|
||||
if (flowId === "open-reservation-periods") {
|
||||
setCurrentStepIndex(0);
|
||||
setReservationPeriodScope(null);
|
||||
@@ -313,7 +398,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
|
||||
API.post<any[]>("employees/get-by-company", { companyId: org.id || org._id })
|
||||
.then(empRes => {
|
||||
const availableEmployees = flowId === "open-reservation-periods"
|
||||
const availableEmployees = flowId === "open-reservation-periods" || flowId === "collaborator-schedule-summary"
|
||||
? (empRes || []).filter((employee: any) => !employee.removed)
|
||||
: (empRes || []).filter(canManageEmployeeSchedules);
|
||||
setAllEmployees(availableEmployees);
|
||||
@@ -360,6 +445,9 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
setServicesShowPublicScores(services.every(service => service.showPublicScores !== false));
|
||||
setServicesShowPublicOpinions(services.every(service => service.showPublicOpinions !== false));
|
||||
}).catch(console.error);
|
||||
} else if (flowId === "publish-services" || flowId === "unpublish-services") {
|
||||
setCurrentStepIndex(0);
|
||||
loadPublicationServices(org.id || org._id);
|
||||
} else if (flowId === "public-link") {
|
||||
setCurrentStepIndex(0);
|
||||
setPublicLinkCopied(false);
|
||||
@@ -448,7 +536,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (flowId === "new-collaborator" || flowId === "setup-collaborator" || flowId === "public-visibility" || flowId === "public-link" || flowId === "notifications" || flowId === "whatsapp-bot" || flowId === "arca-onboarding" || flowId === "arca-test-connection" || flowId.startsWith("onboarding") || flowId === "update-schedule" || flowId === "override-schedule" || flowId === "disable-schedule" || flowId === "open-reservation-periods") {
|
||||
if (flowId === "new-collaborator" || flowId === "setup-collaborator" || flowId === "public-visibility" || flowId === "publish-services" || flowId === "unpublish-services" || flowId === "collaborator-schedule-summary" || flowId === "public-link" || flowId === "notifications" || flowId === "whatsapp-bot" || flowId === "arca-onboarding" || flowId === "arca-test-connection" || flowId.startsWith("onboarding") || flowId === "update-schedule" || flowId === "override-schedule" || flowId === "disable-schedule" || flowId === "open-reservation-periods") {
|
||||
setIsResuming(true);
|
||||
API.post<any[]>("companies/get-by-user", { sessionUser: SessionInfo.userId })
|
||||
.then(response => {
|
||||
@@ -469,7 +557,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
.finally(() => setIsResuming(false));
|
||||
}
|
||||
|
||||
if (flowId === "onboarding-service" || flowId === "onboarding-schedule" || flowId === "setup-collaborator" || flowId === "public-visibility" || flowId === "public-link" || flowId === "notifications" || flowId === "whatsapp-bot" || flowId === "arca-onboarding" || flowId === "arca-test-connection" || flowId === "open-reservation-periods" || (flowId === "onboarding-org" && action === "new-org")) {
|
||||
if (flowId === "onboarding-service" || flowId === "onboarding-schedule" || flowId === "setup-collaborator" || flowId === "public-visibility" || flowId === "publish-services" || flowId === "unpublish-services" || flowId === "collaborator-schedule-summary" || flowId === "public-link" || flowId === "notifications" || flowId === "whatsapp-bot" || flowId === "arca-onboarding" || flowId === "arca-test-connection" || flowId === "open-reservation-periods" || (flowId === "onboarding-org" && action === "new-org")) {
|
||||
setCurrentStepIndex(0);
|
||||
}
|
||||
}, [flowId, SessionInfo.metrics.organizationsCount, SessionInfo.userId, router]);
|
||||
@@ -1060,15 +1148,55 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
);
|
||||
|
||||
const handleCreateService = async () => {
|
||||
if (!createdCompanyId || !serviceName || !serviceDescription || !serviceLength || isCreating) return;
|
||||
setServiceCreateError("");
|
||||
const defaultOrganization = selectedOrganization || (allOrganizations.length === 1 ? allOrganizations[0] : null);
|
||||
let companyId = createdCompanyId || defaultOrganization?.id || defaultOrganization?._id;
|
||||
const normalizedPrice = typeof servicePrice === "string" ? servicePrice.replace(",", ".") : servicePrice;
|
||||
const parsedPrice = Number(normalizedPrice);
|
||||
|
||||
if (isCreating) return;
|
||||
|
||||
if (!companyId) {
|
||||
try {
|
||||
const organizations = await API.post<any[]>("companies/get-by-user", { sessionUser: SessionInfo.userId });
|
||||
const availableOrganizations = organizations || [];
|
||||
setAllOrganizations(availableOrganizations);
|
||||
|
||||
if (availableOrganizations.length === 1) {
|
||||
const organization = availableOrganizations[0];
|
||||
companyId = organization.id || organization._id;
|
||||
loadOrganizationContext(organization);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error cargando organizaciones para crear servicio:", error);
|
||||
setServiceCreateError("No pudimos cargar tus organizaciones para crear el servicio. Reintentá en unos segundos.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!companyId) {
|
||||
setServiceCreateError("Para crear un servicio primero necesitás tener una organización asociada. Volvé al inicio del asistente y creá una organización.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!serviceName || !serviceDescription || !serviceLength) {
|
||||
setServiceCreateError("Completá el nombre, la descripción y la duración antes de crear el servicio.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (servicePrice === "" || Number.isNaN(parsedPrice)) {
|
||||
setServiceCreateError("Ingresá el precio del servicio. Si es gratis, cargá 0.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const service = await API.post<any>("services/create", {
|
||||
companyId: createdCompanyId,
|
||||
companyId,
|
||||
name: serviceName,
|
||||
description: serviceDescription,
|
||||
length: serviceLength,
|
||||
originalPrice: Number(servicePrice) || 0,
|
||||
originalPrice: parsedPrice,
|
||||
limit: serviceLimit,
|
||||
sessionUser: SessionInfo.userId
|
||||
});
|
||||
@@ -1080,7 +1208,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
handleNext(); // Move to Step 4 (Service Image)
|
||||
} catch (error: any) {
|
||||
console.error("Error creando servicio:", error);
|
||||
alert(error?.message || "Hubo un error al guardar el servicio.");
|
||||
setServiceCreateError(error?.message || "Hubo un error al guardar el servicio.");
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
@@ -1098,6 +1226,8 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
.then(res => {
|
||||
setPreviewServices(res || []);
|
||||
});
|
||||
} else if (isStandaloneAction && action === "new-service") {
|
||||
setCurrentStepIndex(5);
|
||||
} else if (isStandaloneAction) {
|
||||
handleActionSuccess();
|
||||
} else {
|
||||
@@ -1123,7 +1253,139 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
finalizeService();
|
||||
} catch (error: any) {
|
||||
console.error("Error subiendo imagen del servicio:", error);
|
||||
alert(error?.message || "Hubo un error al subir la imagen. Puedes intentarlo más tarde desde tu panel.");
|
||||
setServiceCreateError(error?.message || "Hubo un error al subir la imagen. Podés intentarlo más tarde desde tu panel.");
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignCreatedServiceToMe = async () => {
|
||||
if (!createdCompanyId || !createdServiceId || isCreating) return;
|
||||
|
||||
setIsCreating(true);
|
||||
setServiceAssignmentError("");
|
||||
setServiceAssignmentWarning("");
|
||||
try {
|
||||
const employees = await API.post<any[]>("employees/get-by-company", { companyId: createdCompanyId });
|
||||
const currentEmployee = (employees || []).find((employee: any) => String(getEmployeeUserId(employee)) === String(SessionInfo.userId));
|
||||
let finalEmployeeId = currentEmployee ? getEmployeeId(currentEmployee) : null;
|
||||
|
||||
if (!finalEmployeeId) {
|
||||
const newEmp = await API.post<any>("employees/create", {
|
||||
companyId: createdCompanyId,
|
||||
userId: SessionInfo.userId,
|
||||
roles: ["owner", "employee"],
|
||||
sessionUser: SessionInfo.userId
|
||||
});
|
||||
finalEmployeeId = newEmp?.id || newEmp?._id;
|
||||
}
|
||||
|
||||
if (!finalEmployeeId) {
|
||||
throw new Error("No pudimos inicializar tu perfil de colaborador.");
|
||||
}
|
||||
|
||||
setEmployeeId(finalEmployeeId);
|
||||
await API.post<any>("employees/update-collaborator-services", {
|
||||
companyId: createdCompanyId,
|
||||
employeeId: finalEmployeeId,
|
||||
services: [{ serviceId: createdServiceId, active: true }],
|
||||
sessionUser: SessionInfo.userId
|
||||
});
|
||||
|
||||
const schedulesRes = await API.post<any>("schedules/get-all-by-collaborator", {
|
||||
companyId: createdCompanyId,
|
||||
employeeId: finalEmployeeId
|
||||
});
|
||||
|
||||
if (schedulesRes?.data?.length > 0) {
|
||||
handleActionSuccess();
|
||||
return;
|
||||
}
|
||||
|
||||
router.push(`/admin/assistant?flow=update-schedule&companyId=${createdCompanyId}`);
|
||||
} catch (error: any) {
|
||||
console.error("Error asignando servicio al colaborador:", error);
|
||||
setServiceAssignmentError(error?.message || "No pudimos asignarte el servicio. Reintentá en unos segundos.");
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadServiceAssignmentCollaborators = async () => {
|
||||
if (!createdCompanyId || isCreating || isLoadingServiceCollaborators) return;
|
||||
|
||||
setServiceAssignmentMode("collaborators");
|
||||
setServiceAssignmentError("");
|
||||
setServiceAssignmentWarning("");
|
||||
setIsLoadingServiceCollaborators(true);
|
||||
try {
|
||||
const employees = await API.post<any[]>("employees/get-by-company", { companyId: createdCompanyId });
|
||||
const activeEmployees = (employees || []).filter((employee: any) => !employee.removed);
|
||||
setAllEmployees(activeEmployees);
|
||||
} catch (error: any) {
|
||||
console.error("Error cargando colaboradores:", error);
|
||||
setServiceAssignmentError(error?.message || "No pudimos cargar los colaboradores. Reintentá en unos segundos.");
|
||||
} finally {
|
||||
setIsLoadingServiceCollaborators(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleServiceCollaboratorSelection = (employee: any) => {
|
||||
const id = getEmployeeId(employee);
|
||||
if (!id || isCreating) return;
|
||||
|
||||
setSelectedServiceCollaboratorIds(prev => (
|
||||
prev.includes(id)
|
||||
? prev.filter(selectedId => selectedId !== id)
|
||||
: [...prev, id]
|
||||
));
|
||||
};
|
||||
|
||||
const handleAssignCreatedServiceToCollaborators = async () => {
|
||||
if (!createdCompanyId || !createdServiceId || isCreating) return;
|
||||
if (selectedServiceCollaboratorIds.length === 0) {
|
||||
setServiceAssignmentError("Seleccioná al menos un colaborador para asignar el servicio.");
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedEmployees = allEmployees.filter((employee: any) => selectedServiceCollaboratorIds.includes(getEmployeeId(employee)));
|
||||
if (selectedEmployees.length === 0) {
|
||||
setServiceAssignmentError("No encontramos los colaboradores seleccionados. Volvé a cargarlos e intentá de nuevo.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
setServiceAssignmentError("");
|
||||
setServiceAssignmentWarning("");
|
||||
try {
|
||||
await Promise.all(selectedEmployees.map((employee: any) => API.post<any>("employees/update-collaborator-services", {
|
||||
companyId: createdCompanyId,
|
||||
employeeId: getEmployeeId(employee),
|
||||
services: [{ serviceId: createdServiceId, active: true }],
|
||||
sessionUser: SessionInfo.userId
|
||||
})));
|
||||
|
||||
const scheduleResults = await Promise.all(selectedEmployees.map(async (employee: any) => {
|
||||
const employeeId = getEmployeeId(employee);
|
||||
const schedulesRes = await API.post<any>("schedules/get-all-by-collaborator", {
|
||||
companyId: createdCompanyId,
|
||||
employeeId
|
||||
});
|
||||
return { employee, hasSchedules: (schedulesRes?.data || []).length > 0 };
|
||||
}));
|
||||
|
||||
const missingSchedules = scheduleResults.filter(result => !result.hasSchedules);
|
||||
if (missingSchedules.length > 0) {
|
||||
const names = missingSchedules.map(({ employee }: any) => getCollaboratorLabel(employee)).join(", ");
|
||||
setServiceAssignmentWarning(`Servicio asignado. Todavía necesitás cargar horarios para: ${names}. Hasta entonces no van a poder recibir reservas.`);
|
||||
handleActionSuccess();
|
||||
return;
|
||||
}
|
||||
|
||||
handleActionSuccess();
|
||||
} catch (error: any) {
|
||||
console.error("Error asignando servicio a colaboradores:", error);
|
||||
setServiceAssignmentError(error?.message || "No pudimos asignar el servicio a los colaboradores. Reintentá en unos segundos.");
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
@@ -1147,9 +1409,22 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
setServicePrice={setServicePrice}
|
||||
serviceLimit={serviceLimit}
|
||||
setServiceLimit={setServiceLimit}
|
||||
serviceCreateError={serviceCreateError}
|
||||
handleUploadServiceImage={handleUploadServiceImage}
|
||||
serviceImage={serviceImage}
|
||||
setServiceImage={setServiceImage}
|
||||
isStandaloneNewService={isStandaloneAction && action === "new-service"}
|
||||
handleAssignCreatedServiceToMe={handleAssignCreatedServiceToMe}
|
||||
handleShowCollaboratorAssignment={loadServiceAssignmentCollaborators}
|
||||
handleToggleServiceCollaborator={toggleServiceCollaboratorSelection}
|
||||
handleAssignCreatedServiceToCollaborators={handleAssignCreatedServiceToCollaborators}
|
||||
handleFinishWithoutAssignment={handleActionSuccess}
|
||||
serviceAssignmentError={serviceAssignmentError}
|
||||
serviceAssignmentWarning={serviceAssignmentWarning}
|
||||
serviceAssignmentMode={serviceAssignmentMode}
|
||||
serviceAssignmentCollaborators={allEmployees}
|
||||
selectedServiceCollaboratorIds={selectedServiceCollaboratorIds}
|
||||
isLoadingServiceCollaborators={isLoadingServiceCollaborators}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1321,57 +1596,160 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
);
|
||||
|
||||
|
||||
const handleUpdateScheduleSubmit = async () => {
|
||||
const formatScheduleConflicts = (conflicts: any[]) => {
|
||||
if (!conflicts || conflicts.length === 0) return "";
|
||||
|
||||
return conflicts.map((conflict) => conflict.message).join("\n");
|
||||
};
|
||||
|
||||
const getUpdateScheduleTargetEmployees = () => {
|
||||
if (scheduleScope === "specific") {
|
||||
return allEmployees.filter((employee: any) => selectedScheduleEmployeeIds.includes(String(getEmployeeId(employee))));
|
||||
}
|
||||
|
||||
return getTargetEmployees(allEmployees, scheduleScope, targetEmployeeId, SessionInfo.userId);
|
||||
};
|
||||
|
||||
const loadScheduleServicesForEmployees = async (employeeIdsToLoad: string[]) => {
|
||||
if (!createdCompanyId || employeeIdsToLoad.length === 0) {
|
||||
setScheduleAvailableServices([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await Promise.all(employeeIdsToLoad.map((employeeIdToLoad) => API.post<any>("employees/get-all-services", {
|
||||
companyId: createdCompanyId,
|
||||
employeeId: employeeIdToLoad,
|
||||
sessionUser: SessionInfo.userId
|
||||
})));
|
||||
const serviceGroups = results.map((result) => (result?.services || []).filter((service: any) => service.active));
|
||||
|
||||
if (serviceGroups.length === 0) {
|
||||
setScheduleAvailableServices([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const commonServiceIds = serviceGroups.slice(1).reduce((commonIds, services) => {
|
||||
const currentIds = new Set(services.map((service: any) => String(service.id || service._id)));
|
||||
return commonIds.filter((serviceId: string) => currentIds.has(serviceId));
|
||||
}, serviceGroups[0].map((service: any) => String(service.id || service._id)));
|
||||
const commonServiceIdSet = new Set(commonServiceIds);
|
||||
const commonServices = serviceGroups[0].filter((service: any) => commonServiceIdSet.has(String(service.id || service._id)));
|
||||
|
||||
setScheduleAvailableServices(commonServices);
|
||||
setScheduleServiceIds((currentIds) => currentIds.filter((serviceId) => commonServiceIdSet.has(String(serviceId))));
|
||||
};
|
||||
|
||||
const handleToggleScheduleEmployee = (employee: any) => {
|
||||
const employeeIdToToggle = String(getEmployeeId(employee));
|
||||
|
||||
setSelectedScheduleEmployeeIds((currentIds) => {
|
||||
const nextIds = currentIds.includes(employeeIdToToggle)
|
||||
? currentIds.filter((id) => id !== employeeIdToToggle)
|
||||
: [...currentIds, employeeIdToToggle];
|
||||
|
||||
setScheduleServiceScope("all");
|
||||
setScheduleServiceIds([]);
|
||||
setScheduleAvailableServices([]);
|
||||
setScheduleConflictMessage("");
|
||||
|
||||
return nextIds;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSetUpdateScheduleScope = (scope: "me" | "specific" | "all") => {
|
||||
setScheduleScope(scope);
|
||||
setScheduleServiceScope("all");
|
||||
setScheduleServiceIds([]);
|
||||
setScheduleAvailableServices([]);
|
||||
setScheduleConflictMessage("");
|
||||
setPendingScheduleOverwrite(false);
|
||||
};
|
||||
|
||||
const handleUpdateScheduleSubmit = async (conflictStrategy: "reject" | "overwrite-conflicts" = "reject") => {
|
||||
if (!createdCompanyId || isCreating || !scheduleScope) return;
|
||||
setIsCreating(true);
|
||||
setScheduleConflictMessage("");
|
||||
setPendingScheduleOverwrite(false);
|
||||
try {
|
||||
const targetEmployees = getTargetEmployees(allEmployees, scheduleScope, targetEmployeeId, SessionInfo.userId);
|
||||
const targetEmployees = getUpdateScheduleTargetEmployees();
|
||||
|
||||
if (targetEmployees.length === 0) {
|
||||
alert("No se encontró ningún colaborador seleccionado.");
|
||||
setScheduleConflictMessage("Seleccioná al menos un colaborador para actualizar sus horarios.");
|
||||
setIsCreating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const workingDays = getWorkingDays(workingDaysMode);
|
||||
const employeeIds = targetEmployees.map((employee) => getEmployeeId(employee)).filter(Boolean);
|
||||
const weekDays = workingDaysMode === "specific-day" && specificWeekDay !== null
|
||||
? [specificWeekDay]
|
||||
: getWorkingDays(workingDaysMode);
|
||||
const canUseSpecificServices = workingDaysMode === "specific-day" && employeeIds.length > 0;
|
||||
const effectiveServiceScope = canUseSpecificServices ? scheduleServiceScope : "all";
|
||||
const schedules = buildScheduleBlocks(
|
||||
openTime,
|
||||
closeTime,
|
||||
hasBreak,
|
||||
breakStart,
|
||||
breakEnd,
|
||||
effectiveServiceScope,
|
||||
effectiveServiceScope === "specific" ? scheduleServiceIds : []
|
||||
);
|
||||
|
||||
for (const emp of targetEmployees) {
|
||||
const empId = getEmployeeId(emp);
|
||||
if (!empId) continue;
|
||||
if (weekDays.length === 0) {
|
||||
throw new Error("Seleccioná al menos un día de trabajo.");
|
||||
}
|
||||
|
||||
for (let i = 0; i <= 6; i++) {
|
||||
const isWorkingDay = workingDays.includes(i);
|
||||
const schedules = isWorkingDay ? buildScheduleBlocks(openTime, closeTime, hasBreak, breakStart, breakEnd) : [];
|
||||
if (effectiveServiceScope === "specific" && scheduleServiceIds.length === 0) {
|
||||
throw new Error("Seleccioná al menos un servicio.");
|
||||
}
|
||||
|
||||
await API.post<any>("schedules/update", {
|
||||
if (effectiveServiceScope === "specific" && scheduleAvailableServices.length === 0) {
|
||||
throw new Error("Los colaboradores seleccionados no tienen servicios activos en común.");
|
||||
}
|
||||
|
||||
const result = await API.post<any>("schedules/apply", {
|
||||
companyId: createdCompanyId,
|
||||
employeeId: empId,
|
||||
weekDay: i,
|
||||
schedules: schedules,
|
||||
sessionUser: SessionInfo.userId
|
||||
employeeIds,
|
||||
weekDays,
|
||||
schedules,
|
||||
sessionUser: SessionInfo.userId,
|
||||
conflictStrategy
|
||||
});
|
||||
|
||||
if (result?.valid === false && !result?.applied) {
|
||||
setScheduleConflictMessage(formatScheduleConflicts(result.conflicts) || result.message);
|
||||
setPendingScheduleOverwrite(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
handleActionSuccess();
|
||||
} catch (error: any) {
|
||||
console.error("Error actualizando horarios:", error);
|
||||
alert("Hubo un error al guardar los horarios: " + error?.message);
|
||||
setScheduleConflictMessage("Hubo un error al guardar los horarios: " + error?.message);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderUpdateScheduleFlow = () => (
|
||||
<UpdateScheduleFlow
|
||||
const renderUpdateScheduleFlow = () => {
|
||||
const scheduleTargetEmployees = getUpdateScheduleTargetEmployees();
|
||||
const scheduleTargetEmployeeIds = scheduleTargetEmployees.map((employee) => String(getEmployeeId(employee))).filter(Boolean);
|
||||
const canUseSpecificScheduleServices = scheduleTargetEmployeeIds.length > 0;
|
||||
|
||||
return <UpdateScheduleFlow
|
||||
currentStepIndex={currentStepIndex}
|
||||
scheduleScope={scheduleScope}
|
||||
setScheduleScope={setScheduleScope}
|
||||
setScheduleScope={handleSetUpdateScheduleScope}
|
||||
targetEmployeeId={targetEmployeeId}
|
||||
setTargetEmployeeId={setTargetEmployeeId}
|
||||
selectedScheduleEmployeeIds={selectedScheduleEmployeeIds}
|
||||
handleToggleScheduleEmployee={handleToggleScheduleEmployee}
|
||||
allEmployees={allEmployees}
|
||||
handleNext={handleNext}
|
||||
workingDaysMode={workingDaysMode}
|
||||
setWorkingDaysMode={setWorkingDaysMode}
|
||||
specificWeekDay={specificWeekDay}
|
||||
setSpecificWeekDay={setSpecificWeekDay}
|
||||
openTime={openTime}
|
||||
setOpenTime={setOpenTime}
|
||||
closeTime={closeTime}
|
||||
@@ -1384,8 +1762,20 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
setBreakEnd={setBreakEnd}
|
||||
isCreating={isCreating}
|
||||
handleUpdateScheduleSubmit={handleUpdateScheduleSubmit}
|
||||
/>
|
||||
);
|
||||
scheduleServiceScope={scheduleServiceScope}
|
||||
setScheduleServiceScope={setScheduleServiceScope}
|
||||
scheduleServiceIds={scheduleServiceIds}
|
||||
setScheduleServiceIds={setScheduleServiceIds}
|
||||
scheduleAvailableServices={scheduleAvailableServices}
|
||||
loadScheduleServicesForEmployees={loadScheduleServicesForEmployees}
|
||||
scheduleTargetEmployeeIds={scheduleTargetEmployeeIds}
|
||||
canUseSpecificScheduleServices={canUseSpecificScheduleServices}
|
||||
scheduleConflictMessage={scheduleConflictMessage}
|
||||
pendingScheduleOverwrite={pendingScheduleOverwrite}
|
||||
setScheduleConflictMessage={setScheduleConflictMessage}
|
||||
setPendingScheduleOverwrite={setPendingScheduleOverwrite}
|
||||
/>;
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -1747,6 +2137,45 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
}
|
||||
};
|
||||
|
||||
const togglePublicationService = (serviceId: string) => {
|
||||
if (isCreating) return;
|
||||
setSelectedPublicationServiceIds(prev => (
|
||||
prev.includes(serviceId)
|
||||
? prev.filter(selectedId => selectedId !== serviceId)
|
||||
: [...prev, serviceId]
|
||||
));
|
||||
};
|
||||
|
||||
const handleSaveServicePublication = async () => {
|
||||
if (!createdCompanyId || isCreating) return;
|
||||
if (selectedPublicationServiceIds.length === 0) {
|
||||
setPublicationMessage("Seleccioná al menos un servicio para continuar.");
|
||||
return;
|
||||
}
|
||||
|
||||
const nextStatus = flowId === "publish-services"
|
||||
? SERVICE_PUBLISHED_STATUS.PUBLISHED
|
||||
: SERVICE_PUBLISHED_STATUS.PRIVATE;
|
||||
|
||||
setIsCreating(true);
|
||||
setPublicationMessage("");
|
||||
try {
|
||||
await Promise.all(selectedPublicationServiceIds.map(serviceId =>
|
||||
API.post<any>("services/set-published-status", {
|
||||
serviceId,
|
||||
published: nextStatus,
|
||||
sessionUser: SessionInfo.userId
|
||||
})
|
||||
));
|
||||
setIsSuccess(true);
|
||||
} catch (error: any) {
|
||||
console.error("Error actualizando publicación de servicios:", error);
|
||||
setPublicationMessage(error?.message || "No pudimos guardar el cambio de publicación. Reintentá en unos segundos.");
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderPublicVisibilityFlow = () => (
|
||||
<PublicVisibilityFlow
|
||||
isSelectedOrganizationPaidPlan={isSelectedOrganizationPaidPlan}
|
||||
@@ -1768,6 +2197,59 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
/>
|
||||
);
|
||||
|
||||
const renderServicePublicationFlow = () => (
|
||||
<ServicePublicationFlow
|
||||
mode={flowId === "publish-services" ? "publish" : "unpublish"}
|
||||
currentStepIndex={currentStepIndex}
|
||||
services={publicationServices}
|
||||
selectedServiceIds={selectedPublicationServiceIds}
|
||||
toggleService={togglePublicationService}
|
||||
handleSave={handleSaveServicePublication}
|
||||
isCreating={isCreating}
|
||||
isLoading={publicationLoading}
|
||||
message={publicationMessage}
|
||||
selectedOrganization={selectedOrganization}
|
||||
orgName={orgName}
|
||||
/>
|
||||
);
|
||||
|
||||
const handleLoadCollaboratorScheduleDetails = async () => {
|
||||
if (!createdCompanyId || !targetEmployeeId || collaboratorScheduleLoading) return;
|
||||
|
||||
setCollaboratorScheduleLoading(true);
|
||||
setCollaboratorScheduleMessage("");
|
||||
try {
|
||||
const details = await API.post<CollaboratorScheduleDetails>("admin-assistant/collaborator-schedule-details", {
|
||||
companyId: createdCompanyId,
|
||||
employeeId: targetEmployeeId,
|
||||
sessionUser: SessionInfo.userId,
|
||||
});
|
||||
setCollaboratorScheduleDetails(details);
|
||||
setCurrentStepIndex(1);
|
||||
} catch (error: any) {
|
||||
console.error("Error consultando horarios del colaborador:", error);
|
||||
setCollaboratorScheduleMessage(error?.message || "No pudimos cargar los horarios del colaborador. Reintentá en unos segundos.");
|
||||
} finally {
|
||||
setCollaboratorScheduleLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderCollaboratorScheduleSummaryFlow = () => (
|
||||
<CollaboratorScheduleSummaryFlow
|
||||
currentStepIndex={currentStepIndex}
|
||||
employees={allEmployees}
|
||||
selectedEmployeeId={targetEmployeeId}
|
||||
setSelectedEmployeeId={setTargetEmployeeId}
|
||||
details={collaboratorScheduleDetails}
|
||||
loading={collaboratorScheduleLoading}
|
||||
message={collaboratorScheduleMessage}
|
||||
selectedOrganization={selectedOrganization}
|
||||
orgName={orgName}
|
||||
loadDetails={handleLoadCollaboratorScheduleDetails}
|
||||
goToAssistantHome={() => router.push("/admin/assistant")}
|
||||
/>
|
||||
);
|
||||
|
||||
const getSelectedOrganizationSlug = () => {
|
||||
return buildSelectedOrganizationSlug(selectedOrganization, orgName);
|
||||
};
|
||||
@@ -1905,13 +2387,31 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
/>
|
||||
);
|
||||
const renderFlow = () => {
|
||||
if (requiresOrganizationContext() && !isResuming && allOrganizations.length === 0 && !createdCompanyId) {
|
||||
return (
|
||||
<QuestionCard
|
||||
isActive={true}
|
||||
title="Necesitás una organización"
|
||||
description="Para continuar con este flujo primero tenés que crear o tener asociada una organización. Sin una organización no podemos aplicar esta configuración."
|
||||
onNext={() => router.push("/admin/assistant")}
|
||||
nextLabel="Volver al inicio del asistente"
|
||||
>
|
||||
<div style={{ color: "rgba(255,255,255,0.75)", textAlign: "center", lineHeight: 1.6 }}>
|
||||
Creá una organización desde el inicio del asistente y después volvé a intentar esta acción.
|
||||
</div>
|
||||
</QuestionCard>
|
||||
);
|
||||
}
|
||||
|
||||
if (allOrganizations.length > 1 && !createdCompanyId && action !== "new-org") {
|
||||
return <OrganizationPicker allOrganizations={allOrganizations} loadOrganizationContext={loadOrganizationContext} />;
|
||||
}
|
||||
|
||||
if (flowId === "setup-collaborator") return renderSetupCollaboratorFlow();
|
||||
if (flowId === "new-collaborator") return renderNewCollaboratorFlow();
|
||||
if (flowId === "collaborator-schedule-summary") return renderCollaboratorScheduleSummaryFlow();
|
||||
if (flowId === "public-visibility") return renderPublicVisibilityFlow();
|
||||
if (flowId === "publish-services" || flowId === "unpublish-services") return renderServicePublicationFlow();
|
||||
if (flowId === "public-link") return renderPublicLinkFlow();
|
||||
if (flowId === "notifications") return renderNotificationsFlow();
|
||||
if (flowId === "whatsapp-bot") return renderWhatsAppBotFlow();
|
||||
@@ -1952,6 +2452,8 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
);
|
||||
}
|
||||
|
||||
const shouldHideFlowTitle = flowId === "collaborator-schedule-summary" && currentStepIndex === 1;
|
||||
|
||||
return (
|
||||
<div className={style.engineContainer}>
|
||||
<div className={style.header}>
|
||||
@@ -1960,7 +2462,7 @@ export default function StepEngine({ flowId, action, initialCompanyId, initialEm
|
||||
<span>Volver</span>
|
||||
</div>
|
||||
<div className={style.flowTitle}>
|
||||
Configuración de Negocio
|
||||
{!shouldHideFlowTitle && "Configuración de Negocio"}
|
||||
</div>
|
||||
<div className={style.progressIndicator}>
|
||||
{/* Placeholder for progress dots or percentage */}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import React from "react";
|
||||
|
||||
type Props = {
|
||||
employees: any[];
|
||||
selectedIds?: string[];
|
||||
selectedId?: string | null;
|
||||
multiple?: boolean;
|
||||
disabled?: boolean;
|
||||
onToggle?: (employee: any) => void;
|
||||
onSelect?: (employeeId: string) => void;
|
||||
};
|
||||
|
||||
const getEmployeeId = (employee: any) => employee.id || employee._id || employee.employeeId;
|
||||
|
||||
export const getCollaboratorLabel = (employee: any) => {
|
||||
const fullName = employee.fullName || [employee.firstName, employee.lastName].filter(Boolean).join(" ").trim();
|
||||
return fullName || employee.email || employee.userId?.email || employee.userId?.name || "Colaborador";
|
||||
};
|
||||
|
||||
export default function CollaboratorPicker({ employees, selectedIds = [], selectedId = null, multiple = false, disabled = false, onToggle, onSelect }: Props) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', width: '100%' }}>
|
||||
{employees.map((employee: any) => {
|
||||
const employeeId = getEmployeeId(employee);
|
||||
const isSelected = multiple ? selectedIds.includes(employeeId) : selectedId === employeeId;
|
||||
const label = getCollaboratorLabel(employee);
|
||||
|
||||
return (
|
||||
<label
|
||||
key={employeeId}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '12px 14px',
|
||||
borderRadius: '12px',
|
||||
border: `1px solid ${isSelected ? 'var(--wine-red)' : 'rgba(255,255,255,0.16)'}`,
|
||||
background: isSelected ? 'rgba(128, 0, 64, 0.3)' : 'rgba(255,255,255,0.05)',
|
||||
color: 'white',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer'
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type={multiple ? "checkbox" : "radio"}
|
||||
checked={isSelected}
|
||||
disabled={disabled}
|
||||
onChange={() => {
|
||||
if (disabled) return;
|
||||
if (multiple) {
|
||||
onToggle?.(employee);
|
||||
} else if (employeeId) {
|
||||
onSelect?.(employeeId);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span style={{ fontSize: '15px' }}>{label}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,8 @@ export default function SuccessScreen(props: Props) {
|
||||
if (flowId === "override-schedule" || flowId === "disable-schedule") return "El horario especial ha sido guardado exitosamente.";
|
||||
if (flowId === "new-collaborator") return "El colaborador fue invitado exitosamente.";
|
||||
if (flowId === "setup-collaborator") return "El colaborador ha sido configurado exitosamente y ya puede comenzar a recibir reservas.";
|
||||
if (flowId === "publish-services") return "Los servicios seleccionados ya están publicados.";
|
||||
if (flowId === "unpublish-services") return "Los servicios seleccionados dejaron de estar publicados.";
|
||||
if (flowId === "notifications") return "La configuración de notificaciones se ha guardado exitosamente.";
|
||||
if (flowId === "whatsapp-bot") return "Tu bot de WhatsApp está configurado y funcionando. Tus clientes recibirán notificaciones automáticas por WhatsApp.";
|
||||
if (flowId === "open-reservation-periods") return "El período de reservas fue extendido exitosamente.";
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
import React from "react";
|
||||
import dayjs from "dayjs";
|
||||
import QuestionCard from "../../QuestionCard/QuestionCard";
|
||||
import CollaboratorPicker from "../components/CollaboratorPicker";
|
||||
|
||||
type ScheduleItem = {
|
||||
from: string;
|
||||
to: string;
|
||||
disabled: boolean;
|
||||
serviceScope?: "all" | "specific";
|
||||
serviceIds?: string[];
|
||||
};
|
||||
|
||||
type WeeklySchedule = {
|
||||
scheduleId: string;
|
||||
weekDay: number;
|
||||
schedules: ScheduleItem[];
|
||||
};
|
||||
|
||||
type DisabledSchedule = {
|
||||
id?: string;
|
||||
_id?: string;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
};
|
||||
|
||||
type ScheduleOverride = {
|
||||
id?: string;
|
||||
_id?: string;
|
||||
date: Date | string;
|
||||
schedules: ScheduleItem[];
|
||||
};
|
||||
|
||||
export type CollaboratorScheduleDetails = {
|
||||
collaborator: {
|
||||
employeeId: string;
|
||||
fullName: string;
|
||||
email: string;
|
||||
avatar: string;
|
||||
userId: string;
|
||||
};
|
||||
servicesById: Record<string, { id: string; name: string }>;
|
||||
weeklySchedules: WeeklySchedule[];
|
||||
futureDisabledSchedules: DisabledSchedule[];
|
||||
futureOverrides: ScheduleOverride[];
|
||||
};
|
||||
|
||||
type Props = {
|
||||
currentStepIndex: number;
|
||||
employees: any[];
|
||||
selectedEmployeeId: string | null;
|
||||
setSelectedEmployeeId: (employeeId: string) => void;
|
||||
details: CollaboratorScheduleDetails | null;
|
||||
loading: boolean;
|
||||
message: string;
|
||||
selectedOrganization?: any;
|
||||
orgName: string;
|
||||
loadDetails: () => void;
|
||||
goToAssistantHome: () => void;
|
||||
};
|
||||
|
||||
const weekDays = ["Domingo", "Lunes", "Martes", "Miércoles", "Jueves", "Viernes", "Sábado"];
|
||||
const weekDayDisplayOrder = [1, 2, 3, 4, 5, 6, 0];
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
padding: "14px",
|
||||
borderRadius: "14px",
|
||||
background: "rgba(255,255,255,0.05)",
|
||||
border: "1px solid rgba(255,255,255,0.12)",
|
||||
color: "white",
|
||||
};
|
||||
|
||||
const mutedStyle: React.CSSProperties = {
|
||||
color: "rgba(255,255,255,0.7)",
|
||||
fontSize: "14px",
|
||||
lineHeight: 1.45,
|
||||
};
|
||||
|
||||
const formatDate = (date: Date | string) => dayjs(date).format("DD/MM/YYYY");
|
||||
|
||||
const formatSchedule = (schedule: ScheduleItem, servicesById: CollaboratorScheduleDetails["servicesById"] = {}) => {
|
||||
const selectedServices = (schedule.serviceIds || [])
|
||||
.map(serviceId => servicesById[serviceId]?.name || `Servicio ${serviceId}`)
|
||||
.filter(Boolean);
|
||||
const scope = schedule.serviceScope === "specific"
|
||||
? selectedServices.length > 0 ? selectedServices.join(", ") : "servicios específicos sin nombre disponible"
|
||||
: "todos los servicios";
|
||||
return `${schedule.from} a ${schedule.to}${schedule.disabled ? " (deshabilitado)" : ""} · ${scope}`;
|
||||
};
|
||||
|
||||
function ExpandableItem({ title, subtitle, children }: { title: string; subtitle: string; children: React.ReactNode }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(prev => !prev)}
|
||||
style={{ ...cardStyle, width: "100%", textAlign: "left", cursor: "pointer" }}
|
||||
>
|
||||
<span style={{ display: "flex", justifyContent: "space-between", gap: "12px", alignItems: "center" }}>
|
||||
<span style={{ display: "flex", flexDirection: "column", gap: "4px" }}>
|
||||
<strong>{title}</strong>
|
||||
<span style={mutedStyle}>{subtitle}</span>
|
||||
</span>
|
||||
<span style={{ color: "rgba(255,255,255,0.72)", fontSize: "13px" }}>{open ? "Ocultar" : "Ver detalle"}</span>
|
||||
</span>
|
||||
{open && (
|
||||
<span style={{ display: "block", marginTop: "12px", paddingTop: "12px", borderTop: "1px solid rgba(255,255,255,0.1)" }}>
|
||||
{children}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CollaboratorScheduleSummaryFlow(props: Props) {
|
||||
const {
|
||||
currentStepIndex,
|
||||
employees,
|
||||
selectedEmployeeId,
|
||||
setSelectedEmployeeId,
|
||||
details,
|
||||
loading,
|
||||
message,
|
||||
selectedOrganization,
|
||||
orgName,
|
||||
loadDetails,
|
||||
goToAssistantHome,
|
||||
} = props;
|
||||
const configuredWeeklySchedules = details?.weeklySchedules
|
||||
.filter(day => day.schedules.length > 0)
|
||||
.sort((a, b) => weekDayDisplayOrder.indexOf(a.weekDay) - weekDayDisplayOrder.indexOf(b.weekDay)) || [];
|
||||
|
||||
if (currentStepIndex === 0) {
|
||||
return (
|
||||
<QuestionCard
|
||||
isActive={true}
|
||||
title="Elegí el colaborador"
|
||||
description="Seleccioná a quién querés consultar. Esta acción es sólo de lectura."
|
||||
onNext={loadDetails}
|
||||
disableNext={!selectedEmployeeId || loading || employees.length === 0}
|
||||
nextLabel={loading ? "Consultando..." : "Ver horarios"}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "14px", width: "100%" }}>
|
||||
<div style={{ ...mutedStyle, textAlign: "center" }}>
|
||||
Organización: <strong style={{ color: "white" }}>{selectedOrganization?.name || orgName || "seleccionada"}</strong>
|
||||
</div>
|
||||
{message && <div style={cardStyle}>{message}</div>}
|
||||
{employees.length === 0 ? (
|
||||
<div style={{ ...cardStyle, ...mutedStyle, textAlign: "center" }}>No encontramos colaboradores activos en esta organización.</div>
|
||||
) : (
|
||||
<CollaboratorPicker employees={employees} selectedId={selectedEmployeeId} onSelect={setSelectedEmployeeId} disabled={loading} />
|
||||
)}
|
||||
</div>
|
||||
</QuestionCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<QuestionCard
|
||||
isActive={true}
|
||||
title=""
|
||||
onNext={goToAssistantHome}
|
||||
nextLabel="Volver al asistente"
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "16px", width: "100%", paddingTop: "150px" }}>
|
||||
{message && <div style={cardStyle}>{message}</div>}
|
||||
{!details ? (
|
||||
<div style={{ ...cardStyle, ...mutedStyle, textAlign: "center" }}>No hay datos para mostrar.</div>
|
||||
) : (
|
||||
<>
|
||||
<div style={{ ...cardStyle, display: "flex", alignItems: "center", justifyContent: "center", gap: "12px", textAlign: "left", padding: "18px 14px", marginTop: "4px" }}>
|
||||
<img
|
||||
src={details.collaborator.avatar}
|
||||
alt={details.collaborator.fullName}
|
||||
style={{ width: "46px", height: "46px", borderRadius: "50%", objectFit: "cover", border: "2px solid rgba(255,255,255,0.18)", flex: "0 0 auto" }}
|
||||
/>
|
||||
<div>
|
||||
<strong style={{ display: "block", fontSize: "18px", lineHeight: 1.35 }}>{details.collaborator.fullName}</strong>
|
||||
<div style={mutedStyle}>{details.collaborator.email || "Sin email registrado"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
|
||||
<h3 style={{ margin: 0, fontSize: "16px", color: "white" }}>Horarios semanales</h3>
|
||||
{configuredWeeklySchedules.length === 0 ? (
|
||||
<div style={{ ...cardStyle, ...mutedStyle }}>Este colaborador no tiene horarios semanales configurados.</div>
|
||||
) : configuredWeeklySchedules.map(day => (
|
||||
<div key={day.scheduleId || day.weekDay} style={cardStyle}>
|
||||
<strong>{weekDays[day.weekDay] || `Día ${day.weekDay}`}</strong>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "6px", marginTop: "8px" }}>
|
||||
{day.schedules.map((schedule, index) => (
|
||||
<span key={`${day.weekDay}-${index}`} style={mutedStyle}>{formatSchedule(schedule, details.servicesById)}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
|
||||
<h3 style={{ margin: 0, fontSize: "16px", color: "white" }}>Excepciones futuras</h3>
|
||||
{details.futureOverrides.length === 0 ? (
|
||||
<div style={{ ...cardStyle, ...mutedStyle }}>No hay excepciones futuras configuradas.</div>
|
||||
) : details.futureOverrides.map((override, index) => (
|
||||
<ExpandableItem key={override.id || override._id || index} title={formatDate(override.date)} subtitle={`${override.schedules.length} bloque(s) configurado(s)`}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "6px" }}>
|
||||
{override.schedules.map((schedule, scheduleIndex) => (
|
||||
<span key={scheduleIndex} style={mutedStyle}>{formatSchedule(schedule, details.servicesById)}</span>
|
||||
))}
|
||||
</div>
|
||||
</ExpandableItem>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
|
||||
<h3 style={{ margin: 0, fontSize: "16px", color: "white" }}>Restricciones / agenda cerrada</h3>
|
||||
{details.futureDisabledSchedules.length === 0 ? (
|
||||
<div style={{ ...cardStyle, ...mutedStyle }}>No hay cierres de agenda futuros configurados.</div>
|
||||
) : details.futureDisabledSchedules.map((disabled, index) => (
|
||||
<ExpandableItem
|
||||
key={disabled.id || disabled._id || index}
|
||||
title={`${formatDate(disabled.startDate)} al ${formatDate(disabled.endDate)}`}
|
||||
subtitle="Agenda cerrada para este colaborador"
|
||||
>
|
||||
<div style={mutedStyle}>Durante este período no se muestran turnos disponibles para el colaborador.</div>
|
||||
</ExpandableItem>
|
||||
))}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</QuestionCard>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import QuestionCard from "../../QuestionCard/QuestionCard";
|
||||
import QuestionInput from "../../QuestionCard/QuestionInput";
|
||||
import CollaboratorPicker from "../components/CollaboratorPicker";
|
||||
|
||||
type OnboardingServiceFlowProps = {
|
||||
heatMapFraction: any;
|
||||
@@ -19,15 +20,49 @@ type OnboardingServiceFlowProps = {
|
||||
setServicePrice: any;
|
||||
serviceLimit: any;
|
||||
setServiceLimit: any;
|
||||
serviceCreateError: any;
|
||||
handleUploadServiceImage: any;
|
||||
serviceImage: 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) {
|
||||
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 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 (
|
||||
<>
|
||||
@@ -75,7 +110,7 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
|
||||
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).`}
|
||||
onNext={handleNext}
|
||||
disableNext={!serviceLength}
|
||||
disableNext={!serviceLength || (isCustomDuration && !isValidCustomDuration)}
|
||||
>
|
||||
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap', width: '100%', justifyContent: 'center' }}>
|
||||
{durationOptions.map(val => {
|
||||
@@ -88,15 +123,15 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
|
||||
return (
|
||||
<div
|
||||
key={val}
|
||||
onClick={() => setServiceLength(val)}
|
||||
onClick={() => selectDurationOption(val)}
|
||||
style={{
|
||||
padding: '12px 24px',
|
||||
borderRadius: '12px',
|
||||
cursor: 'pointer',
|
||||
background: 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)'}`,
|
||||
background: !isCustomDuration && serviceLength === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.05)',
|
||||
border: `2px solid ${!isCustomDuration && serviceLength === val ? 'var(--wine-red)' : 'rgba(255,255,255,0.1)'}`,
|
||||
color: 'white',
|
||||
fontWeight: serviceLength === val ? 'bold' : 'normal',
|
||||
fontWeight: !isCustomDuration && serviceLength === val ? 'bold' : 'normal',
|
||||
transition: 'all 0.2s ease',
|
||||
fontSize: '16px'
|
||||
}}
|
||||
@@ -105,7 +140,44 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
|
||||
</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>
|
||||
{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
|
||||
@@ -113,7 +185,7 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
|
||||
title="3. Precio y Cupos"
|
||||
description="¿Cuánto cuesta y a cuántas personas puedes atender a la vez en este servicio?"
|
||||
onNext={handleCreateService}
|
||||
disableNext={isCreating || !servicePrice}
|
||||
disableNext={isCreating}
|
||||
nextLabel={isCreating ? "Guardando..." : "Crear Servicio"}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px', width: '100%' }}>
|
||||
@@ -122,7 +194,8 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
|
||||
<div style={{ position: 'relative' }}>
|
||||
<span style={{ position: 'absolute', left: '15px', top: '50%', transform: 'translateY(-50%)', color: 'white', fontSize: '18px' }}>$</span>
|
||||
<input
|
||||
type="number"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
placeholder="0.00"
|
||||
value={servicePrice}
|
||||
onChange={(e) => setServicePrice(e.target.value)}
|
||||
@@ -163,6 +236,20 @@ 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>
|
||||
</QuestionCard>
|
||||
|
||||
@@ -172,7 +259,7 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
|
||||
description="Sube una foto representativa para que los clientes sepan qué ofreces. (Opcional)"
|
||||
onNext={handleUploadServiceImage}
|
||||
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={{
|
||||
@@ -216,8 +303,151 @@ export default function OnboardingServiceFlow(props: OnboardingServiceFlowProps)
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
</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,233 +1,240 @@
|
||||
import React from "react";
|
||||
import QuestionCard from "../../QuestionCard/QuestionCard";
|
||||
import CollaboratorPicker from "../components/CollaboratorPicker";
|
||||
|
||||
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) {
|
||||
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 (
|
||||
<>
|
||||
<QuestionCard
|
||||
isActive={currentStepIndex === 0}
|
||||
title="Actualizar Horarios"
|
||||
description="¿A quiénes quieres aplicar esta nueva rutina de horarios?"
|
||||
onNext={() => {
|
||||
if (scheduleScope === "specific" && !targetEmployeeId) {
|
||||
alert("Por favor selecciona un colaborador.");
|
||||
return;
|
||||
}
|
||||
handleNext();
|
||||
}}
|
||||
disableNext={!scheduleScope || (scheduleScope === "specific" && !targetEmployeeId)}
|
||||
description="¿A quiénes querés aplicar esta nueva rutina de horarios?"
|
||||
onNext={handleNext}
|
||||
disableNext={!scheduleScope || (scheduleScope === "specific" && selectedScheduleEmployeeIds.length === 0)}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px', width: '100%' }}>
|
||||
<div
|
||||
onClick={() => setScheduleScope("me")}
|
||||
style={{
|
||||
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 style={{ display: "flex", flexDirection: "column", gap: "10px", width: "100%" }}>
|
||||
<div onClick={() => setScheduleScope("me")} style={optionStyle(scheduleScope === "me")}>
|
||||
<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
|
||||
onClick={() => setScheduleScope("specific")}
|
||||
style={{
|
||||
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 onClick={() => setScheduleScope("specific")} style={optionStyle(scheduleScope === "specific")}>
|
||||
<h4 style={{ margin: "0 0 5px 0" }}>A uno o más colaboradores</h4>
|
||||
<p style={{ margin: 0, fontSize: "12px", opacity: 0.7 }}>Aplica esta rutina a uno o varios integrantes específicos de tu equipo.</p>
|
||||
</div>
|
||||
<div
|
||||
onClick={() => setScheduleScope("all")}
|
||||
style={{
|
||||
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 onClick={() => setScheduleScope("all")} style={optionStyle(scheduleScope === "all")}>
|
||||
<h4 style={{ margin: "0 0 5px 0" }}>A todos mis colaboradores</h4>
|
||||
<p style={{ margin: 0, fontSize: "12px", opacity: 0.7 }}>Actualiza masivamente los horarios de todo tu equipo.</p>
|
||||
</div>
|
||||
|
||||
{scheduleScope === "specific" && (
|
||||
<div style={{ marginTop: '15px', width: '100%' }}>
|
||||
<label style={{ color: 'white', display: 'block', marginBottom: '8px', fontSize: '14px' }}>Selecciona el colaborador:</label>
|
||||
<select
|
||||
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 style={{ marginTop: "15px", width: "100%" }}>
|
||||
<label style={{ color: "white", display: "block", marginBottom: "8px", fontSize: "14px" }}>Seleccioná uno o más colaboradores:</label>
|
||||
<CollaboratorPicker employees={allEmployees} multiple selectedIds={selectedScheduleEmployeeIds} onToggle={handleToggleScheduleEmployee} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</QuestionCard>
|
||||
|
||||
<QuestionCard
|
||||
isActive={currentStepIndex === 1}
|
||||
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%' }}>
|
||||
<QuestionCard isActive={currentStepIndex === 1} 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-sat', label: 'Lunes a Sábado' },
|
||||
{ id: 'mon-sun', label: 'Todos los días' }
|
||||
].map(mode => (
|
||||
<div
|
||||
key={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'
|
||||
}}
|
||||
>
|
||||
{ id: "mon-fri", label: "Lunes a Viernes" },
|
||||
{ id: "mon-sat", label: "Lunes a Sábado" },
|
||||
{ id: "mon-sun", label: "Todos los días" },
|
||||
{ id: "specific-day", label: "Día específico" },
|
||||
].map((mode) => (
|
||||
<div key={mode.id} onClick={() => setWorkingDaysMode(mode.id)} style={optionStyle(workingDaysMode === mode.id)}>
|
||||
{mode.label}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</QuestionCard>
|
||||
|
||||
<QuestionCard
|
||||
isActive={currentStepIndex === 2}
|
||||
title="Horario Comercial"
|
||||
description="¿A qué hora empieza y termina la jornada laboral?"
|
||||
onNext={handleNext}
|
||||
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'
|
||||
}}
|
||||
/>
|
||||
<QuestionCard isActive={currentStepIndex === weekDayStep && isSpecificDay} title="Día específico" description="Elegí el día que querés configurar." onNext={handleNext} disableNext={specificWeekDay === null}>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(130px, 1fr))", gap: "10px", width: "100%" }}>
|
||||
{weekDays.map((day) => (
|
||||
<div key={day.id} onClick={() => setSpecificWeekDay(day.id)} style={optionStyle(specificWeekDay === day.id)}>
|
||||
{day.label}
|
||||
</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>
|
||||
</QuestionCard>
|
||||
|
||||
<QuestionCard isActive={currentStepIndex === timeStep} title="Horario Comercial" description="¿A qué hora empieza y termina la jornada laboral?" onNext={handleNext} 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 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>
|
||||
</QuestionCard>
|
||||
|
||||
<QuestionCard
|
||||
isActive={currentStepIndex === 3}
|
||||
title="Descansos"
|
||||
description="¿Tienen un horario de corte al mediodía o descanso?"
|
||||
onNext={() => {
|
||||
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
|
||||
<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")}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px", width: "100%" }}>
|
||||
<div style={{ display: "flex", gap: "15px", width: "100%", justifyContent: "center" }}>
|
||||
<div onClick={() => setHasBreak(true)} style={{ ...optionStyle(hasBreak === true), flex: 1 }}>Sí, hay descanso</div>
|
||||
<div onClick={() => setHasBreak(false)} style={{ ...optionStyle(hasBreak === false), flex: 1 }}>No, horario corrido</div>
|
||||
</div>
|
||||
{renderConflictActions()}
|
||||
</div>
|
||||
</QuestionCard>
|
||||
|
||||
<QuestionCard
|
||||
isActive={currentStepIndex === 4}
|
||||
title="Horario de Descanso"
|
||||
description="¿De qué hora a qué hora no estarán disponibles?"
|
||||
onNext={handleUpdateScheduleSubmit}
|
||||
disableNext={!breakStart || !breakEnd || isCreating}
|
||||
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'
|
||||
}}
|
||||
/>
|
||||
<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"}>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "12px", width: "100%" }}>
|
||||
<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 style={{ display: 'flex', flexDirection: 'column', gap: '5px' }}>
|
||||
<label style={{ color: 'white', fontSize: '12px', opacity: 0.7 }}>Fin descanso</label>
|
||||
<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'
|
||||
}}
|
||||
/>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "5px" }}>
|
||||
<label style={{ color: "white", fontSize: "12px", opacity: 0.7 }}>Fin descanso</label>
|
||||
<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" }} />
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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[] = []) => {
|
||||
if (workingDaysMode === "mon-fri") return [1, 2, 3, 4, 5];
|
||||
@@ -13,14 +19,18 @@ export const buildScheduleBlocks = (
|
||||
closeTime: string,
|
||||
hasBreak: boolean | null,
|
||||
breakStart: string,
|
||||
breakEnd: string
|
||||
breakEnd: string,
|
||||
serviceScope: "all" | "specific" = "all",
|
||||
serviceIds: string[] = []
|
||||
): ScheduleBlock[] => {
|
||||
const serviceData = serviceScope === "specific" ? { serviceScope, serviceIds } : { serviceScope: "all" as const, serviceIds: [] };
|
||||
|
||||
if (hasBreak) {
|
||||
return [
|
||||
{ from: openTime, to: breakStart, disabled: false },
|
||||
{ from: breakEnd, to: closeTime, disabled: false }
|
||||
{ from: openTime, to: breakStart, disabled: false, ...serviceData },
|
||||
{ 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";
|
||||
import {
|
||||
findSystemNotifications,
|
||||
setStateNotificationByUser,
|
||||
updateSystemNotification,
|
||||
} from "@core/app/user/profile/notifications/Notifications.Service";
|
||||
import {
|
||||
@@ -214,6 +215,29 @@ export default function Header(): React.ReactElement {
|
||||
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) => {
|
||||
setNotificationsOpen(false);
|
||||
|
||||
@@ -392,6 +416,15 @@ export default function Header(): React.ReactElement {
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={style.notificationsFooterActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={style.notificationsFooter}
|
||||
onClick={markNotificationsAsRead}
|
||||
disabled={headerState.notificationsCount === 0}
|
||||
>
|
||||
Marcar como leídas
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={style.notificationsFooter}
|
||||
@@ -400,6 +433,7 @@ export default function Header(): React.ReactElement {
|
||||
Ver todas las notificaciones
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -185,11 +185,16 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notificationsFooterActions {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--gray-lighter);
|
||||
background: var(--white);
|
||||
}
|
||||
|
||||
.notificationsFooter {
|
||||
width: 100%;
|
||||
padding: 14px 18px;
|
||||
border: 0;
|
||||
border-top: 1px solid var(--gray-lighter);
|
||||
background: var(--white);
|
||||
color: var(--wine-dark);
|
||||
cursor: pointer;
|
||||
@@ -198,11 +203,21 @@
|
||||
transition: background-color 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
|
||||
.notificationsFooter:hover {
|
||||
.notificationsFooter + .notificationsFooter {
|
||||
border-left: 1px solid var(--gray-lighter);
|
||||
}
|
||||
|
||||
.notificationsFooter:hover:not(:disabled) {
|
||||
background: var(--wine-lighterX2);
|
||||
color: var(--wine-darkest);
|
||||
}
|
||||
|
||||
.notificationsFooter:disabled {
|
||||
color: var(--gray-dark);
|
||||
cursor: default;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.header {
|
||||
padding-left: 18px;
|
||||
@@ -212,4 +227,13 @@
|
||||
.notificationsMenu {
|
||||
right: -48px;
|
||||
}
|
||||
|
||||
.notificationsFooterActions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.notificationsFooter + .notificationsFooter {
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--gray-lighter);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ export default function Login(props: LoginProps) {
|
||||
const [password, setPassword] = useState("");
|
||||
const [verificationCode, setVerificationCode] = useState("");
|
||||
const [checkReadTerms, setCheckReadTerms] = useState(false);
|
||||
const [loginMethod, setLoginMethod] = useState<"google" | "password">("google");
|
||||
|
||||
const clientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
|
||||
|
||||
@@ -323,8 +324,164 @@ export default function Login(props: LoginProps) {
|
||||
</>
|
||||
)}
|
||||
{!verification && !recovery && !recoveryCode && (
|
||||
<>
|
||||
<h1 className="homeTitleHeader">Ingresar</h1>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: "10px",
|
||||
marginBottom: "24px",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
color: "#777777",
|
||||
fontSize: "14px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Elegí cómo querés acceder
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLoginMethod("google")}
|
||||
style={{
|
||||
border:
|
||||
loginMethod === "google"
|
||||
? "2px solid var(--wine-dark)"
|
||||
: "1px solid #e1e1e1",
|
||||
borderRadius: "14px",
|
||||
background:
|
||||
loginMethod === "google"
|
||||
? "rgba(111, 16, 50, 0.08)"
|
||||
: "#ffffff",
|
||||
color:
|
||||
loginMethod === "google"
|
||||
? "var(--wine-dark)"
|
||||
: "#3f3f3f",
|
||||
cursor: "pointer",
|
||||
fontWeight: loginMethod === "google" ? 700 : 500,
|
||||
padding: "14px 16px",
|
||||
textAlign: "left",
|
||||
boxShadow:
|
||||
loginMethod === "google"
|
||||
? "0 8px 18px rgba(111, 16, 50, 0.12)"
|
||||
: "none",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: "12px",
|
||||
}}
|
||||
>
|
||||
<span>Continuar con Google</span>
|
||||
<span
|
||||
style={{
|
||||
borderRadius: "999px",
|
||||
background: "var(--wine-dark)",
|
||||
color: "#ffffff",
|
||||
fontSize: "11px",
|
||||
fontWeight: 700,
|
||||
padding: "4px 8px",
|
||||
}}
|
||||
>
|
||||
Recomendado
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
marginTop: "4px",
|
||||
color: "#777777",
|
||||
fontSize: "13px",
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
Más rápido, sin recordar otra contraseña.
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setLoginMethod("password")}
|
||||
style={{
|
||||
border:
|
||||
loginMethod === "password"
|
||||
? "2px solid var(--wine-dark)"
|
||||
: "1px solid #e1e1e1",
|
||||
borderRadius: "14px",
|
||||
background:
|
||||
loginMethod === "password"
|
||||
? "rgba(111, 16, 50, 0.08)"
|
||||
: "#ffffff",
|
||||
color:
|
||||
loginMethod === "password"
|
||||
? "var(--wine-dark)"
|
||||
: "#3f3f3f",
|
||||
cursor: "pointer",
|
||||
fontWeight: loginMethod === "password" ? 700 : 500,
|
||||
padding: "14px 16px",
|
||||
textAlign: "left",
|
||||
boxShadow:
|
||||
loginMethod === "password"
|
||||
? "0 8px 18px rgba(111, 16, 50, 0.12)"
|
||||
: "none",
|
||||
}}
|
||||
>
|
||||
<span>Email y clave</span>
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
marginTop: "4px",
|
||||
color: "#777777",
|
||||
fontSize: "13px",
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
Usá tu cuenta tradicional de TurnosXpress.
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!verification && !recovery && !recoveryCode && loginMethod === "google" && (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "flex-start",
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checkReadTerms}
|
||||
onClick={() => setCheckReadTerms(!checkReadTerms)}
|
||||
/>
|
||||
<div style={{ marginTop: "8px" }}>
|
||||
Declaro que acepto los siguientes
|
||||
<Link href="/landing/terms">
|
||||
Términos y condiciones
|
||||
</Link>{" "}
|
||||
y{" "}
|
||||
<Link href="/landing/privacy">
|
||||
Políticas de privacidad
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonGoogle termsReaded={checkReadTerms} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!verification && !recovery && !recoveryCode
|
||||
? loginMethod === "password"
|
||||
: true) && (
|
||||
<>
|
||||
<Textbox
|
||||
name="name"
|
||||
placeholder="Email"
|
||||
@@ -417,34 +574,7 @@ export default function Login(props: LoginProps) {
|
||||
type="submit"
|
||||
/>
|
||||
)}
|
||||
|
||||
{!verification && !recovery && !recoveryCode && (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "flex-start",
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={checkReadTerms}
|
||||
onClick={() => setCheckReadTerms(!checkReadTerms)}
|
||||
/>
|
||||
<div style={{ marginTop: "8px" }}>
|
||||
Declaro que acepto los siguientes
|
||||
<Link href="/landing/terms">
|
||||
Términos y condiciones
|
||||
</Link>{" "}
|
||||
y{" "}
|
||||
<Link href="/landing/privacy">
|
||||
Políticas de privacidad
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonGoogle termsReaded={checkReadTerms} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!recoveryCode && (
|
||||
<p style={{ marginTop: "20px" }}>
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
.page {
|
||||
width: 100vw;
|
||||
min-height: 100dvh;
|
||||
margin-left: calc(50% - 50vw);
|
||||
margin-right: calc(50% - 50vw);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background:
|
||||
radial-gradient(circle at 20% 20%, rgba(255, 42, 127, 0.18), transparent 34%),
|
||||
linear-gradient(180deg, var(--wine-darkest) 0%, var(--wine-dark) 100%);
|
||||
}
|
||||
|
||||
.card {
|
||||
width: min(860px, calc(100% - 48px));
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 0.9fr) minmax(300px, 1fr);
|
||||
gap: 28px;
|
||||
align-items: center;
|
||||
padding: clamp(22px, 3vw, 36px);
|
||||
border-radius: 28px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
box-shadow: 0 18px 54px rgba(0, 0, 0, 0.2);
|
||||
border: 1px solid rgba(255, 255, 255, 0.42);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.illustrationPanel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 300px;
|
||||
border-radius: 24px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.illustration {
|
||||
width: min(100%, 360px);
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
max-width: 430px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
padding: 8px 14px;
|
||||
border-radius: 999px;
|
||||
color: var(--wine-red);
|
||||
background: rgba(255, 42, 127, 0.1);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
margin: 0;
|
||||
color: var(--wine-darkest);
|
||||
font-size: clamp(2rem, 4vw, 3.15rem);
|
||||
line-height: 1.04;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.content p {
|
||||
margin: 0;
|
||||
color: rgba(37, 5, 37, 0.72);
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.primaryAction,
|
||||
.secondaryAction {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 46px;
|
||||
padding: 0 22px;
|
||||
border-radius: 999px;
|
||||
font-weight: 800;
|
||||
text-decoration: none;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.primaryAction {
|
||||
color: white;
|
||||
background: var(--wine-red);
|
||||
box-shadow: 0 12px 26px rgba(255, 42, 127, 0.28);
|
||||
}
|
||||
|
||||
.secondaryAction {
|
||||
color: var(--wine-red);
|
||||
background: rgba(255, 42, 127, 0.08);
|
||||
}
|
||||
|
||||
.primaryAction:hover,
|
||||
.secondaryAction:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.page {
|
||||
align-items: flex-start;
|
||||
padding: 88px 16px 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 18px;
|
||||
text-align: center;
|
||||
width: min(420px, calc(100% - 32px));
|
||||
padding: 22px 20px 24px;
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
box-shadow: 0 14px 38px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.content {
|
||||
align-items: center;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.illustrationPanel {
|
||||
min-height: 0;
|
||||
padding: 16px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.illustration {
|
||||
width: min(100%, 260px);
|
||||
max-height: 230px;
|
||||
}
|
||||
|
||||
.content {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
max-width: 12ch;
|
||||
font-size: clamp(1.8rem, 8vw, 2.35rem);
|
||||
line-height: 1.05;
|
||||
}
|
||||
|
||||
.content p {
|
||||
max-width: 27ch;
|
||||
font-size: 0.98rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.actions {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.primaryAction,
|
||||
.secondaryAction {
|
||||
width: min(100%, 220px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 420px) {
|
||||
.page {
|
||||
padding: 78px 12px 18px;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: min(390px, calc(100% - 24px));
|
||||
padding: 18px 18px 22px;
|
||||
gap: 14px;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.illustrationPanel {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.illustration {
|
||||
width: min(100%, 230px);
|
||||
max-height: 205px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 7px 12px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.content h1 {
|
||||
font-size: clamp(1.65rem, 7.5vw, 2rem);
|
||||
}
|
||||
|
||||
.content p {
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.primaryAction,
|
||||
.secondaryAction {
|
||||
min-height: 42px;
|
||||
padding: 0 18px;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,28 @@
|
||||
import Link from "next/link";
|
||||
import styles from "./ServicePrivate.module.css";
|
||||
|
||||
export default function ServicePrivate() {
|
||||
return (
|
||||
<div className="card error-card">
|
||||
<h2 style={{ color: "var(--wine-red)" }}>
|
||||
Este servicio aun no ha sido publicado!
|
||||
</h2>
|
||||
<div className={styles.page}>
|
||||
<section className={styles.card} aria-labelledby="service-private-title">
|
||||
<div className={styles.illustrationPanel} aria-hidden="true">
|
||||
<img src="/error.png" alt="" className={styles.illustration} />
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
<span className={styles.badge}>Servicio no disponible</span>
|
||||
<h1 id="service-private-title">Este servicio todavía no está publicado</h1>
|
||||
<p>
|
||||
Este servicio no ha sido publicado. Por favor, intenta volver a
|
||||
cargar la página o regresa más tarde.
|
||||
El perfil público existe, pero el servicio aún no fue habilitado para recibir reservas.
|
||||
Puede estar en preparación o haber sido retirado temporalmente.
|
||||
</p>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Link href="/" className={styles.primaryAction}>Ir al inicio</Link>
|
||||
<Link href="/landing" className={styles.secondaryAction}>Explorar servicios</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,11 @@ export default function TimePicker(): React.ReactElement {
|
||||
sx={{ borderRadius: "8px" }}
|
||||
className={style.timePicker}
|
||||
onClose={() => timePickerState.hide()}
|
||||
onChange={(newValue: dayjs.Dayjs | null) => {
|
||||
if (newValue) {
|
||||
timePickerState.setValue(newValue);
|
||||
}
|
||||
}}
|
||||
onAccept={(newValue: dayjs.Dayjs | null) => {
|
||||
if (newValue) {
|
||||
timePickerState.setValue(newValue);
|
||||
|
||||
@@ -136,7 +136,10 @@
|
||||
.assistantMenuItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
gap: 15px;
|
||||
min-height: 59px;
|
||||
box-sizing: border-box;
|
||||
padding: 14px 20px;
|
||||
margin: 10px 0;
|
||||
border-radius: 12px;
|
||||
@@ -157,9 +160,14 @@
|
||||
filter: brightness(0) invert(1); /* makes the black SVG white */
|
||||
}
|
||||
|
||||
.assistantMenuItem span {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.assistantMenuItem:hover {
|
||||
transform: scale(1.02);
|
||||
animation: none;
|
||||
color: white;
|
||||
box-shadow: 0 8px 25px rgba(255, 65, 108, 0.5);
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ export default function RegisterPage() {
|
||||
const [areaCode, setAreaCode] = useState("");
|
||||
const [phone, setPhone] = useState("");
|
||||
const [checkReadTerms, setCheckReadTerms] = useState(false);
|
||||
const [registerMethod, setRegisterMethod] = useState<"google" | "manual">("google");
|
||||
|
||||
const registerUser = () => {
|
||||
const validationPhoneResult = validatePhone(areaCode, phone);
|
||||
@@ -100,6 +101,140 @@ export default function RegisterPage() {
|
||||
<h1 className="homeTitleHeader">
|
||||
Registrate Gratis
|
||||
</h1>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: "10px",
|
||||
marginBottom: "24px",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
color: "#777777",
|
||||
fontSize: "14px",
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
Elegí cómo querés crear tu cuenta
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRegisterMethod("google")}
|
||||
style={{
|
||||
border:
|
||||
registerMethod === "google"
|
||||
? "2px solid var(--wine-dark)"
|
||||
: "1px solid #e1e1e1",
|
||||
borderRadius: "14px",
|
||||
background:
|
||||
registerMethod === "google"
|
||||
? "rgba(111, 16, 50, 0.08)"
|
||||
: "#ffffff",
|
||||
color:
|
||||
registerMethod === "google"
|
||||
? "var(--wine-dark)"
|
||||
: "#3f3f3f",
|
||||
cursor: "pointer",
|
||||
fontWeight: registerMethod === "google" ? 700 : 500,
|
||||
padding: "14px 16px",
|
||||
textAlign: "left",
|
||||
boxShadow:
|
||||
registerMethod === "google"
|
||||
? "0 8px 18px rgba(111, 16, 50, 0.12)"
|
||||
: "none",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
gap: "12px",
|
||||
}}
|
||||
>
|
||||
<span>Continuar con Google</span>
|
||||
<span
|
||||
style={{
|
||||
borderRadius: "999px",
|
||||
background: "var(--wine-dark)",
|
||||
color: "#ffffff",
|
||||
fontSize: "11px",
|
||||
fontWeight: 700,
|
||||
padding: "4px 8px",
|
||||
}}
|
||||
>
|
||||
Recomendado
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
marginTop: "4px",
|
||||
color: "#777777",
|
||||
fontSize: "13px",
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
Creá o ingresá a tu cuenta sin completar el formulario.
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRegisterMethod("manual")}
|
||||
style={{
|
||||
border:
|
||||
registerMethod === "manual"
|
||||
? "2px solid var(--wine-dark)"
|
||||
: "1px solid #e1e1e1",
|
||||
borderRadius: "14px",
|
||||
background:
|
||||
registerMethod === "manual"
|
||||
? "rgba(111, 16, 50, 0.08)"
|
||||
: "#ffffff",
|
||||
color:
|
||||
registerMethod === "manual"
|
||||
? "var(--wine-dark)"
|
||||
: "#3f3f3f",
|
||||
cursor: "pointer",
|
||||
fontWeight: registerMethod === "manual" ? 700 : 500,
|
||||
padding: "14px 16px",
|
||||
textAlign: "left",
|
||||
boxShadow:
|
||||
registerMethod === "manual"
|
||||
? "0 8px 18px rgba(111, 16, 50, 0.12)"
|
||||
: "none",
|
||||
}}
|
||||
>
|
||||
<span>Registrarme con email</span>
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
marginTop: "4px",
|
||||
color: "#777777",
|
||||
fontSize: "13px",
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
Completá tus datos y validá tu cuenta por correo.
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{registerMethod === "google" && (
|
||||
<Button
|
||||
name="btnSignupGoogle"
|
||||
text="Ir a continuar con Google"
|
||||
width="100%"
|
||||
onClick={() => goTo("/landing/login")}
|
||||
style={{ marginTop: "20px" }}
|
||||
type="button"
|
||||
/>
|
||||
)}
|
||||
|
||||
{registerMethod === "manual" && (
|
||||
<>
|
||||
<Textbox
|
||||
placeholder="Email"
|
||||
type="text"
|
||||
@@ -141,14 +276,10 @@ export default function RegisterPage() {
|
||||
width="100%"
|
||||
areaCode={areaCode}
|
||||
number={phone}
|
||||
onChangeArea={(
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
onChangeArea={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setAreaCode(e.target.value);
|
||||
}}
|
||||
onChangePhone={(
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
onChangePhone={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setPhone(e.target.value);
|
||||
}}
|
||||
/>
|
||||
@@ -162,19 +293,12 @@ export default function RegisterPage() {
|
||||
>
|
||||
<Checkbox
|
||||
checked={checkReadTerms}
|
||||
onClick={() =>
|
||||
setCheckReadTerms(!checkReadTerms)
|
||||
}
|
||||
onClick={() => setCheckReadTerms(!checkReadTerms)}
|
||||
/>
|
||||
<div style={{ marginTop: "8px" }}>
|
||||
Declaro que acepto los siguientes
|
||||
<Link href="/landing/terms">
|
||||
Términos y condiciones
|
||||
</Link>{" "}
|
||||
y{" "}
|
||||
<Link href="/landing/privacy">
|
||||
Políticas de privacidad
|
||||
</Link>
|
||||
<Link href="/landing/terms">Términos y condiciones</Link>{" "}
|
||||
y <Link href="/landing/privacy">Políticas de privacidad</Link>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
@@ -185,6 +309,8 @@ export default function RegisterPage() {
|
||||
style={{ marginTop: "20px" }}
|
||||
type="submit"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div style={{ marginTop: "10px" }}>
|
||||
Ya tengo una cuenta!
|
||||
<Link href="/landing/login">
|
||||
|
||||