feat: migrate to frontend v2, add task external links, auto-assign logged user, and preferences support

This commit is contained in:
Gabriel Menezes 2026-10-15 09:04:24 -04:00
parent 92aaeaf153
commit d1bfe4d1cb
166 changed files with 9202 additions and 19466 deletions

View file

@ -6,6 +6,7 @@ public class User {
private String email; private String email;
private String password; private String password;
private String photo; private String photo;
private String preferences;
public Long getId() { public Long getId() {
return id; return id;
@ -46,4 +47,12 @@ public class User {
public void setPhoto(String photo) { public void setPhoto(String photo) {
this.photo = photo; this.photo = photo;
} }
public String getPreferences() {
return preferences;
}
public void setPreferences(String preferences) {
this.preferences = preferences;
}
} }

View file

@ -19,6 +19,15 @@ public class Task {
private User user; private User user;
private LocalDateTime finishedAt; private LocalDateTime finishedAt;
private List<TaskHistoryItem> history = new ArrayList<>(); private List<TaskHistoryItem> history = new ArrayList<>();
private String externalUrl;
public String getExternalUrl() {
return externalUrl;
}
public void setExternalUrl(String externalUrl) {
this.externalUrl = externalUrl;
}
public Long getId() { public Long getId() {
return id; return id;

View file

@ -53,4 +53,14 @@ public class UserUseCaseConfig {
public UploadUserPhotoUseCase uploadUserPhotoUseCase(UserRepository userRepository, StorageRepository storageRepository) { public UploadUserPhotoUseCase uploadUserPhotoUseCase(UserRepository userRepository, StorageRepository storageRepository) {
return new UploadUserPhotoUseCase(userRepository, storageRepository); return new UploadUserPhotoUseCase(userRepository, storageRepository);
} }
@Produces
public net.beetechgroup.beetask.usecase.user.preferences.GetUserPreferencesUseCase getUserPreferencesUseCase(UserRepository userRepository) {
return new net.beetechgroup.beetask.usecase.user.preferences.GetUserPreferencesUseCase(userRepository);
}
@Produces
public net.beetechgroup.beetask.usecase.user.preferences.UpdateUserPreferencesUseCase updateUserPreferencesUseCase(UserRepository userRepository) {
return new net.beetechgroup.beetask.usecase.user.preferences.UpdateUserPreferencesUseCase(userRepository);
}
} }

View file

@ -28,6 +28,8 @@ public class TaskEntity {
private TaskStatus status; private TaskStatus status;
@Column(name = "finished_at") @Column(name = "finished_at")
private java.time.LocalDateTime finishedAt; private java.time.LocalDateTime finishedAt;
@Column(name = "external_url", length = 1000)
private String externalUrl;
@ManyToOne @ManyToOne
@JoinColumn(name = "project_id") @JoinColumn(name = "project_id")
@ -112,4 +114,12 @@ public class TaskEntity {
public void setUser(UserEntity user) { public void setUser(UserEntity user) {
this.user = user; this.user = user;
} }
public String getExternalUrl() {
return externalUrl;
}
public void setExternalUrl(String externalUrl) {
this.externalUrl = externalUrl;
}
} }

View file

@ -16,7 +16,10 @@ public class UserEntity {
private String email; private String email;
private String password; private String password;
private String photo; private String photo;
@jakarta.persistence.Column(columnDefinition = "jsonb")
@org.hibernate.annotations.JdbcTypeCode(org.hibernate.type.SqlTypes.JSON)
private String preferences;
public Long getId() { public Long getId() {
return id; return id;
} }
@ -56,4 +59,12 @@ public class UserEntity {
public void setPhoto(String photo) { public void setPhoto(String photo) {
this.photo = photo; this.photo = photo;
} }
public String getPreferences() {
return preferences;
}
public void setPreferences(String preferences) {
this.preferences = preferences;
}
} }

View file

@ -16,6 +16,7 @@ public class TaskEntityMapper {
entity.setDescription(task.getDescription()); entity.setDescription(task.getDescription());
entity.setStatus(task.getStatus()); entity.setStatus(task.getStatus());
entity.setFinishedAt(task.getFinishedAt()); entity.setFinishedAt(task.getFinishedAt());
entity.setExternalUrl(task.getExternalUrl());
entity.setProject(ProjectEntityMapper.toEntity(task.getProject())); entity.setProject(ProjectEntityMapper.toEntity(task.getProject()));
entity.setCategory(CategoryEntityMapper.toEntity(task.getCategory())); entity.setCategory(CategoryEntityMapper.toEntity(task.getCategory()));
entity.setUser(UserEntityMapper.toEntity(task.getUser())); entity.setUser(UserEntityMapper.toEntity(task.getUser()));
@ -36,6 +37,7 @@ public class TaskEntityMapper {
task.setDescription(entity.getDescription()); task.setDescription(entity.getDescription());
task.setStatus(entity.getStatus()); task.setStatus(entity.getStatus());
task.setFinishedAt(entity.getFinishedAt()); task.setFinishedAt(entity.getFinishedAt());
task.setExternalUrl(entity.getExternalUrl());
task.setProject(ProjectEntityMapper.toDomain(entity.getProject())); task.setProject(ProjectEntityMapper.toDomain(entity.getProject()));
task.setCategory(CategoryEntityMapper.toDomain(entity.getCategory())); task.setCategory(CategoryEntityMapper.toDomain(entity.getCategory()));
task.setUser(UserEntityMapper.toDomain(entity.getUser())); task.setUser(UserEntityMapper.toDomain(entity.getUser()));

View file

@ -14,6 +14,7 @@ public class UserEntityMapper {
entity.setEmail(user.getEmail()); entity.setEmail(user.getEmail());
entity.setPassword(user.getPassword()); entity.setPassword(user.getPassword());
entity.setPhoto(user.getPhoto()); entity.setPhoto(user.getPhoto());
entity.setPreferences(user.getPreferences());
return entity; return entity;
} }
@ -25,6 +26,7 @@ public class UserEntityMapper {
user.setEmail(entity.getEmail()); user.setEmail(entity.getEmail());
user.setPassword(entity.getPassword()); user.setPassword(entity.getPassword());
user.setPhoto(entity.getPhoto()); user.setPhoto(entity.getPhoto());
user.setPreferences(entity.getPreferences());
return user; return user;
} }
} }

View file

@ -30,6 +30,7 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
} else { } else {
entity = getEntityManager().merge(entity); entity = getEntityManager().merge(entity);
} }
getEntityManager().flush();
Task savedTask = TaskEntityMapper.toDomain(entity); Task savedTask = TaskEntityMapper.toDomain(entity);
LOGGER.infof("Persisted task %d with status %s", savedTask.getId(), savedTask.getStatus()); LOGGER.infof("Persisted task %d with status %s", savedTask.getId(), savedTask.getStatus());
return savedTask; return savedTask;

View file

@ -59,6 +59,12 @@ public class AuthController {
@Inject @Inject
UploadUserPhotoUseCase uploadUserPhotoUseCase; UploadUserPhotoUseCase uploadUserPhotoUseCase;
@Inject
net.beetechgroup.beetask.usecase.user.preferences.GetUserPreferencesUseCase getUserPreferencesUseCase;
@Inject
net.beetechgroup.beetask.usecase.user.preferences.UpdateUserPreferencesUseCase updateUserPreferencesUseCase;
@Inject @Inject
SecurityIdentity securityIdentity; SecurityIdentity securityIdentity;
@ -99,6 +105,28 @@ public class AuthController {
return AuthControllerMapper.toUpdateProfileResponse(output); return AuthControllerMapper.toUpdateProfileResponse(output);
} }
@Path("/preferences")
@GET
@Authenticated
@Operation(summary = "Get current user preferences", description = "Returns preferences JSON for authenticated user")
public PreferencesResponse getPreferences() {
String email = securityIdentity.getPrincipal().getName();
LOGGER.infof("Preferences requested for user %s", email);
String prefs = getUserPreferencesUseCase.execute(email);
return new PreferencesResponse(prefs);
}
@Path("/preferences")
@PUT
@Authenticated
@Operation(summary = "Update current user preferences", description = "Updates preferences JSON for authenticated user")
public PreferencesResponse updatePreferences(PreferencesRequest request) {
String email = securityIdentity.getPrincipal().getName();
LOGGER.infof("Preferences update requested for user %s", email);
String prefs = updateUserPreferencesUseCase.execute(email, request != null ? request.preferences() : null);
return new PreferencesResponse(prefs);
}
@Path("/register") @Path("/register")
@POST @POST
@Operation(summary = "Register", description = "Creates a new user account") @Operation(summary = "Register", description = "Creates a new user account")

View file

@ -0,0 +1,3 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.auth;
public record PreferencesRequest(String preferences) {}

View file

@ -0,0 +1,3 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.auth;
public record PreferencesResponse(String preferences) {}

View file

@ -12,5 +12,6 @@ public record CreateTaskRequest(
Long projectId, Long projectId,
Long categoryId, Long categoryId,
Long assigneeUserId, Long assigneeUserId,
List<TaskHistoryItemResponse> history) { List<TaskHistoryItemResponse> history,
String externalUrl) {
} }

View file

@ -14,16 +14,11 @@ public record CreateTaskResponse(
CategoryResponse category, CategoryResponse category,
UserResponse user, UserResponse user,
LocalDateTime finishedAt, LocalDateTime finishedAt,
List<TaskHistoryItemResponse> history) { List<TaskHistoryItemResponse> history,
String externalUrl) {
public record ProjectResponse(Long id, String name) {} public record ProjectResponse(Long id, String name) {}
public record CategoryResponse(Long id, String name, String color, String icon) {} public record CategoryResponse(Long id, String name, String color, String icon) {}
public record UserResponse(Long id, String name, String email, String photo) {} public record UserResponse(Long id, String name, String email, String photo) {}
} }
record TaskHistoryItemResponse(
Long id,
LocalDateTime startAt,
LocalDateTime endAt,
Long elapsedSeconds) {
}

View file

@ -40,7 +40,8 @@ public class TaskControllerMapper {
userEmail, userEmail,
Objects.nonNull(request.history()) ? request.history().stream() Objects.nonNull(request.history()) ? request.history().stream()
.map(h -> new TaskHistoryItemInput(h.id(), h.startAt(), h.endAt())) .map(h -> new TaskHistoryItemInput(h.id(), h.startAt(), h.endAt()))
.toList() : null .toList() : null,
request.externalUrl()
); );
} }
@ -69,7 +70,8 @@ public class TaskControllerMapper {
: null, : null,
output.finishedAt(), output.finishedAt(),
output.history().stream().map(taskHistoryItemOutput -> new TaskHistoryItemResponse( output.history().stream().map(taskHistoryItemOutput -> new TaskHistoryItemResponse(
taskHistoryItemOutput.id(), taskHistoryItemOutput.startAt(), taskHistoryItemOutput.endAt(), taskHistoryItemOutput.elapsedSeconds())).toList() taskHistoryItemOutput.id(), taskHistoryItemOutput.startAt(), taskHistoryItemOutput.endAt(), taskHistoryItemOutput.elapsedSeconds())).toList(),
output.externalUrl()
); );
} }
@ -94,7 +96,8 @@ public class TaskControllerMapper {
userEmail, userEmail,
Objects.nonNull(request.history()) ? request.history().stream() Objects.nonNull(request.history()) ? request.history().stream()
.map(h -> new TaskHistoryItemInput(h.id(), h.startAt(), h.endAt())) .map(h -> new TaskHistoryItemInput(h.id(), h.startAt(), h.endAt()))
.toList() : null .toList() : null,
request.externalUrl()
); );
} }
} }

View file

@ -0,0 +1,10 @@
package net.beetechgroup.beetask.interfaceadapters.controllers.task;
import java.time.LocalDateTime;
public record TaskHistoryItemResponse(
Long id,
LocalDateTime startAt,
LocalDateTime endAt,
Long elapsedSeconds) {
}

View file

@ -13,5 +13,6 @@ public record CreateTaskInput(
Long categoryId, Long categoryId,
Long assigneeUserId, Long assigneeUserId,
String userEmail, String userEmail,
List<TaskHistoryItemInput> history) { List<TaskHistoryItemInput> history,
String externalUrl) {
} }

View file

@ -29,14 +29,15 @@ public class CreateTaskMapper {
getPhotoUrl(task.getUser().getName(), task.getUser().getPhoto())) getPhotoUrl(task.getUser().getName(), task.getUser().getPhoto()))
: null, : null,
task.getFinishedAt(), task.getFinishedAt(),
task.getHistory().stream().map(CreateTaskMapper::toTaskHistoryItemOutput).toList()); task.getHistory().stream().map(CreateTaskMapper::toTaskHistoryItemOutput).toList(),
task.getExternalUrl());
} }
private static String getPhotoUrl(String name, String photo) { private static String getPhotoUrl(String name, String photo) {
return (Objects.nonNull(photo) && !photo.isBlank()) ? photo : null; return (Objects.nonNull(photo) && !photo.isBlank()) ? photo : null;
} }
private static TaskHistoryItemOutput toTaskHistoryItemOutput(TaskHistoryItem taskHistoryItem) { public static TaskHistoryItemOutput toTaskHistoryItemOutput(TaskHistoryItem taskHistoryItem) {
return new TaskHistoryItemOutput( return new TaskHistoryItemOutput(
taskHistoryItem.getId(), taskHistoryItem.getId(),
taskHistoryItem.getStartAt(), taskHistoryItem.getStartAt(),

View file

@ -13,7 +13,8 @@ public record CreateTaskOutput(
CategoryOutput category, CategoryOutput category,
UserOutput user, UserOutput user,
LocalDateTime finishedAt, LocalDateTime finishedAt,
List<TaskHistoryItemOutput> history) { List<TaskHistoryItemOutput> history,
String externalUrl) {
public record ProjectOutput(Long id, String name) {} public record ProjectOutput(Long id, String name) {}
public record CategoryOutput(Long id, String name, String color, String icon) {} public record CategoryOutput(Long id, String name, String color, String icon) {}

View file

@ -39,6 +39,7 @@ public class CreateTaskUseCase {
task.setTitle(input.title()); task.setTitle(input.title());
task.setDescription(input.description()); task.setDescription(input.description());
task.setStatus(input.status()); task.setStatus(input.status());
task.setExternalUrl(input.externalUrl());
if (Objects.nonNull(input.projectId())) { if (Objects.nonNull(input.projectId())) {
Project project = projectRepository.findProjectById(input.projectId()) Project project = projectRepository.findProjectById(input.projectId())

View file

@ -13,5 +13,6 @@ public record UpdateTaskInput(
Long categoryId, Long categoryId,
Long assigneeUserId, Long assigneeUserId,
String userEmail, String userEmail,
List<TaskHistoryItemInput> history) { List<TaskHistoryItemInput> history,
String externalUrl) {
} }

View file

@ -44,6 +44,7 @@ public class UpdateTaskUseCase {
task.setTitle(input.title()); task.setTitle(input.title());
task.setDescription(input.description()); task.setDescription(input.description());
task.setStatus(input.status()); task.setStatus(input.status());
task.setExternalUrl(input.externalUrl());
if (Objects.nonNull(input.projectId())) { if (Objects.nonNull(input.projectId())) {
Project project = projectRepository.findProjectById(input.projectId()) Project project = projectRepository.findProjectById(input.projectId())

View file

@ -0,0 +1,18 @@
package net.beetechgroup.beetask.usecase.user.preferences;
import net.beetechgroup.beetask.entities.User;
import net.beetechgroup.beetask.usecase.repository.UserRepository;
public class GetUserPreferencesUseCase {
private final UserRepository userRepository;
public GetUserPreferencesUseCase(UserRepository userRepository) {
this.userRepository = userRepository;
}
public String execute(String email) {
return userRepository.findByEmail(email)
.map(User::getPreferences)
.orElse(null);
}
}

View file

@ -0,0 +1,29 @@
package net.beetechgroup.beetask.usecase.user.preferences;
import net.beetechgroup.beetask.entities.User;
import net.beetechgroup.beetask.usecase.repository.UserRepository;
import org.jboss.logging.Logger;
public class UpdateUserPreferencesUseCase {
private static final Logger LOGGER = Logger.getLogger(UpdateUserPreferencesUseCase.class);
private final UserRepository userRepository;
public UpdateUserPreferencesUseCase(UserRepository userRepository) {
this.userRepository = userRepository;
}
public String execute(String email, String preferences) {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> {
LOGGER.warnf("Preferences update failed: user %s not found", email);
return new RuntimeException("User not found");
});
user.setPreferences(preferences);
User saved = userRepository.save(user);
LOGGER.infof("Preferences updated for user %s", email);
return saved.getPreferences();
}
}

View file

@ -48,7 +48,8 @@ public class GetUserProfileUseCase {
user.getName(), user.getName(),
user.getEmail(), user.getEmail(),
user.getPhoto(), user.getPhoto(),
orgs orgs,
user.getPreferences()
); );
} }
} }

View file

@ -6,7 +6,8 @@ public record UserProfileOutput(
String name, String name,
String email, String email,
String photo, String photo,
List<OrganizationProfileOutput> organizations List<OrganizationProfileOutput> organizations,
String preferences
) { ) {
public record OrganizationProfileOutput( public record OrganizationProfileOutput(
Long id, Long id,

View file

@ -0,0 +1 @@
ALTER TABLE users_entity ADD COLUMN preferences TEXT;

View file

@ -0,0 +1 @@
ALTER TABLE users_entity ALTER COLUMN preferences TYPE jsonb USING preferences::jsonb;

View file

@ -0,0 +1 @@
ALTER TABLE tasks ADD COLUMN external_url VARCHAR(1000);

View file

@ -37,11 +37,11 @@ services:
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.docker.network=proxy" - "traefik.docker.network=proxy"
- "traefik.http.routers.minio.rule=Host(`minio-task2.beetech.dev`)" - "traefik.http.routers.minio.rule=Host(`minio-task.beetech.dev`) || Host(`minio-task2.beetech.dev`)"
- "traefik.http.routers.minio.entrypoints=web" - "traefik.http.routers.minio.entrypoints=web"
- "traefik.http.routers.minio.service=minio" - "traefik.http.routers.minio.service=minio"
- "traefik.http.services.minio.loadbalancer.server.port=9000" - "traefik.http.services.minio.loadbalancer.server.port=9000"
- "traefik.http.routers.minio-console.rule=Host(`console-task2.beetech.dev`)" - "traefik.http.routers.minio-console.rule=Host(`console-task.beetech.dev`) || Host(`console-task2.beetech.dev`)"
- "traefik.http.routers.minio-console.entrypoints=web" - "traefik.http.routers.minio-console.entrypoints=web"
- "traefik.http.routers.minio-console.service=minio-console" - "traefik.http.routers.minio-console.service=minio-console"
- "traefik.http.services.minio-console.loadbalancer.server.port=9001" - "traefik.http.services.minio-console.loadbalancer.server.port=9001"
@ -76,7 +76,7 @@ services:
DB_USER: beetask DB_USER: beetask
DB_PASSWORD: beetask DB_PASSWORD: beetask
DB_URL: jdbc:postgresql://postgres:5432/beetask DB_URL: jdbc:postgresql://postgres:5432/beetask
QUARKUS_HTTP_CORS_ORIGINS: "https://task2.beetech.dev,https://task3.beetech.dev" QUARKUS_HTTP_CORS_ORIGINS: "https://task.beetech.dev,https://task2.beetech.dev,https://task3.beetech.dev"
MP_JWT_VERIFY_ISSUER: ${MP_JWT_VERIFY_ISSUER} MP_JWT_VERIFY_ISSUER: ${MP_JWT_VERIFY_ISSUER}
SMALLRYE_JWT_SIGN_KEY: ${SMALLRYE_JWT_SIGN_KEY} SMALLRYE_JWT_SIGN_KEY: ${SMALLRYE_JWT_SIGN_KEY}
MP_JWT_VERIFY_PUBLICKEY: ${MP_JWT_VERIFY_PUBLICKEY} MP_JWT_VERIFY_PUBLICKEY: ${MP_JWT_VERIFY_PUBLICKEY}
@ -94,7 +94,7 @@ services:
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.docker.network=proxy" - "traefik.docker.network=proxy"
- "traefik.http.routers.backend.rule=Host(`api-task2.beetech.dev`) || Host(`api-task3.beetech.dev`)" - "traefik.http.routers.backend.rule=Host(`api-task.beetech.dev`) || Host(`api-task2.beetech.dev`) || Host(`api-task3.beetech.dev`)"
- "traefik.http.routers.backend.entrypoints=web" - "traefik.http.routers.backend.entrypoints=web"
- "traefik.http.services.backend.loadbalancer.server.port=8081" - "traefik.http.services.backend.loadbalancer.server.port=8081"
@ -103,7 +103,7 @@ services:
context: ./frontend context: ./frontend
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
VITE_API_URL: ${VITE_API_URL:-https://api-task2.beetech.dev} VITE_API_URL: ${VITE_API_URL:-https://api-task.beetech.dev}
container_name: bee-task-frontend container_name: bee-task-frontend
restart: unless-stopped restart: unless-stopped
expose: expose:
@ -116,32 +116,10 @@ services:
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.docker.network=proxy" - "traefik.docker.network=proxy"
- "traefik.http.routers.frontend.rule=Host(`task2.beetech.dev`)" - "traefik.http.routers.frontend.rule=Host(`task.beetech.dev`) || Host(`task3.beetech.dev`) || Host(`task2.beetech.dev`)"
- "traefik.http.routers.frontend.entrypoints=web" - "traefik.http.routers.frontend.entrypoints=web"
- "traefik.http.services.frontend.loadbalancer.server.port=80" - "traefik.http.services.frontend.loadbalancer.server.port=80"
frontend2:
build:
context: ./new-frontend
dockerfile: Dockerfile
args:
VITE_API_URL: ${VITE_API_URL:-https://api-task2.beetech.dev}
container_name: bee-task-frontend2
restart: unless-stopped
expose:
- "80"
depends_on:
- backend
networks:
- proxy
- internal
labels:
- "traefik.enable=true"
- "traefik.docker.network=proxy"
- "traefik.http.routers.frontend2.rule=Host(`task3.beetech.dev`)"
- "traefik.http.routers.frontend2.entrypoints=web"
- "traefik.http.services.frontend2.loadbalancer.server.port=80"
volumes: volumes:
postgres_data: postgres_data:
minio_data: minio_data:

View file

@ -1 +0,0 @@
VITE_API_URL=https://api-task2.beetech.dev

View file

@ -3,7 +3,7 @@ FROM node:22-alpine AS build
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm install --legacy-peer-deps
COPY . . COPY . .

View file

@ -1,6 +1,6 @@
# React + TypeScript + Vite # React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available: Currently, two official plugins are available:
@ -11,63 +11,22 @@ Currently, two official plugins are available:
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration ## Expanding the Oxlint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```js ```json
export default defineConfig([ {
globalIgnores(['dist']), "$schema": "./node_modules/oxlint/configuration_schema.json",
{ "plugins": ["react", "typescript", "oxc"],
files: ['**/*.{ts,tsx}'], "options": {
extends: [ "typeAware": true
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
}, },
]) "rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
``` ```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View file

@ -1,22 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
},
},
])

View file

@ -6,11 +6,11 @@
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link <link
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@500;600&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Bee Task</title> <title>Bee Task | Gestão Ágil, Time Tracking & Produtividade para Equipes</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

File diff suppressed because it is too large Load diff

View file

@ -1,41 +1,36 @@
{ {
"name": "frontend", "name": "new-frontend",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "oxlint",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@hello-pangea/dnd": "^18.0.1", "@fontsource/inter": "^5.3.0",
"@tailwindcss/vite": "^4.2.4", "@heroui/react": "^3.2.6",
"clsx": "^2.1.1", "@heroui/system": "^2.4.28",
"date-fns": "^4.1.0", "@heroui/theme": "^2.4.26",
"framer-motion": "^12.38.0", "framer-motion": "^14.1.0",
"lucide-react": "^1.14.0", "lucide-react": "^1.55.0",
"react": "^19.2.5", "react": "^19.2.8",
"react-dom": "^19.2.5", "react-dom": "^19.2.8",
"react-router-dom": "^7.9.4", "react-router-dom": "^7.18.4"
"tailwind-merge": "^3.5.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@tailwindcss/vite": "^4.3.3",
"@types/node": "^24.12.2", "@types/node": "^24.13.3",
"@types/react": "^19.2.14", "@types/react": "^19.2.18",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.0.1", "@vitejs/plugin-react": "^6.1.1",
"autoprefixer": "^10.5.0", "autoprefixer": "^10.6.1",
"eslint": "^10.2.1", "oxlint": "^1.81.0",
"eslint-plugin-react-hooks": "^7.1.1", "postcss": "^8.5.29",
"eslint-plugin-react-refresh": "^0.5.2", "tailwindcss": "^4.3.3",
"globals": "^17.5.0",
"postcss": "^8.5.12",
"tailwindcss": "^4.2.4",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"typescript-eslint": "^8.58.2", "vite": "^8.3.0"
"vite": "^8.0.10"
} }
} }

File diff suppressed because it is too large Load diff

View file

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.9 KiB

View file

@ -1,15 +1,63 @@
import { RouterProvider } from "react-router-dom"; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { router } from "./router"; import { ThemeProvider } from './contexts/ThemeContext'
import { AuthProvider } from "./contexts/AuthContext"; import { AuthProvider } from './contexts/AuthContext'
import { SystemStatusBadge } from "./components/SystemStatusBadge"; import { TimerProvider } from './contexts/TimerContext'
import { LandingPage } from './pages/LandingPage'
import { AuthPage } from './pages/AuthPage'
import { ProtectedRoute } from './components/layout/ProtectedRoute'
import { AppShell } from './components/layout/AppShell'
import { DashboardPage } from './pages/DashboardPage'
import { DashboardV2Page } from './pages/DashboardV2Page'
import { OrganizationsPage } from './pages/OrganizationsPage'
import { TasksPage } from './pages/TasksPage'
import { ProjectsPage } from './pages/ProjectsPage'
import { CategoriesPage } from './pages/CategoriesPage'
import { MembersPage } from './pages/MembersPage'
import { OrganizationSettingsPage } from './pages/OrganizationSettingsPage'
import { AnalyticsPage } from './pages/AnalyticsPage'
import { ProfilePage } from './pages/ProfilePage'
function App() { export function App() {
return ( return (
<AuthProvider> <BrowserRouter>
<SystemStatusBadge /> <ThemeProvider>
<RouterProvider router={router} /> <AuthProvider>
</AuthProvider> <TimerProvider>
); <Routes>
{/* Rotas Públicas */}
<Route path="/" element={<LandingPage />} />
<Route path="/login" element={<AuthPage />} />
<Route path="/register" element={<AuthPage />} />
{/* Rotas Protegidas dentro do AppShell */}
<Route
element={
<ProtectedRoute>
<AppShell />
</ProtectedRoute>
}
>
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/dashboard-v2" element={<DashboardV2Page />} />
<Route path="/organizations" element={<OrganizationsPage />} />
<Route path="/tasks" element={<TasksPage />} />
<Route path="/kanban" element={<TasksPage />} />
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/categories" element={<CategoriesPage />} />
<Route path="/analytics" element={<AnalyticsPage />} />
<Route path="/members" element={<MembersPage />} />
<Route path="/organization/settings" element={<OrganizationSettingsPage />} />
<Route path="/profile" element={<ProfilePage />} />
</Route>
{/* Fallback */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</TimerProvider>
</AuthProvider>
</ThemeProvider>
</BrowserRouter>
)
} }
export default App; export default App

View file

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View file

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 4 KiB

View file

Before

Width:  |  Height:  |  Size: 8.5 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

View file

@ -1,641 +0,0 @@
import { useState } from "react";
import {
Layout,
ListTodo,
Plus,
Columns,
LogOut,
Settings,
Building2,
Clock,
ChevronDown,
FolderKanban,
Tag,
BarChart3,
PieChart,
Search,
User,
Users,
PlusCircle,
} from "lucide-react";
import { NavLink, Outlet, Navigate, Link, useLocation } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import { cn } from "../lib/utils";
import { motion } from "framer-motion";
const navLinkClassName = ({ isActive }: { isActive: boolean }) =>
cn(
"inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-semibold transition-all duration-200",
isActive
? "bg-brand text-white shadow-md shadow-brand/20"
: "text-text-muted hover:bg-surface-muted hover:text-text-main",
);
const dropdownItemClassName = ({ isActive }: { isActive: boolean }) =>
cn(
"flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-bold transition-all duration-200",
isActive
? "bg-brand/10 text-brand"
: "text-text-muted hover:bg-surface-muted hover:text-text-main",
);
export function AppShell() {
const { user, isAuthenticated, isLoading, logout, activeOrg, setActiveOrg } =
useAuth();
const location = useLocation();
const [showOrgSwitcher, setShowOrgSwitcher] = useState(false);
const [showTasksDropdown, setShowTasksDropdown] = useState(false);
const [showTeamDropdown, setShowTeamDropdown] = useState(false);
const [showAvatarDropdown, setShowAvatarDropdown] = useState(false);
const isAdmin =
activeOrg?.roles.includes("OWNER") || activeOrg?.roles.includes("ADMIN");
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-app-bg text-brand">
<div className="h-10 w-10 animate-spin rounded-full border-4 border-current border-t-transparent" />
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
if (
isAuthenticated &&
user &&
user.organizations.length === 0 &&
location.pathname !== "/organizations"
) {
return <Navigate to="/organizations" replace />;
}
return (
<div className="flex min-h-screen flex-col bg-app-bg font-sans text-text-main">
{/* Top Navigation */}
<header className="sticky top-0 z-50 border-b border-border-soft bg-surface/80 backdrop-blur-xl">
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4 lg:px-10">
<div className="flex items-center gap-8">
<NavLink to="/" className="flex items-center gap-3 group">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-brand text-white shadow-lg shadow-brand/20 transition-transform group-hover:scale-105">
<Layout size={20} />
</div>
<div>
<span className="block text-lg font-black tracking-tight text-text-main">
BeeTask
</span>
<span className="block text-[10px] font-bold uppercase tracking-widest text-text-muted">
Workspace
</span>
</div>
</NavLink>
<div className="h-8 w-px bg-border-soft hidden lg:block" />
{/* Org Switcher */}
<div className="relative">
<button
onClick={() => setShowOrgSwitcher(!showOrgSwitcher)}
className="flex w-48 items-center gap-2 rounded-xl border border-border-soft bg-surface-muted/50 px-4 py-2 text-sm font-bold transition-all hover:bg-surface-muted group"
>
<div className="flex h-6 w-6 items-center justify-center rounded-lg bg-brand/10 text-brand group-hover:bg-brand group-hover:text-white transition-all">
<Building2 size={14} />
</div>
<div className="text-left hidden sm:block flex-1 min-w-0">
<span className="block text-xs text-text-main leading-tight truncate">
{activeOrg?.name || "Selecionar Org"}
</span>
<span className="block text-[10px] text-text-muted font-black uppercase tracking-tighter leading-none truncate">
{activeOrg?.roles[0] || "Visitante"}
</span>
</div>
<ChevronDown
size={14}
className={cn(
"text-text-muted transition-transform",
showOrgSwitcher && "rotate-180",
)}
/>
</button>
{showOrgSwitcher && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setShowOrgSwitcher(false)}
/>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="absolute left-0 mt-2 z-50 w-48 rounded-2xl border border-border-soft bg-surface p-2 shadow-xl shadow-brand/10"
>
<div className="px-3 py-2 text-[10px] font-black uppercase tracking-widest text-text-muted">
Suas Organizações
</div>
<div className="grid gap-1 mt-1">
{user?.organizations.map((org) => (
<button
key={org.id}
onClick={() => {
setActiveOrg(org.id);
setShowOrgSwitcher(false);
}}
className={cn(
"flex w-full items-center justify-between rounded-xl px-3 py-2.5 transition-all text-left",
org.id === activeOrg?.id
? "bg-brand/10 text-brand"
: "hover:bg-surface-muted text-text-muted hover:text-text-main",
)}
>
<div className="flex items-center gap-3">
<div
className={cn(
"flex h-8 w-8 items-center justify-center rounded-lg font-bold text-xs shadow-sm",
org.id === activeOrg?.id
? "bg-brand text-white"
: "bg-white border border-border-soft text-text-muted",
)}
>
{org.name.substring(0, 2).toUpperCase()}
</div>
<div>
<span className="block text-sm font-bold leading-none">
{org.name}
</span>
<span className="text-[10px] font-medium opacity-70">
{org.roles.join(", ")}
</span>
</div>
</div>
{org.id === activeOrg?.id && (
<div className="h-1.5 w-1.5 rounded-full bg-brand" />
)}
</button>
))}
</div>
<div className="mt-2 border-t border-border-soft p-1">
<Link
to="/organizations"
onClick={() => setShowOrgSwitcher(false)}
className="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-xs font-bold text-brand hover:bg-brand/5 transition-all"
>
<PlusCircle size={14} />
Gerenciar Organizações
</Link>
</div>
</motion.div>
</>
)}
</div>
{/* Desktop Navigation */}
<nav className="hidden items-center gap-1 md:flex">
<NavLink to="/dashboard" className={navLinkClassName}>
<BarChart3 size={18} />
Meu Dashboard
</NavLink>
{/* Tarefas Dropdown */}
<div className="relative">
<NavLink
to="/"
className={({ isActive }) => {
const isBoardActive = window.location.pathname === "/board";
const active = isActive || isBoardActive;
return cn(
"inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-semibold transition-all duration-200",
active || showTasksDropdown
? "bg-brand text-white shadow-md shadow-brand/20"
: "text-text-muted hover:bg-surface-muted hover:text-text-main",
);
}}
onClick={(e) => {
e.preventDefault();
setShowTasksDropdown(!showTasksDropdown);
}}
>
<ListTodo size={18} />
Tarefas
<ChevronDown
size={14}
className={cn(
"transition-transform",
showTasksDropdown && "rotate-180",
)}
/>
</NavLink>
{showTasksDropdown && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setShowTasksDropdown(false)}
/>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="absolute left-0 mt-2 z-50 w-48 rounded-2xl border border-border-soft bg-surface p-2 shadow-xl shadow-brand/10"
>
<NavLink
to="/"
end
onClick={() => setShowTasksDropdown(false)}
className={dropdownItemClassName}
>
<ListTodo size={18} />
Minhas Tarefas
</NavLink>
<NavLink
to="/board"
onClick={() => setShowTasksDropdown(false)}
className={(props) =>
cn(dropdownItemClassName(props), "mt-1")
}
>
<Columns size={18} />
Quadro
</NavLink>
</motion.div>
</>
)}
</div>
{isAdmin && (
<NavLink to="/projects" className={navLinkClassName}>
<FolderKanban size={18} />
Projetos
</NavLink>
)}
{isAdmin && (
<NavLink to="/categories" className={navLinkClassName}>
<Tag size={18} />
Categorias
</NavLink>
)}
{/* Equipe Dropdown */}
<div className="relative">
<NavLink
to="/organizations"
className={({ isActive }) => {
const path = window.location.pathname;
const active =
isActive ||
path === "/requests" ||
path === "/admin" ||
path === "/org-dashboard" ||
path === "/organizations/join";
return cn(
"inline-flex items-center gap-2 rounded-xl px-4 py-2 text-sm font-semibold transition-all duration-200",
active || showTeamDropdown
? "bg-brand text-white shadow-md shadow-brand/20"
: "text-text-muted hover:bg-surface-muted hover:text-text-main",
);
}}
onClick={(e) => {
e.preventDefault();
setShowTeamDropdown(!showTeamDropdown);
}}
>
<Users size={18} />
Equipe
<ChevronDown
size={14}
className={cn(
"transition-transform",
showTeamDropdown && "rotate-180",
)}
/>
</NavLink>
{showTeamDropdown && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setShowTeamDropdown(false)}
/>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="absolute right-0 mt-2 z-50 w-60 rounded-2xl border border-border-soft bg-surface p-2 shadow-xl shadow-brand/10"
>
{isAdmin && (
<>
<NavLink
to="/org-dashboard"
onClick={() => setShowTeamDropdown(false)}
className={dropdownItemClassName}
>
<PieChart size={18} />
Dashboard da Org
</NavLink>
<NavLink
to="/admin"
onClick={() => setShowTeamDropdown(false)}
className={(props) =>
cn(dropdownItemClassName(props), "mt-1")
}
>
<Settings size={18} />
Solicitações Pendentes
</NavLink>
<div className="my-2 border-t border-border-soft" />
</>
)}
<NavLink
to="/organizations"
onClick={() => setShowTeamDropdown(false)}
className={dropdownItemClassName}
>
<Building2 size={18} />
Minhas Organizações
</NavLink>
<NavLink
to="/requests"
onClick={() => setShowTeamDropdown(false)}
className={(props) =>
cn(dropdownItemClassName(props), "mt-1")
}
>
<Clock size={18} />
Minhas Solicitações
</NavLink>
<NavLink
to="/organizations/join"
onClick={() => setShowTeamDropdown(false)}
className={(props) =>
cn(dropdownItemClassName(props), "mt-1")
}
>
<Search size={18} />
Buscar Organização
</NavLink>
</motion.div>
</>
)}
</div>
</nav>
</div>
{/* Right Actions */}
<div className="flex items-center gap-3">
<Link
to="/new"
className="hidden md:inline-flex items-center gap-2 rounded-xl bg-brand px-4 py-2 text-sm font-bold text-white shadow-md shadow-brand/20 transition-all hover:scale-[1.02] hover:bg-brand-dark active:scale-95"
>
<Plus size={16} />
Nova Tarefa
</Link>
<div className="hidden h-8 w-px bg-border-soft md:block" />
{/* Avatar Dropdown */}
<div className="relative">
<button
onClick={() => setShowAvatarDropdown(!showAvatarDropdown)}
className="flex h-9 w-9 items-center justify-center rounded-full bg-brand/10 text-brand transition-all overflow-hidden border border-brand/20 hover:border-brand"
title={user?.name}
>
{user?.photo ? (
<img src={user.photo} alt={user.name} className="h-full w-full object-cover" />
) : (
<User size={18} />
)}
</button>
{showAvatarDropdown && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setShowAvatarDropdown(false)}
/>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="absolute right-0 mt-2 z-50 w-56 rounded-2xl border border-border-soft bg-surface p-2 shadow-xl shadow-brand/10"
>
<div className="px-3 py-3 border-b border-border-soft mb-1">
<p className="text-sm font-bold text-text-main truncate">
{user?.name}
</p>
<p className="text-xs text-text-muted truncate mt-0.5">
{user?.email}
</p>
</div>
<Link
to="/profile"
onClick={() => setShowAvatarDropdown(false)}
className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-bold text-text-muted hover:bg-surface-muted hover:text-text-main transition-all"
>
<User size={16} />
Meu Perfil
</Link>
<button
onClick={() => {
setShowAvatarDropdown(false);
logout();
}}
className="flex w-full items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-bold text-text-muted hover:bg-danger/5 hover:text-danger transition-all"
>
<LogOut size={16} />
Sair
</button>
</motion.div>
</>
)}
</div>
</div>
</div>
</header>
{/* Main Content */}
<main className="flex-1 px-6 py-8 lg:px-10">
<div className="mx-auto max-w-7xl">
<Outlet />
</div>
</main>
{/* Footer / Mobile Nav */}
<footer className="border-t border-border-soft bg-surface py-6 md:hidden">
<nav className="flex justify-around px-4">
<NavLink
to="/dashboard"
className={({ isActive }) =>
cn(navLinkClassName({ isActive }), "flex-col gap-0.5 py-1")
}
>
<BarChart3 size={20} />
<span className="text-[10px]">Dashboard</span>
</NavLink>
{/* Mobile Tarefas Group */}
<div className="relative">
<button
onClick={() => setShowTasksDropdown(!showTasksDropdown)}
className={cn(
navLinkClassName({
isActive:
window.location.pathname === "/" ||
window.location.pathname === "/board",
}),
"flex-col gap-0.5 py-1",
)}
>
<ListTodo size={20} />
<span className="text-[10px]">Tarefas</span>
</button>
{showTasksDropdown && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setShowTasksDropdown(false)}
/>
<motion.div
initial={{ opacity: 0, y: -10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
className="absolute bottom-full left-1/2 -translate-x-1/2 mb-4 z-50 w-40 rounded-2xl border border-border-soft bg-surface p-1 shadow-2xl"
>
<NavLink
to="/"
end
onClick={() => setShowTasksDropdown(false)}
className={dropdownItemClassName}
>
<ListTodo size={18} />
Lista
</NavLink>
<NavLink
to="/board"
onClick={() => setShowTasksDropdown(false)}
className={(props) =>
cn(dropdownItemClassName(props), "mt-1")
}
>
<Columns size={18} />
Quadro
</NavLink>
</motion.div>
</>
)}
</div>
{/* Mobile + New Task */}
<Link
to="/new"
className={cn(
navLinkClassName({ isActive: window.location.pathname === "/new" }),
"flex-col gap-0.5 py-1",
)}
>
<Plus size={20} />
<span className="text-[10px]">Nova</span>
</Link>
{/* Mobile Equipe Group */}
<div className="relative">
<button
onClick={() => setShowTeamDropdown(!showTeamDropdown)}
className={cn(
navLinkClassName({
isActive: [
"/organizations",
"/requests",
"/admin",
"/org-dashboard",
"/organizations/join",
"/projects",
].includes(window.location.pathname),
}),
"flex-col gap-0.5 py-1",
)}
>
<Users size={20} />
<span className="text-[10px]">Equipe</span>
</button>
{showTeamDropdown && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setShowTeamDropdown(false)}
/>
<motion.div
initial={{ opacity: 0, y: -10, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
className="absolute bottom-full right-0 mb-4 z-50 w-52 rounded-2xl border border-border-soft bg-surface p-1 shadow-2xl"
>
{isAdmin && (
<>
<NavLink
to="/projects"
onClick={() => setShowTeamDropdown(false)}
className={dropdownItemClassName}
>
<FolderKanban size={18} />
Projetos
</NavLink>
<NavLink
to="/org-dashboard"
onClick={() => setShowTeamDropdown(false)}
className={(props) =>
cn(dropdownItemClassName(props), "mt-1")
}
>
<PieChart size={18} />
Dashboard da Org
</NavLink>
<NavLink
to="/admin"
onClick={() => setShowTeamDropdown(false)}
className={(props) =>
cn(dropdownItemClassName(props), "mt-1")
}
>
<Settings size={18} />
Gestão
</NavLink>
<div className="my-1 border-t border-border-soft" />
</>
)}
<NavLink
to="/organizations"
onClick={() => setShowTeamDropdown(false)}
className={dropdownItemClassName}
>
<Building2 size={18} />
Minhas Orgs
</NavLink>
<NavLink
to="/requests"
onClick={() => setShowTeamDropdown(false)}
className={(props) =>
cn(dropdownItemClassName(props), "mt-1")
}
>
<Clock size={18} />
Pedidos
</NavLink>
<NavLink
to="/organizations/join"
onClick={() => setShowTeamDropdown(false)}
className={(props) =>
cn(dropdownItemClassName(props), "mt-1")
}
>
<Search size={18} />
Buscar Org
</NavLink>
</motion.div>
</>
)}
</div>
</nav>
</footer>
</div>
);
}

View file

@ -1,22 +0,0 @@
import type { TaskCategory } from '../types/task'
import { CategoryIcon } from './CategoryIcon'
interface CategoryBadgeProps {
category: TaskCategory | null | undefined
size?: number
showLabel?: boolean
}
export function CategoryBadge({ category, size = 14, showLabel = false }: CategoryBadgeProps) {
if (!category) return null
return (
<span
title={category.name}
className="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-[10px] font-bold"
style={{ background: `${category.color}15`, color: category.color }}
>
<CategoryIcon iconName={category.icon} color={category.color} size={size} />
{showLabel && category.name}
</span>
)
}

View file

@ -1,16 +0,0 @@
import * as LucideIcons from 'lucide-react'
import { Tag } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
interface CategoryIconProps {
iconName?: string | null
color?: string | null
size?: number
className?: string
}
export function CategoryIcon({ iconName, color, size = 16, className }: CategoryIconProps) {
const icons = LucideIcons as unknown as Record<string, LucideIcon>
const IconComponent = (iconName && icons[iconName]) || Tag
return <IconComponent size={size} color={color ?? undefined} className={className} />
}

View file

@ -1,193 +0,0 @@
import React, { useRef, useState } from "react";
export const MONTH_NAMES = ["Jan", "Fev", "Mar", "Abr", "Mai", "Jun", "Jul", "Ago", "Set", "Out", "Nov", "Dez"];
export const CHART_COLORS = ["#7C3AED", "#06B6D4", "#10B981", "#F59E0B", "#EF4444", "#8B5CF6", "#3B82F6", "#EC4899"];
export interface BarDataPoint {
year: number;
month: number;
day: number | null;
finishedTasksCount: number;
totalMinutesWorked: number;
}
export interface ProjectSlice {
projectName: string;
totalMinutes: number;
}
export function chartFormatMinutes(minutes: number): string {
const h = Math.floor(minutes / 60);
const m = minutes % 60;
if (h === 0) return `${m}m`;
if (m === 0) return `${h}h`;
return `${h}h ${m}m`;
}
export function getPeriodLabel(s: BarDataPoint, groupedBy: "DAY" | "MONTH"): string {
if (groupedBy === "DAY") {
return `${String(s.day).padStart(2, "0")}/${String(s.month).padStart(2, "0")}`;
}
return `${MONTH_NAMES[s.month - 1]}/${String(s.year).slice(2)}`;
}
export interface TooltipState { x: number; y: number; content: string }
export function ChartTooltip({ tooltip }: { tooltip: TooltipState }) {
const above = tooltip.y > 40;
return (
<div
className="absolute pointer-events-none z-20 bg-surface border border-border-soft rounded-lg px-2 py-1 text-xs font-bold text-text-main shadow-lg whitespace-nowrap"
style={{
left: tooltip.x,
top: above ? tooltip.y - 34 : tooltip.y + 8,
transform: "translateX(-50%)",
}}
>
{tooltip.content}
</div>
);
}
export function BarChart({
data,
valueKey,
color,
groupedBy,
formatValue,
}: {
data: BarDataPoint[];
valueKey: "finishedTasksCount" | "totalMinutesWorked";
color: string;
groupedBy: "DAY" | "MONTH";
formatValue: (v: number) => string;
}) {
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const svgRef = useRef<SVGSVGElement>(null);
const handleMouseMove = (e: React.MouseEvent, content: string) => {
const rect = svgRef.current?.getBoundingClientRect();
if (!rect) return;
setTooltip({ x: e.clientX - rect.left, y: e.clientY - rect.top, content });
};
const values = data.map((d) => d[valueKey] as number);
const maxVal = Math.max(...values, 1);
const chartW = 220;
const barAreaH = 80;
const barCount = data.length;
const gap = barCount > 15 ? 2 : 4;
const barW = barCount > 0 ? Math.max(2, Math.floor((chartW - gap * (barCount - 1)) / barCount)) : 10;
const showLabel = (i: number) => {
if (groupedBy === "DAY") return barCount <= 10 || i % Math.ceil(barCount / 6) === 0;
return true;
};
return (
<div className="relative">
<svg
ref={svgRef}
viewBox={`0 0 ${chartW} ${barAreaH + 18}`}
className="w-full"
onMouseLeave={() => setTooltip(null)}
>
{data.map((d, i) => {
const val = d[valueKey] as number;
const barH = maxVal > 0 ? Math.max(val > 0 ? 2 : 0, Math.round((val / maxVal) * barAreaH)) : 0;
const x = i * (barW + gap);
const y = barAreaH - barH;
const label = showLabel(i) ? getPeriodLabel(d, groupedBy) : "";
return (
<g key={i}>
<rect
x={x} y={y} width={barW} height={barH} rx={2}
fill={color}
opacity={val === 0 ? 0.12 : 0.85}
className="cursor-pointer transition-opacity hover:opacity-100"
onMouseMove={(e) => handleMouseMove(e, `${getPeriodLabel(d, groupedBy)}: ${formatValue(val)}`)}
/>
{val === 0 && (
<rect
x={x} y={0} width={barW} height={barAreaH}
fill="transparent"
onMouseMove={(e) => handleMouseMove(e, `${getPeriodLabel(d, groupedBy)}: ${formatValue(val)}`)}
/>
)}
{label && (
<text x={x + barW / 2} y={barAreaH + 12} textAnchor="middle" fontSize="7" fontWeight="700" fill="currentColor" opacity="0.45">
{label}
</text>
)}
</g>
);
})}
</svg>
{tooltip && <ChartTooltip tooltip={tooltip} />}
</div>
);
}
export function ProjectMiniPieChart({ data }: { data: ProjectSlice[] }) {
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const svgRef = useRef<SVGSVGElement>(null);
const total = data.reduce((s, d) => s + d.totalMinutes, 0);
if (total === 0 || data.length === 0) {
return (
<div className="flex items-center justify-center h-full opacity-40 text-xs font-bold">
Sem dados
</div>
);
}
const cx = 50, cy = 50, r = 38, innerR = 22;
let startAngle = -Math.PI / 2;
const slices = data.map((d, i) => {
const fraction = d.totalMinutes / total;
const angle = fraction * 2 * Math.PI;
const endAngle = startAngle + angle;
const x1 = cx + r * Math.cos(startAngle), y1 = cy + r * Math.sin(startAngle);
const x2 = cx + r * Math.cos(endAngle), y2 = cy + r * Math.sin(endAngle);
const ix1 = cx + innerR * Math.cos(endAngle), iy1 = cy + innerR * Math.sin(endAngle);
const ix2 = cx + innerR * Math.cos(startAngle), iy2 = cy + innerR * Math.sin(startAngle);
const largeArc = angle > Math.PI ? 1 : 0;
const path = `M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} L ${ix1} ${iy1} A ${innerR} ${innerR} 0 ${largeArc} 0 ${ix2} ${iy2} Z`;
const pct = (fraction * 100).toFixed(0);
const result = { path, color: CHART_COLORS[i % CHART_COLORS.length], label: `${d.projectName}: ${chartFormatMinutes(d.totalMinutes)} (${pct}%)` };
startAngle = endAngle;
return result;
});
const handleMouseMove = (e: React.MouseEvent, content: string) => {
const rect = svgRef.current?.getBoundingClientRect();
if (!rect) return;
setTooltip({ x: e.clientX - rect.left, y: e.clientY - rect.top, content });
};
return (
<div className="relative w-full h-full">
<svg
ref={svgRef}
viewBox="0 0 100 100"
className="w-full h-full"
onMouseLeave={() => setTooltip(null)}
>
{slices.map((s, i) => (
<path
key={i}
d={s.path}
fill={s.color}
stroke="var(--color-surface,#fff)"
strokeWidth="1.5"
className="cursor-pointer transition-opacity hover:opacity-90"
onMouseMove={(e) => handleMouseMove(e, s.label)}
/>
))}
</svg>
{tooltip && <ChartTooltip tooltip={tooltip} />}
</div>
);
}

View file

@ -1,88 +0,0 @@
import { useMemo, useState } from 'react'
import * as LucideIcons from 'lucide-react'
import { Search } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { cn } from '../lib/utils'
interface IconPickerProps {
value: string
onChange: (iconName: string) => void
color?: string
}
const EXCLUDED = new Set(['createLucideIcon', 'icons', 'default', 'Icon', 'LucideIcon'])
function getAllIconNames(): string[] {
const icons = LucideIcons as unknown as Record<string, unknown>
return Object.keys(icons)
.filter((name) => /^[A-Z]/.test(name))
.filter((name) => !EXCLUDED.has(name))
.filter((name) => !name.endsWith('Icon'))
.sort()
}
const ALL_ICON_NAMES = getAllIconNames()
const MAX_VISIBLE = 144
export function IconPicker({ value, onChange, color }: IconPickerProps) {
const [search, setSearch] = useState('')
const icons = LucideIcons as unknown as Record<string, LucideIcon>
const filtered = useMemo(() => {
const q = search.trim().toLowerCase()
if (!q) return ALL_ICON_NAMES.slice(0, MAX_VISIBLE)
return ALL_ICON_NAMES.filter((n) => n.toLowerCase().includes(q)).slice(0, MAX_VISIBLE)
}, [search])
return (
<div className="space-y-3">
<div className="relative">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted"
size={16}
/>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Buscar ícone..."
className="h-10 w-full rounded-xl border border-border-soft bg-app-bg pl-10 pr-3 text-sm outline-none focus:border-brand focus:ring-2 focus:ring-brand/10"
/>
</div>
<div className="grid grid-cols-8 gap-1.5 max-h-60 overflow-y-auto rounded-xl border border-border-soft bg-app-bg p-2">
{filtered.map((name) => {
const Icon = icons[name]
if (!Icon) return null
const selected = value === name
return (
<button
key={name}
type="button"
onClick={() => onChange(name)}
title={name}
className={cn(
'flex items-center justify-center rounded-lg p-2 transition-all',
selected
? 'bg-brand text-white shadow-md shadow-brand/30'
: 'text-text-muted hover:bg-surface-muted hover:text-text-main',
)}
style={selected && color ? { background: color, color: '#fff' } : undefined}
>
<Icon size={18} />
</button>
)
})}
{filtered.length === 0 && (
<p className="col-span-8 py-6 text-center text-xs font-bold text-text-muted">
Nenhum ícone encontrado.
</p>
)}
</div>
{value && (
<p className="text-[10px] font-bold uppercase tracking-widest text-text-muted">
Selecionado: <span className="text-text-main">{value}</span>
</p>
)}
</div>
)
}

View file

@ -1,329 +0,0 @@
import React, { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { X, Mail, Clock, CheckCircle2, Loader2, AlertCircle, TrendingUp, Zap } from "lucide-react";
import { format, differenceInDays } from "date-fns";
import {
orgDashboardService,
type MemberDetailData,
type PeriodStats,
type MemberCategoryStats,
} from "../services/orgDashboardService";
import { CategoryIcon } from "./CategoryIcon";
import {
CHART_COLORS,
BarChart,
ProjectMiniPieChart,
ChartTooltip,
chartFormatMinutes,
getPeriodLabel,
} from "./DashboardCharts";
import type { TooltipState } from "./DashboardCharts";
function Avatar({ name, photo }: { name: string; photo?: string | null }) {
if (photo) return <img src={photo} alt={name} className="w-14 h-14 rounded-2xl object-cover shrink-0" />;
const initials = name.split(" ").slice(0, 2).map((n) => n[0]).join("").toUpperCase();
return (
<div className="w-14 h-14 rounded-2xl bg-brand/10 text-brand font-black text-xl flex items-center justify-center shrink-0">
{initials}
</div>
);
}
function CategoryMiniPieChart({ data }: { data: MemberCategoryStats[] }) {
const [tooltip, setTooltip] = useState<TooltipState | null>(null);
const svgRef = useRef<SVGSVGElement>(null);
const total = data.reduce((s, d) => s + d.totalMinutes, 0);
if (total === 0 || data.length === 0) {
return (
<div className="flex items-center justify-center h-full opacity-40 text-xs font-bold">
Sem dados
</div>
);
}
const cx = 50, cy = 50, r = 38, innerR = 22;
let startAngle = -Math.PI / 2;
const slices = data.map((d) => {
const fraction = d.totalMinutes / total;
const angle = fraction * 2 * Math.PI;
const endAngle = startAngle + angle;
const x1 = cx + r * Math.cos(startAngle), y1 = cy + r * Math.sin(startAngle);
const x2 = cx + r * Math.cos(endAngle), y2 = cy + r * Math.sin(endAngle);
const ix1 = cx + innerR * Math.cos(endAngle), iy1 = cy + innerR * Math.sin(endAngle);
const ix2 = cx + innerR * Math.cos(startAngle), iy2 = cy + innerR * Math.sin(startAngle);
const largeArc = angle > Math.PI ? 1 : 0;
const path = `M ${x1} ${y1} A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2} L ${ix1} ${iy1} A ${innerR} ${innerR} 0 ${largeArc} 0 ${ix2} ${iy2} Z`;
const pct = (fraction * 100).toFixed(0);
const result = { path, color: d.color, label: `${d.categoryName}: ${chartFormatMinutes(d.totalMinutes)} (${pct}%)` };
startAngle = endAngle;
return result;
});
const handleMouseMove = (e: React.MouseEvent, content: string) => {
const rect = svgRef.current?.getBoundingClientRect();
if (!rect) return;
setTooltip({ x: e.clientX - rect.left, y: e.clientY - rect.top, content });
};
return (
<div className="relative w-full h-full">
<svg
ref={svgRef}
viewBox="0 0 100 100"
className="w-full h-full"
onMouseLeave={() => setTooltip(null)}
>
{slices.map((s, i) => (
<path
key={i}
d={s.path}
fill={s.color}
stroke="var(--color-surface,#fff)"
strokeWidth="1.5"
className="cursor-pointer transition-opacity hover:opacity-90"
onMouseMove={(e) => handleMouseMove(e, s.label)}
/>
))}
</svg>
{tooltip && <ChartTooltip tooltip={tooltip} />}
</div>
);
}
interface MemberDetailModalProps {
memberId: number | null;
orgId: number;
startDate: Date;
endDate: Date;
onClose: () => void;
}
export const MemberDetailModal: React.FC<MemberDetailModalProps> = ({
memberId,
orgId,
startDate,
endDate,
onClose,
}) => {
const [data, setData] = useState<MemberDetailData | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (memberId === null) { setData(null); return; }
setIsLoading(true);
setError(null);
setData(null);
const start = format(startDate, "yyyy-MM-dd'T'HH:mm:ss");
const end = format(endDate, "yyyy-MM-dd'T'HH:mm:ss");
orgDashboardService
.getMemberStats(orgId, memberId, start, end)
.then(setData)
.catch(() => setError("Falha ao carregar os dados do membro."))
.finally(() => setIsLoading(false));
}, [memberId, orgId, startDate, endDate]);
const totalMinutes = data?.periodStats.reduce((s, p) => s + p.totalMinutesWorked, 0) ?? 0;
const totalFinished = data?.periodStats.reduce((s, p) => s + p.finishedTasksCount, 0) ?? 0;
const daySpan = Math.max(differenceInDays(endDate, startDate), 1);
const avgPerDay = totalMinutes / daySpan;
const bestPeriod = data?.periodStats.reduce(
(best, p) => (p.totalMinutesWorked > (best?.totalMinutesWorked ?? -1) ? p : best),
null as PeriodStats | null,
);
const bestLabel = bestPeriod && bestPeriod.totalMinutesWorked > 0
? getPeriodLabel(bestPeriod, data!.groupedBy)
: "—";
return (
<AnimatePresence>
{memberId !== null && (
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<motion.div
className="relative z-10 w-full max-w-3xl bg-surface border border-border-soft rounded-[2rem] shadow-2xl overflow-hidden"
initial={{ scale: 0.93, opacity: 0, y: 16 }}
animate={{ scale: 1, opacity: 1, y: 0 }}
exit={{ scale: 0.93, opacity: 0, y: 16 }}
transition={{ type: "spring", stiffness: 320, damping: 28 }}
>
<button
onClick={onClose}
className="absolute top-4 right-4 p-2 rounded-xl text-text-muted hover:text-text-main hover:bg-surface-muted transition-colors z-10"
>
<X size={18} />
</button>
<div className="p-6">
{isLoading ? (
<div className="flex flex-col items-center justify-center py-12">
<Loader2 className="animate-spin text-brand mb-3" size={32} />
<p className="text-text-muted font-bold text-sm">Carregando dados...</p>
</div>
) : error ? (
<div className="flex flex-col items-center justify-center py-10 text-center">
<AlertCircle size={32} className="text-rose-500 mb-3" />
<p className="text-text-muted font-bold text-sm">{error}</p>
</div>
) : data ? (
<div className="space-y-5">
{/* Header */}
<div className="flex items-center gap-4">
<Avatar name={data.userName} photo={data.userPhoto} />
<div>
<h2 className="text-xl font-black text-text-main">{data.userName}</h2>
<div className="flex items-center gap-1.5 mt-0.5 text-text-muted text-sm">
<Mail size={12} className="text-brand" />
{data.userEmail}
</div>
</div>
</div>
{/* 4 stat chips */}
<div className="grid grid-cols-4 gap-2">
<div className="bg-app-bg border border-border-soft rounded-xl p-3 flex flex-col items-center text-center">
<CheckCircle2 size={14} className="text-accent mb-1" />
<div className="text-lg font-black text-text-main leading-none">{totalFinished}</div>
<div className="text-[9px] font-black uppercase tracking-widest text-text-muted mt-1">Concluídas</div>
</div>
<div className="bg-app-bg border border-border-soft rounded-xl p-3 flex flex-col items-center text-center">
<Clock size={14} className="text-brand mb-1" />
<div className="text-lg font-black text-text-main leading-none">{chartFormatMinutes(totalMinutes)}</div>
<div className="text-[9px] font-black uppercase tracking-widest text-text-muted mt-1">Trabalhado</div>
</div>
<div className="bg-app-bg border border-border-soft rounded-xl p-3 flex flex-col items-center text-center">
<TrendingUp size={14} className="text-cyan-500 mb-1" />
<div className="text-lg font-black text-text-main leading-none">{chartFormatMinutes(Math.round(avgPerDay))}</div>
<div className="text-[9px] font-black uppercase tracking-widest text-text-muted mt-1">Média/dia</div>
</div>
<div className="bg-app-bg border border-border-soft rounded-xl p-3 flex flex-col items-center text-center">
<Zap size={14} className="text-amber-500 mb-1" />
<div className="text-lg font-black text-text-main leading-none">{bestLabel}</div>
<div className="text-[9px] font-black uppercase tracking-widest text-text-muted mt-1">Melhor período</div>
</div>
</div>
{/* Bar charts side by side */}
<div className="grid grid-cols-2 gap-3">
<div className="bg-app-bg border border-border-soft rounded-xl p-4">
<div className="flex items-center gap-1.5 mb-3">
<CheckCircle2 size={13} className="text-accent" />
<span className="text-xs font-black text-text-main">
Tarefas Concluídas {data.groupedBy === "DAY" ? "por Dia" : "por Mês"}
</span>
</div>
<BarChart
data={data.periodStats}
valueKey="finishedTasksCount"
color="#7C3AED"
groupedBy={data.groupedBy}
formatValue={(v) => `${v} tarefa${v !== 1 ? "s" : ""}`}
/>
</div>
<div className="bg-app-bg border border-border-soft rounded-xl p-4">
<div className="flex items-center gap-1.5 mb-3">
<Clock size={13} className="text-brand" />
<span className="text-xs font-black text-text-main">
Tempo Trabalhado {data.groupedBy === "DAY" ? "por Dia" : "por Mês"}
</span>
</div>
<BarChart
data={data.periodStats}
valueKey="totalMinutesWorked"
color="#06B6D4"
groupedBy={data.groupedBy}
formatValue={chartFormatMinutes}
/>
</div>
</div>
{/* Pie chart — hours by category */}
{data.categoryStats.length > 0 && (
<div className="bg-app-bg border border-border-soft rounded-xl p-4">
<div className="flex items-center gap-1.5 mb-3">
<span className="text-xs font-black text-text-main">Horas por Categoria</span>
</div>
<div className="flex items-center gap-6">
<div className="w-20 h-20 shrink-0">
<CategoryMiniPieChart data={data.categoryStats} />
</div>
<div className="flex-1 space-y-1.5">
{data.categoryStats
.slice()
.sort((a, b) => b.totalMinutes - a.totalMinutes)
.map((c) => {
const totalCat = data.categoryStats.reduce((s, x) => s + x.totalMinutes, 0);
const pct = totalCat > 0 ? ((c.totalMinutes / totalCat) * 100).toFixed(0) : "0";
return (
<div key={c.categoryId} className="flex items-center gap-2">
<div
className="flex h-5 w-5 items-center justify-center rounded-md shrink-0"
style={{ background: `${c.color}1a`, color: c.color }}
>
<CategoryIcon iconName={c.icon} color={c.color} size={12} />
</div>
<span className="text-xs font-bold text-text-main truncate flex-1">{c.categoryName}</span>
<span className="text-xs font-black text-text-muted shrink-0">
{chartFormatMinutes(c.totalMinutes)} ({pct}%)
</span>
</div>
);
})}
</div>
</div>
</div>
)}
{/* Pie chart — hours by project */}
{data.projectStats.length > 0 && (
<div className="bg-app-bg border border-border-soft rounded-xl p-4">
<div className="flex items-center gap-1.5 mb-3">
<span className="text-xs font-black text-text-main">Horas por Projeto</span>
</div>
<div className="flex items-center gap-6">
<div className="w-20 h-20 shrink-0">
<ProjectMiniPieChart data={data.projectStats} />
</div>
<div className="flex-1 space-y-1.5">
{data.projectStats
.slice()
.sort((a, b) => b.totalMinutes - a.totalMinutes)
.map((p, i) => {
const total = data.projectStats.reduce((s, x) => s + x.totalMinutes, 0);
const pct = total > 0 ? ((p.totalMinutes / total) * 100).toFixed(0) : "0";
return (
<div key={p.projectId ?? "geral"} className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full shrink-0" style={{ background: CHART_COLORS[i % CHART_COLORS.length] }} />
<span className="text-xs font-bold text-text-main truncate flex-1">{p.projectName}</span>
<span className="text-xs font-black text-text-muted shrink-0">
{chartFormatMinutes(p.totalMinutes)} ({pct}%)
</span>
</div>
);
})}
</div>
</div>
</div>
)}
</div>
) : null}
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
};

View file

@ -1,70 +0,0 @@
import type { ReactNode } from 'react'
import { cn } from '../lib/utils'
export interface MultiSelectOption<T extends string | number> {
value: T
label: string
icon?: ReactNode
color?: string
}
interface MultiSelectChipsProps<T extends string | number> {
options: MultiSelectOption<T>[]
selected: T[]
onChange: (selected: T[]) => void
emptyLabel?: string
}
export function MultiSelectChips<T extends string | number>({
options,
selected,
onChange,
emptyLabel = 'Todos',
}: MultiSelectChipsProps<T>) {
const toggle = (value: T) => {
if (selected.includes(value)) {
onChange(selected.filter((v) => v !== value))
} else {
onChange([...selected, value])
}
}
const allSelected = selected.length === 0
return (
<div className="flex items-center gap-2 overflow-x-auto pb-1">
<button
type="button"
onClick={() => onChange([])}
className={cn(
'whitespace-nowrap rounded-lg px-3 py-1.5 text-xs font-bold transition-all',
allSelected
? 'bg-brand text-white'
: 'bg-app-bg text-text-muted hover:bg-surface-muted hover:text-text-main',
)}
>
{emptyLabel}
</button>
{options.map((option) => {
const isSelected = selected.includes(option.value)
return (
<button
key={String(option.value)}
type="button"
onClick={() => toggle(option.value)}
className={cn(
'inline-flex items-center gap-1.5 whitespace-nowrap rounded-lg px-3 py-1.5 text-xs font-bold transition-all',
isSelected
? 'bg-brand text-white'
: 'bg-app-bg text-text-muted hover:bg-surface-muted hover:text-text-main',
)}
style={isSelected && option.color ? { background: option.color, color: '#fff' } : undefined}
>
{option.icon}
{option.label}
</button>
)
})}
</div>
)
}

View file

@ -1,132 +0,0 @@
import { Check, ChevronDown, X } from 'lucide-react'
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { cn } from '../lib/utils'
export interface MultiSelectDropdownOption<T extends string | number> {
value: T
label: string
icon?: ReactNode
}
interface MultiSelectDropdownProps<T extends string | number> {
label: string
allLabel: string
options: MultiSelectDropdownOption<T>[]
selected: T[]
onChange: (selected: T[]) => void
className?: string
}
export function MultiSelectDropdown<T extends string | number>({
label,
allLabel,
options,
selected,
onChange,
className,
}: MultiSelectDropdownProps<T>) {
const [isOpen, setIsOpen] = useState(false)
const containerRef = useRef<HTMLDivElement | null>(null)
useEffect(() => {
if (!isOpen) return
const handlePointerDown = (event: MouseEvent) => {
if (!containerRef.current?.contains(event.target as Node)) {
setIsOpen(false)
}
}
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [isOpen])
const toggleValue = (value: T) => {
if (selected.includes(value)) {
onChange(selected.filter((item) => item !== value))
return
}
onChange([...selected, value])
}
const getSummaryLabel = () => {
if (selected.length === 0) return allLabel
if (selected.length === 1) {
return options.find((option) => option.value === selected[0])?.label ?? allLabel
}
return `${selected.length} selecionados`
}
return (
<div ref={containerRef} className={cn('relative min-w-0', className)}>
<span className="mb-1.5 block text-[10px] font-black uppercase tracking-widest text-text-muted">
{label}
</span>
<button
type="button"
onClick={() => setIsOpen((current) => !current)}
className={cn(
'flex h-11 w-full items-center justify-between gap-3 rounded-xl border border-border-soft bg-app-bg px-3 text-left text-sm text-text-main outline-none transition-all',
'focus:border-brand focus:ring-2 focus:ring-brand/10',
isOpen && 'border-brand ring-2 ring-brand/10',
)}
>
<span className="truncate">{getSummaryLabel()}</span>
<ChevronDown
size={16}
className={cn('shrink-0 text-text-muted transition-transform', isOpen && 'rotate-180')}
/>
</button>
{isOpen && (
<div className="absolute z-20 mt-2 w-full rounded-2xl border border-border-soft bg-surface p-2 shadow-xl">
<button
type="button"
onClick={() => onChange([])}
className={cn(
'flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2 text-sm transition-colors',
selected.length === 0
? 'bg-brand/10 font-bold text-brand'
: 'text-text-muted hover:bg-app-bg hover:text-text-main',
)}
>
<span className="truncate">{allLabel}</span>
{selected.length > 0 && <X size={14} className="shrink-0" />}
</button>
<div className="mt-2 max-h-64 overflow-y-auto">
{options.map((option) => {
const isSelected = selected.includes(option.value)
return (
<button
key={String(option.value)}
type="button"
onClick={() => toggleValue(option.value)}
className={cn(
'flex w-full items-center justify-between gap-3 rounded-xl px-3 py-2 text-sm transition-colors',
isSelected
? 'bg-brand text-white'
: 'text-text-main hover:bg-app-bg',
)}
>
<span className="flex min-w-0 items-center gap-2">
{option.icon}
<span className="truncate">{option.label}</span>
</span>
<Check
size={14}
className={cn('shrink-0', isSelected ? 'opacity-100' : 'opacity-0')}
/>
</button>
)
})}
</div>
</div>
)}
</div>
)
}

View file

@ -1,89 +0,0 @@
import React from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { PlusCircle, Search, Layout, X } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
interface OnboardingModalProps {
isOpen: boolean
onClose?: () => void
}
export const OnboardingModal: React.FC<OnboardingModalProps> = ({ isOpen, onClose }) => {
const navigate = useNavigate()
const handleClose = () => {
if (onClose) {
onClose()
} else {
navigate('/')
}
}
if (!isOpen) return null
return (
<AnimatePresence>
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-app-bg/80 backdrop-blur-sm"
onClick={handleClose}
>
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
onClick={(e) => e.stopPropagation()}
className="w-full max-w-lg bg-surface border border-border-soft rounded-[2.5rem] shadow-panel p-10 overflow-hidden relative"
>
<button
onClick={handleClose}
className="absolute top-8 right-8 text-text-muted hover:text-brand transition-colors z-20"
>
<X size={24} />
</button>
{/* Decorations */}
<div className="absolute -top-20 -right-20 w-40 h-40 bg-brand/10 blur-[80px] rounded-full" />
<div className="absolute -bottom-20 -left-20 w-40 h-40 bg-accent/10 blur-[80px] rounded-full" />
<div className="relative z-10 text-center mb-10">
<div className="w-16 h-16 bg-brand text-white rounded-2xl flex items-center justify-center mb-6 shadow-lg shadow-brand/20 mx-auto">
<Layout size={32} />
</div>
<h2 className="text-3xl font-black text-text-main tracking-tight mb-3">Quase lá!</h2>
<p className="text-text-muted font-medium">Você ainda não faz parte de nenhuma organização. Como deseja começar?</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 relative z-10">
<button
onClick={() => {
navigate('/organizations');
onClose?.();
}}
className="group p-6 bg-surface-muted/30 border border-border-soft rounded-[2rem] text-left hover:border-brand hover:bg-brand/5 transition-all active:scale-[0.98]"
>
<div className="w-12 h-12 bg-brand/10 text-brand rounded-xl flex items-center justify-center mb-4 group-hover:bg-brand group-hover:text-white transition-colors">
<PlusCircle size={24} />
</div>
<h3 className="font-bold text-text-main mb-1">Criar uma</h3>
<p className="text-sm text-text-muted">Crie seu próprio workspace e convide sua equipe.</p>
</button>
<button
onClick={() => {
navigate('/organizations');
onClose?.();
}}
className="group p-6 bg-surface-muted/30 border border-border-soft rounded-[2rem] text-left hover:border-accent hover:bg-accent/5 transition-all active:scale-[0.98]"
>
<div className="w-12 h-12 bg-accent/10 text-accent rounded-xl flex items-center justify-center mb-4 group-hover:bg-accent group-hover:text-white transition-colors">
<Search size={24} />
</div>
<h3 className="font-bold text-text-main mb-1">Participar</h3>
<p className="text-sm text-text-muted">Busque por uma organização existente e peça para entrar.</p>
</button>
</div>
</motion.div>
</div>
</AnimatePresence>
)
}

View file

@ -1,121 +0,0 @@
import { useRef, useState } from 'react'
import type { ReactNode } from 'react'
export interface PieSlice {
key: string | number
label: string
value: number
color: string
legendIcon?: ReactNode
}
interface PieChartProps {
slices: PieSlice[]
centerLabel?: string
centerValue?: string | number
size?: number
formatTooltip?: (slice: PieSlice, percent: number) => string
}
interface TooltipState {
x: number
y: number
content: string
}
export function PieChart({ slices, centerLabel, centerValue, size = 200, formatTooltip }: PieChartProps) {
const [tooltip, setTooltip] = useState<TooltipState | null>(null)
const svgRef = useRef<SVGSVGElement>(null)
const total = slices.reduce((sum, s) => sum + s.value, 0)
if (total === 0 || slices.length === 0) {
return (
<div className="flex items-center justify-center w-full h-full opacity-40 font-bold text-sm">
Sem dados no período
</div>
)
}
const cx = 100
const cy = 100
const r = 70
const innerR = 42
let startAngle = -Math.PI / 2
const paths = slices.map((slice) => {
const fraction = slice.value / total
const angle = fraction * 2 * Math.PI
const endAngle = startAngle + angle
const x1 = cx + r * Math.cos(startAngle)
const y1 = cy + r * Math.sin(startAngle)
const x2 = cx + r * Math.cos(endAngle)
const y2 = cy + r * Math.sin(endAngle)
const ix1 = cx + innerR * Math.cos(endAngle)
const iy1 = cy + innerR * Math.sin(endAngle)
const ix2 = cx + innerR * Math.cos(startAngle)
const iy2 = cy + innerR * Math.sin(startAngle)
const largeArc = angle > Math.PI ? 1 : 0
const path = [
`M ${x1} ${y1}`,
`A ${r} ${r} 0 ${largeArc} 1 ${x2} ${y2}`,
`L ${ix1} ${iy1}`,
`A ${innerR} ${innerR} 0 ${largeArc} 0 ${ix2} ${iy2}`,
'Z',
].join(' ')
const pct = fraction * 100
const tip = formatTooltip
? formatTooltip(slice, pct)
: `${slice.label}: ${slice.value} (${pct.toFixed(1)}%)`
startAngle = endAngle
return { path, color: slice.color, tip }
})
const handleMouseMove = (e: React.MouseEvent, content: string) => {
const rect = svgRef.current?.getBoundingClientRect()
if (!rect) return
setTooltip({ x: e.clientX - rect.left, y: e.clientY - rect.top, content })
}
return (
<div className="relative" style={{ width: size, height: size }}>
<svg
ref={svgRef}
viewBox="0 0 200 200"
className="w-full h-full"
onMouseLeave={() => setTooltip(null)}
>
{paths.map((slice, i) => (
<path
key={i}
d={slice.path}
fill={slice.color}
stroke="var(--color-surface, #fff)"
strokeWidth="2"
className="cursor-pointer transition-opacity hover:opacity-80"
onMouseMove={(e) => handleMouseMove(e, slice.tip)}
/>
))}
{centerLabel && (
<text x={cx} y={cy - 8} textAnchor="middle" fontSize="11" fill="currentColor" opacity="0.5">
{centerLabel}
</text>
)}
{centerValue !== undefined && (
<text x={cx} y={cy + 10} textAnchor="middle" fontSize="20" fontWeight="900" fill="currentColor">
{centerValue}
</text>
)}
</svg>
{tooltip && (
<div
className="absolute pointer-events-none z-20 bg-surface border border-border-soft rounded-lg px-2 py-1 text-xs font-bold text-text-main shadow-lg whitespace-nowrap"
style={{ left: tooltip.x, top: tooltip.y - 36, transform: 'translateX(-50%)' }}
>
{tooltip.content}
</div>
)}
</div>
)
}

View file

@ -1,60 +0,0 @@
import React from "react";
import { ShieldCheck, Building2 } from "lucide-react";
import { useAuth } from "../contexts/AuthContext";
interface RoleGateProps {
children: React.ReactNode;
allowedRoles: string[];
message?: string;
}
export function RoleGate({ children, allowedRoles, message }: RoleGateProps) {
const { activeOrg, isLoading } = useAuth();
if (isLoading) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<div className="h-10 w-10 animate-spin rounded-full border-4 border-brand border-t-transparent" />
</div>
);
}
if (!activeOrg) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center p-6">
<div className="w-20 h-20 bg-surface-muted rounded-[2rem] flex items-center justify-center mb-6 text-text-muted">
<Building2 size={40} />
</div>
<h2 className="text-2xl font-black text-text-main mb-2">
Nenhuma Organização Ativa
</h2>
<p className="text-text-muted max-w-sm">
Selecione uma organização no menu superior para gerenciar seus
recursos.
</p>
</div>
);
}
const isAuthorized = activeOrg.roles.some((role) =>
allowedRoles.includes(role),
);
if (!isAuthorized) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center p-6">
<div className="w-20 h-20 bg-danger/10 text-danger rounded-[2rem] flex items-center justify-center mb-6">
<ShieldCheck size={40} />
</div>
<h2 className="text-2xl font-black text-text-main mb-2">
Acesso Restrito
</h2>
<p className="text-text-muted max-w-sm">
{message || "Você não tem permissão para acessar esta área nesta organização."}
</p>
</div>
);
}
return <>{children}</>;
}

View file

@ -1,125 +0,0 @@
import { Activity, WifiOff } from "lucide-react";
import { useEffect, useState } from "react";
import { cn } from "../lib/utils";
import {
getBackendStatus,
type BackendHealthResponse,
} from "../services/statusService";
type LoadState = "loading" | "online" | "offline";
function extractBackendVersion(payload: BackendHealthResponse | null) {
const versionCheck = payload?.checks?.find(
(check) => check.name === "backend-version",
);
const version = versionCheck?.data?.version;
return typeof version === "string" ? version : "unknown";
}
export function SystemStatusBadge() {
const [backendStatus, setBackendStatus] = useState<LoadState>("loading");
const [backendVersion, setBackendVersion] = useState("...");
const [isPinnedOpen, setIsPinnedOpen] = useState(false);
const [isHovered, setIsHovered] = useState(false);
useEffect(() => {
let active = true;
async function loadStatus() {
try {
const response = await getBackendStatus();
if (!active) return;
setBackendStatus(response.status === "UP" ? "online" : "offline");
setBackendVersion(extractBackendVersion(response));
} catch {
if (!active) return;
setBackendStatus("offline");
setBackendVersion("indisponivel");
}
}
void loadStatus();
const intervalId = window.setInterval(() => {
void loadStatus();
}, 60000);
return () => {
active = false;
window.clearInterval(intervalId);
};
}, []);
const frontendVersion = import.meta.env.VITE_APP_VERSION;
const isOnline = backendStatus === "online";
const isExpanded = isHovered || isPinnedOpen;
return (
<div
className="fixed bottom-4 right-4 z-[70]"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<div
className={cn(
"flex items-center overflow-hidden rounded-2xl border border-border-soft bg-surface/95 shadow-[0_12px_30px_rgba(79,62,35,0.16)] backdrop-blur-xl transition-all duration-300",
isExpanded ? "max-w-xs pl-2 pr-3 py-2" : "w-12 h-12 p-2",
)}
>
<button
type="button"
aria-label="Mostrar status do sistema"
onClick={() => setIsPinnedOpen((current) => !current)}
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-xl transition-transform duration-200 hover:scale-105",
isOnline
? "bg-success-soft text-success"
: "bg-danger-soft text-danger",
)}
>
{isOnline ? <Activity size={16} /> : <WifiOff size={16} />}
</button>
<div
className={cn(
"grid transition-all duration-300",
isExpanded
? "ml-3 grid-cols-[1fr] opacity-100"
: "ml-0 grid-cols-[0fr] opacity-0",
)}
>
<div className="min-w-0 overflow-hidden">
<div className="flex items-center gap-2">
<span className="text-[11px] font-black uppercase tracking-[0.22em] text-text-muted">
Sistema
</span>
<span
className={cn(
"rounded-full px-2 py-0.5 text-[10px] font-black uppercase tracking-wide",
backendStatus === "loading" && "bg-warning-soft text-warning",
backendStatus === "online" && "bg-success-soft text-success",
backendStatus === "offline" && "bg-danger-soft text-danger",
)}
>
{backendStatus === "loading"
? "Verificando"
: isOnline
? "Online"
: "Offline"}
</span>
</div>
<div className="mt-1 flex items-center gap-2 text-[11px] font-medium text-text-muted">
<span className="whitespace-nowrap">API {backendVersion}</span>
<span className="text-border-strong">•</span>
<span className="whitespace-nowrap">WEB {frontendVersion}</span>
</div>
</div>
</div>
</div>
</div>
);
}

View file

@ -1,180 +0,0 @@
import { useState } from 'react'
import { RefreshCw, Search, Filter, ChevronDown, ChevronUp } from 'lucide-react'
import { TASK_STATUS_LABELS, TASK_STATUS_OPTIONS, type TaskStatus } from '../types/task'
import type { Project } from '../services/projectService'
import type { Category } from '../types/category'
import { CategoryIcon } from './CategoryIcon'
import { MultiSelectDropdown } from './MultiSelectDropdown'
import type { TaskFilters } from './taskFilters'
import type { TaskAssignee } from '../types/task'
import { UserAvatar } from './UserAvatar'
const STATUS_CHIP_COLORS: Record<TaskStatus, string> = {
NOT_STARTED: '#94a3b8',
IN_PROGRESS: '#f59e0b',
COMPLETED: '#10b981',
CANCELED: '#ef4444',
}
interface TaskFilterBarProps {
filters: TaskFilters
onFiltersChange: (filters: TaskFilters) => void
projects: Project[]
categories: Category[]
assignees: TaskAssignee[]
currentUserId?: number
isLoading?: boolean
onRefresh?: () => void
}
export function TaskFilterBar({
filters,
onFiltersChange,
projects,
categories,
assignees,
currentUserId,
isLoading,
onRefresh,
}: TaskFilterBarProps) {
const [isExpanded, setIsExpanded] = useState(false)
const update = (partial: Partial<TaskFilters>) =>
onFiltersChange({ ...filters, ...partial })
const activeFiltersCount =
(filters.projectIds?.length || 0) +
(filters.statuses?.length || 0) +
(filters.categoryIds?.length || 0) +
(filters.userIds?.length || 0)
return (
<div className="flex flex-col gap-4 rounded-2xl border border-border-soft bg-surface p-4 shadow-sm">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="relative flex-1">
<Search
className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted"
size={18}
/>
<input
type="text"
placeholder="Buscar por título ou descrição..."
value={filters.searchQuery}
onChange={(e) => update({ searchQuery: e.target.value })}
className="h-11 w-full rounded-xl border border-border-soft bg-app-bg pl-10 pr-4 text-sm outline-none transition-all focus:border-brand focus:ring-2 focus:ring-brand/10"
/>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setIsExpanded(!isExpanded)}
className={`flex h-11 items-center gap-2 rounded-xl px-4 text-sm font-medium transition-all ${
isExpanded || activeFiltersCount > 0
? 'bg-brand/10 text-brand'
: 'bg-app-bg text-text-muted hover:bg-surface-muted hover:text-brand'
}`}
>
<Filter size={18} />
<span className="hidden sm:inline">Filtros</span>
{activeFiltersCount > 0 && (
<span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-brand text-xs text-white">
{activeFiltersCount}
</span>
)}
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</button>
{onRefresh && (
<button
onClick={onRefresh}
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl bg-app-bg text-text-muted transition-all hover:bg-surface-muted hover:text-brand"
title="Recarregar"
>
<RefreshCw size={18} className={isLoading ? 'animate-spin' : ''} />
</button>
)}
</div>
</div>
{isExpanded && (
<div className="grid grid-cols-1 gap-4 border-t border-border-soft pt-4 sm:grid-cols-2 lg:grid-cols-4">
{projects.length > 0 && (
<MultiSelectDropdown
label="Projetos"
allLabel="Todos os projetos"
options={projects.map((p) => ({
value: p.id,
label: p.name,
icon: p.icon ? <CategoryIcon iconName={p.icon} size={14} /> : undefined,
}))}
selected={filters.projectIds}
onChange={(projectIds) => update({ projectIds })}
/>
)}
<MultiSelectDropdown
label="Status"
allLabel="Todos os status"
options={TASK_STATUS_OPTIONS.map((s) => ({
value: s,
label: TASK_STATUS_LABELS[s],
icon: (
<div
className="h-3 w-3 shrink-0 rounded-full"
style={{ backgroundColor: STATUS_CHIP_COLORS[s] }}
/>
),
}))}
selected={filters.statuses}
onChange={(statuses) => update({ statuses })}
/>
{categories.length > 0 && (
<MultiSelectDropdown
label="Categorias"
allLabel="Todas as categorias"
options={categories.map((c) => ({
value: c.id,
label: c.name,
icon: <CategoryIcon iconName={c.icon} size={14} />,
}))}
selected={filters.categoryIds}
onChange={(categoryIds) => update({ categoryIds })}
/>
)}
{assignees.length > 0 && (
<div className="flex flex-col gap-2">
<MultiSelectDropdown
label="Funcionário"
allLabel="Todos os funcionários"
options={assignees.map((assignee) => ({
value: assignee.id,
label: assignee.name,
icon: <UserAvatar name={assignee.name} photo={assignee.photo} size="sm" />,
}))}
selected={filters.userIds}
onChange={(userIds) => update({ userIds })}
/>
{currentUserId !== undefined && (
<label className="flex cursor-pointer items-center gap-2 self-start">
<input
type="checkbox"
checked={filters.userIds.length === 1 && filters.userIds[0] === currentUserId}
onChange={(e) =>
update({ userIds: e.target.checked ? [currentUserId] : [] })
}
className="h-4 w-4 accent-brand"
/>
<span className="text-xs font-semibold text-text-muted">
Mostrar apenas as minhas
</span>
</label>
)}
</div>
)}
</div>
)}
</div>
)
}

View file

@ -1,163 +0,0 @@
import { differenceInSeconds } from 'date-fns'
import { Clock, Play, Square } from 'lucide-react'
import { useEffect, useMemo, useState } from 'react'
import { taskService } from '../services/taskService'
import { cn } from '../lib/utils'
import type { TaskResponse } from '../types/task'
interface TaskTimerProps {
task: TaskResponse
onUpdate: () => void
canControl?: boolean
}
function formatDuration(seconds: number) {
const hrs = Math.floor(seconds / 3600)
const mins = Math.floor((seconds % 3600) / 60)
const secs = seconds % 60
return [
hrs > 0 ? String(hrs).padStart(2, '0') : '00',
String(mins).padStart(2, '0'),
String(secs).padStart(2, '0'),
].join(':')
}
export function TaskTimer({ task, onUpdate, canControl = true }: TaskTimerProps) {
const [isActionLoading, setIsActionLoading] = useState(false)
const [responseTimestamp, setResponseTimestamp] = useState<Date | null>(null)
const history = task.history ?? []
const runningItem = useMemo(
() => history.find((item) => !item.endAt),
[history],
)
const accumulatedSeconds = useMemo(() => {
return history.reduce((acc, item) => {
if (item.endAt) {
return acc + (item.elapsedSeconds ?? 0)
}
return acc
}, 0)
}, [history])
const [displaySeconds, setDisplaySeconds] = useState(0)
useEffect(() => {
setResponseTimestamp(new Date())
}, [runningItem])
useEffect(() => {
let interval: ReturnType<typeof setInterval> | null = null
if (runningItem && responseTimestamp) {
interval = setInterval(() => {
const elapsedSinceResponse = Math.max(0, differenceInSeconds(new Date(), responseTimestamp))
const runningSeconds = (runningItem.elapsedSeconds ?? 0) + elapsedSinceResponse
setDisplaySeconds(accumulatedSeconds + runningSeconds)
}, 1000)
} else {
setDisplaySeconds(accumulatedSeconds)
}
return () => {
if (interval) clearInterval(interval)
}
}, [runningItem, accumulatedSeconds, responseTimestamp])
// Initial calculation
useEffect(() => {
if (runningItem && responseTimestamp) {
const elapsedSinceResponse = Math.max(0, differenceInSeconds(new Date(), responseTimestamp))
const runningSeconds = (runningItem.elapsedSeconds ?? 0) + elapsedSinceResponse
setDisplaySeconds(accumulatedSeconds + runningSeconds)
} else {
setDisplaySeconds(accumulatedSeconds)
}
}, [runningItem, accumulatedSeconds, responseTimestamp])
async function handleStart(e: React.MouseEvent) {
e.stopPropagation()
setIsActionLoading(true)
try {
await taskService.startTask(task.id)
onUpdate()
} catch (error) {
console.error('Failed to start task', error)
} finally {
setIsActionLoading(false)
}
}
async function handleStop(e: React.MouseEvent) {
e.stopPropagation()
setIsActionLoading(true)
try {
await taskService.stopTask(task.id)
onUpdate()
} catch (error) {
console.error('Failed to stop task', error)
} finally {
setIsActionLoading(false)
}
}
return (
<div className={cn(
"group relative flex items-center gap-3 overflow-hidden rounded-2xl border p-2 transition-all duration-500",
runningItem
? "border-brand/30 bg-brand/5 shadow-xl shadow-brand/10"
: "border-border-soft bg-surface shadow-sm hover:border-brand/20 hover:bg-app-bg/30"
)}>
{/* Active Indicator Pulse */}
{runningItem && (
<div className="absolute left-0 top-0 h-full w-1 bg-brand animate-pulse" />
)}
<div className={cn(
"flex h-10 w-10 shrink-0 items-center justify-center rounded-xl transition-all duration-500",
runningItem ? "bg-brand text-white scale-110 shadow-lg shadow-brand/30" : "bg-app-bg text-text-muted group-hover:bg-surface"
)}>
<Clock size={18} className={runningItem ? "animate-spin [animation-duration:3s]" : ""} />
</div>
<div className="flex flex-col">
<span className="text-[10px] font-black uppercase tracking-widest text-text-muted/60">
{runningItem ? 'Contagem Ativa' : 'Tempo Total'}
</span>
<span className={cn(
"text-base font-black tabular-nums tracking-tight transition-colors duration-500",
runningItem ? "text-brand" : "text-text-main"
)}>
{formatDuration(displaySeconds)}
</span>
</div>
{canControl && (
<div className="ml-auto pr-1">
{runningItem ? (
<button
onClick={handleStop}
disabled={isActionLoading}
className="flex h-9 w-9 items-center justify-center rounded-xl bg-danger text-white shadow-lg shadow-danger/30 transition-all hover:scale-110 hover:bg-danger active:scale-90 disabled:opacity-50"
title="Parar Cronômetro"
>
<Square size={16} fill="currentColor" />
</button>
) : (
<button
onClick={handleStart}
disabled={isActionLoading}
className="flex h-9 w-9 items-center justify-center rounded-xl bg-brand text-white shadow-lg shadow-brand/30 transition-all hover:scale-110 hover:bg-brand-strong active:scale-90 disabled:opacity-50"
title="Iniciar Cronômetro"
>
<Play size={16} fill="currentColor" className="ml-0.5" />
</button>
)}
</div>
)}
</div>
)
}

View file

@ -1,52 +0,0 @@
import { useState } from 'react'
import { cn } from '../lib/utils'
interface UserAvatarProps {
name: string
photo?: string | null
size?: 'xs' | 'sm' | 'md' | 'lg'
className?: string
}
const sizeClasses = {
xs: 'h-6 w-6 rounded-lg text-[10px]',
sm: 'h-8 w-8 rounded-xl text-xs',
md: 'h-10 w-10 rounded-2xl text-sm',
lg: 'h-14 w-14 rounded-2xl text-xl',
}
export function UserAvatar({ name, photo, size = 'md', className }: UserAvatarProps) {
const [imgError, setImgError] = useState(false)
const initials = name
.split(' ')
.slice(0, 2)
.map((part) => part[0])
.join('')
.toUpperCase()
if (photo && !imgError) {
return (
<img
src={photo}
alt={name}
title={name}
className={cn('shrink-0 object-cover', sizeClasses[size], className)}
onError={() => setImgError(true)}
/>
)
}
return (
<div
title={name}
className={cn(
'flex shrink-0 items-center justify-center bg-brand/10 font-black text-brand',
sizeClasses[size],
className,
)}
>
{initials}
</div>
)
}

View file

@ -0,0 +1,555 @@
import React from 'react'
import {
Clock,
CheckCircle2,
Play,
Square,
TrendingUp,
Flame,
Target,
BarChart3,
PieChart,
ListOrdered,
Layers,
ChevronLeft,
ChevronRight,
Trash2,
Edit2,
GripVertical,
} from 'lucide-react'
import type { DashboardV2Card } from '../../lib/userPreferences'
import type { Task, Project, Category } from '../../types/task'
import {
calculateTaskTotalSeconds,
formatSecondsToHuman,
formatSecondsToTimer,
} from '../../lib/timeUtils'
import {
calculateUserPeriodWorkedSeconds,
isSameDay,
isSameWeek,
isSameMonth,
type WorkGoal,
} from '../../lib/userGoals'
interface BiUniformCardProps {
card: DashboardV2Card
index: number
totalCards: number
tasks: Task[]
projects: Project[]
categories: Category[]
activeTask: Task | null
activeSeconds: number
workGoal: WorkGoal
isBeingDragged?: boolean
isDragOver?: boolean
onToggleTimer: (task: Task) => void
onMoveCard: (id: string, direction: 'prev' | 'next') => void
onEditCard: (card: DashboardV2Card) => void
onDeleteCard: (id: string) => void
onDragStart: (e: React.DragEvent, id: string) => void
onDragOver: (e: React.DragEvent, id: string) => void
onDragLeave: (e: React.DragEvent, id: string) => void
onDrop: (e: React.DragEvent, id: string) => void
onDragEnd: () => void
}
const THEME_STYLES: Record<
NonNullable<DashboardV2Card['colorTheme']>,
{
iconBg: string
iconText: string
borderHover: string
badge: string
bar: string
}
> = {
brand: {
iconBg: 'bg-teal-500/15',
iconText: 'text-teal-700 dark:text-teal-400',
borderHover: 'hover:border-teal-500/40',
badge: 'bg-teal-500/10 text-teal-700 dark:text-teal-400 border-teal-500/20',
bar: 'bg-gradient-to-r from-teal-500 to-teal-400',
},
emerald: {
iconBg: 'bg-emerald-500/15',
iconText: 'text-emerald-700 dark:text-emerald-400',
borderHover: 'hover:border-emerald-500/40',
badge: 'bg-emerald-500/10 text-emerald-700 dark:text-emerald-400 border-emerald-500/20',
bar: 'bg-gradient-to-r from-emerald-500 to-teal-400',
},
accent: {
iconBg: 'bg-amber-500/15',
iconText: 'text-amber-500',
borderHover: 'hover:border-amber-500/40',
badge: 'bg-amber-500/10 text-amber-500 border-amber-500/20',
bar: 'bg-gradient-to-r from-amber-500 to-amber-400',
},
indigo: {
iconBg: 'bg-indigo-500/15',
iconText: 'text-indigo-400',
borderHover: 'hover:border-indigo-500/40',
badge: 'bg-indigo-500/10 text-indigo-400 border-indigo-500/20',
bar: 'bg-gradient-to-r from-indigo-500 to-indigo-400',
},
cyan: {
iconBg: 'bg-cyan-500/15',
iconText: 'text-cyan-400',
borderHover: 'hover:border-cyan-500/40',
badge: 'bg-cyan-500/10 text-cyan-400 border-cyan-500/20',
bar: 'bg-gradient-to-r from-cyan-500 to-cyan-400',
},
rose: {
iconBg: 'bg-rose-500/15',
iconText: 'text-rose-400',
borderHover: 'hover:border-rose-500/40',
badge: 'bg-rose-500/10 text-rose-400 border-rose-500/20',
bar: 'bg-gradient-to-r from-rose-500 to-rose-400',
},
}
export const BiUniformCard: React.FC<BiUniformCardProps> = ({
card,
index,
totalCards,
tasks,
projects,
categories,
activeTask,
activeSeconds,
workGoal,
isBeingDragged = false,
isDragOver = false,
onToggleTimer,
onMoveCard,
onEditCard,
onDeleteCard,
onDragStart,
onDragOver,
onDragLeave,
onDrop,
onDragEnd,
}) => {
const theme = THEME_STYLES[card.colorTheme || 'brand']
const now = new Date()
// 1. Filter Tasks based on card's combined dimensions
const filteredTasks = tasks.filter((t) => {
if (card.projectId && t.project?.id !== card.projectId) return false
if (card.categoryId && t.category?.id !== card.categoryId) return false
if (card.statusFilter && card.statusFilter !== 'ALL') {
if (t.status !== card.statusFilter) return false
}
return true
})
// Period-aware seconds calculation
const periodSeconds = filteredTasks.reduce((acc, t) => {
let taskSec = 0
if (card.period === 'ALL') {
taskSec = calculateTaskTotalSeconds(t)
} else {
for (const item of t.history || []) {
if (!item.startAt) continue
const itemDate = new Date(item.startAt)
let matches = false
if (card.period === 'TODAY') matches = isSameDay(itemDate, now)
else if (card.period === 'WEEK') matches = isSameWeek(itemDate, now)
else if (card.period === 'MONTH') matches = isSameMonth(itemDate, now)
if (matches) {
const startMs = itemDate.getTime()
const endMs = item.endAt ? new Date(item.endAt).getTime() : startMs
const duration = Math.max(0, Math.floor((endMs - startMs) / 1000))
taskSec += duration
}
}
}
if (activeTask?.id === t.id && card.period !== 'ALL') {
taskSec += activeSeconds
}
return acc + taskSec
}, 0)
// Associated names
const projectName = projects.find((p) => p.id === card.projectId)?.name
const categoryName = categories.find((c) => c.id === card.categoryId)?.name
const periodLabels: Record<string, string> = {
ALL: 'Total',
TODAY: 'Hoje',
WEEK: 'Esta Semana',
MONTH: 'Este Mês',
}
// Render Body based on metricType
const renderCardBody = () => {
switch (card.metricType) {
case 'HOURS_TRACKED': {
return (
<div className="my-auto">
<p className="text-2xl sm:text-3xl font-extrabold font-mono text-foreground tracking-tight leading-none">
{formatSecondsToTimer(periodSeconds)}
</p>
<p className="text-[10px] text-muted font-medium mt-1 truncate">
≈ {formatSecondsToHuman(periodSeconds)} dedicadas
</p>
</div>
)
}
case 'TASK_COUNT': {
const completedCount = filteredTasks.filter((t) => t.status === 'COMPLETED').length
const inProgressCount = filteredTasks.filter((t) => t.status === 'IN_PROGRESS').length
return (
<div className="my-auto">
<div className="flex items-baseline gap-1.5 leading-none">
<span className="text-2xl sm:text-3xl font-extrabold font-mono text-foreground">
{filteredTasks.length}
</span>
<span className="text-[11px] text-muted font-medium">demandas</span>
</div>
<div className="flex items-center gap-2 text-[10px] text-muted mt-1 truncate">
<span>
<strong className="text-teal-700 dark:text-teal-400">{inProgressCount}</strong> andamento
</span>
<span>•</span>
<span>
<strong className="text-emerald-700 dark:text-emerald-400">{completedCount}</strong> entregues
</span>
</div>
</div>
)
}
case 'COMPLETION_RATE': {
const total = filteredTasks.length
const completed = filteredTasks.filter((t) => t.status === 'COMPLETED').length
const rate = total > 0 ? Math.round((completed / total) * 100) : 0
return (
<div className="space-y-1.5 my-auto">
<div className="flex items-baseline justify-between leading-none">
<span className="text-2xl sm:text-3xl font-extrabold font-mono text-foreground">
{rate}%
</span>
<span className="text-[10px] text-muted font-medium">
{completed}/{total} concluídas
</span>
</div>
<div className="w-full h-1.5 rounded-full bg-surface-sunken overflow-hidden p-0.5 border border-border-soft">
<div
className={`h-full rounded-full transition-all duration-500 ${theme.bar}`}
style={{ width: `${rate}%` }}
/>
</div>
</div>
)
}
case 'STATUS_DISTRIBUTION': {
const notStarted = filteredTasks.filter((t) => t.status === 'NOT_STARTED').length
const inProgress = filteredTasks.filter((t) => t.status === 'IN_PROGRESS').length
const completed = filteredTasks.filter((t) => t.status === 'COMPLETED').length
const total = Math.max(1, filteredTasks.length)
return (
<div className="space-y-1.5 my-auto">
<div className="flex h-2 w-full rounded-full overflow-hidden gap-0.5 bg-surface-sunken p-0.5 border border-border-soft">
<div
style={{ width: `${(completed / total) * 100}%` }}
className="bg-emerald-500 rounded-full"
title={`Concluídas: ${completed}`}
/>
<div
style={{ width: `${(inProgress / total) * 100}%` }}
className="bg-teal-500 rounded-full"
title={`Em Andamento: ${inProgress}`}
/>
<div
style={{ width: `${(notStarted / total) * 100}%` }}
className="bg-zinc-400 rounded-full"
title={`A Fazer: ${notStarted}`}
/>
</div>
<div className="grid grid-cols-3 gap-1 text-[9px] text-muted text-center pt-0.5 font-medium">
<div>
<span className="text-emerald-700 dark:text-emerald-400 font-bold">{completed}</span> Feitas
</div>
<div>
<span className="text-teal-700 dark:text-teal-400 font-bold">{inProgress}</span> Andam.
</div>
<div>
<span className="text-foreground font-bold">{notStarted}</span> Fazer
</div>
</div>
</div>
)
}
case 'PROJECT_BREAKDOWN': {
const projsWithCounts = projects.map((p) => {
const count = tasks.filter((t) => t.project?.id === p.id).length
return { project: p, count }
}).filter((item) => item.count > 0).slice(0, 2)
return (
<div className="space-y-1 my-auto">
{projsWithCounts.length === 0 ? (
<p className="text-[11px] text-muted italic">Nenhum projeto com tarefas.</p>
) : (
projsWithCounts.map(({ project, count }) => (
<div key={project.id} className="flex items-center justify-between text-[11px]">
<span className="font-semibold text-foreground truncate max-w-[130px] flex items-center gap-1.5">
<span
className="w-1.5 h-1.5 rounded-full shrink-0"
style={{ backgroundColor: project.color || '#0f766e' }}
/>
{project.name}
</span>
<span className="font-mono text-muted text-[10px] font-bold">
{count} tasks
</span>
</div>
))
)}
</div>
)
}
case 'WORK_GOAL': {
const targetSeconds = (workGoal?.targetHours || 8) * 3600
const workedSeconds = calculateUserPeriodWorkedSeconds(
tasks,
workGoal?.period || 'DAILY',
activeTask?.id,
activeSeconds
)
const percent = Math.min(100, Math.round((workedSeconds / targetSeconds) * 100))
return (
<div className="space-y-1.5 my-auto">
<div className="flex items-baseline justify-between leading-none">
<span className="text-xl sm:text-2xl font-extrabold font-mono text-foreground">
{formatSecondsToTimer(workedSeconds)}
</span>
<span className="text-[10px] font-bold text-muted">
/ {workGoal.targetHours}h ({percent}%)
</span>
</div>
<div className="w-full h-1.5 rounded-full bg-surface-sunken overflow-hidden p-0.5 border border-border-soft">
<div
className={`h-full rounded-full transition-all duration-500 ${theme.bar}`}
style={{ width: `${percent}%` }}
/>
</div>
</div>
)
}
case 'ACTIVE_TIMER': {
const targetTask = activeTask || tasks[0]
return (
<div className="space-y-1 my-auto">
<div className="flex items-center justify-between">
<span className="text-xl sm:text-2xl font-extrabold font-mono text-foreground">
{activeTask ? formatSecondsToTimer(activeSeconds) : '00:00:00'}
</span>
{targetTask && (
<button
type="button"
onClick={() => onToggleTimer(targetTask)}
className={`p-1.5 rounded-lg transition-all shadow-xs ${
activeTask
? 'bg-rose-500/20 text-rose-300 ring-1 ring-rose-500/40'
: 'bg-teal-500/10 text-teal-700 hover:bg-teal-500/20'
}`}
>
{activeTask ? (
<Square className="w-3 h-3 fill-rose-300" />
) : (
<Play className="w-3 h-3 fill-teal-700 dark:fill-teal-300" />
)}
</button>
)}
</div>
<p className="text-[10px] text-muted truncate">
{activeTask ? activeTask.title : targetTask ? targetTask.title : 'Nenhuma tarefa pronta'}
</p>
</div>
)
}
case 'TOP_TASKS': {
const top2 = [...filteredTasks]
.sort((a, b) => calculateTaskTotalSeconds(b) - calculateTaskTotalSeconds(a))
.slice(0, 2)
return (
<div className="space-y-1 my-auto">
{top2.length === 0 ? (
<p className="text-[11px] text-muted italic">Nenhuma tarefa encontrada.</p>
) : (
top2.map((t, i) => (
<div key={t.id} className="flex items-center justify-between text-[10px] gap-2">
<span className="truncate text-foreground font-semibold">
<strong className="text-muted mr-1">#{i + 1}</strong> {t.title}
</span>
<span className="font-mono text-muted shrink-0">
{formatSecondsToTimer(calculateTaskTotalSeconds(t))}
</span>
</div>
))
)}
</div>
)
}
default:
return null
}
}
const getMetricIcon = () => {
switch (card.metricType) {
case 'HOURS_TRACKED':
return Clock
case 'TASK_COUNT':
return Layers
case 'COMPLETION_RATE':
return CheckCircle2
case 'STATUS_DISTRIBUTION':
return PieChart
case 'PROJECT_BREAKDOWN':
return BarChart3
case 'WORK_GOAL':
return Target
case 'ACTIVE_TIMER':
return Flame
case 'TOP_TASKS':
return ListOrdered
default:
return TrendingUp
}
}
const IconComponent = getMetricIcon()
return (
<div
draggable
onDragStart={(e) => onDragStart(e, card.id)}
onDragOver={(e) => onDragOver(e, card.id)}
onDragLeave={(e) => onDragLeave(e, card.id)}
onDrop={(e) => onDrop(e, card.id)}
onDragEnd={onDragEnd}
className={`h-[185px] min-h-[185px] max-h-[185px] rounded-2xl border border-border-soft bg-surface/90 backdrop-blur-md p-4 shadow-panel flex flex-col justify-between transition-all duration-200 ${
theme.borderHover
} hover:shadow-lg hover:-translate-y-0.5 relative group cursor-grab active:cursor-grabbing select-none ${
isBeingDragged ? 'opacity-30 scale-95 border-dashed border-brand' : ''
} ${isDragOver ? 'ring-2 ring-brand scale-[1.02] bg-brand/5 shadow-2xl' : ''}`}
>
{/* Header */}
<div className="flex items-center justify-between pb-1.5 border-b border-border-soft/60">
<div className="flex items-center gap-2 min-w-0 pr-2">
{/* Drag Handle indicator */}
<GripVertical className="w-3.5 h-3.5 text-muted opacity-40 group-hover:opacity-100 transition-opacity shrink-0" />
<div
className={`w-6 h-6 rounded-lg flex items-center justify-center shrink-0 border border-border-soft ${theme.iconBg} ${theme.iconText}`}
>
<IconComponent className="w-3.5 h-3.5" />
</div>
<h3 className="text-xs font-bold text-foreground truncate" title={card.title}>
{card.title}
</h3>
</div>
{/* Action Controls */}
<div className="flex items-center gap-0.5 opacity-40 group-hover:opacity-100 transition-opacity shrink-0">
<button
type="button"
disabled={index === 0}
onClick={(e) => {
e.stopPropagation()
onMoveCard(card.id, 'prev')
}}
title="Mover para esquerda"
className="p-1 rounded text-muted hover:text-foreground hover:bg-surface-sunken disabled:opacity-20 transition-colors"
>
<ChevronLeft className="w-3 h-3" />
</button>
<button
type="button"
disabled={index === totalCards - 1}
onClick={(e) => {
e.stopPropagation()
onMoveCard(card.id, 'next')
}}
title="Mover para direita"
className="p-1 rounded text-muted hover:text-foreground hover:bg-surface-sunken disabled:opacity-20 transition-colors"
>
<ChevronRight className="w-3 h-3" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onEditCard(card)
}}
title="Editar configurações"
className="p-1 rounded text-muted hover:text-brand hover:bg-surface-sunken transition-colors"
>
<Edit2 className="w-3 h-3" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDeleteCard(card.id)
}}
title="Remover card"
className="p-1 rounded text-muted hover:text-rose-500 hover:bg-surface-sunken transition-colors"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</div>
{/* Main Metric / Graph Body */}
{renderCardBody()}
{/* Footer: Compact Dimension Tags */}
<div className="pt-1.5 border-t border-border-soft/60 flex items-center gap-1.5 flex-wrap text-[9px] truncate">
<span className={`px-1.5 py-0.2 rounded font-semibold border ${theme.badge}`}>
{periodLabels[card.period]}
</span>
{projectName && (
<span className="px-1.5 py-0.2 rounded font-medium bg-surface-sunken text-muted border border-border-soft truncate max-w-[100px]">
{projectName}
</span>
)}
{categoryName && (
<span className="px-1.5 py-0.2 rounded font-medium bg-surface-sunken text-muted border border-border-soft truncate max-w-[90px]">
{categoryName}
</span>
)}
{card.statusFilter && card.statusFilter !== 'ALL' && (
<span className="px-1.5 py-0.2 rounded font-medium bg-surface-sunken text-muted border border-border-soft">
{card.statusFilter === 'COMPLETED'
? 'Concluídas'
: card.statusFilter === 'IN_PROGRESS'
? 'Andamento'
: 'A Fazer'}
</span>
)}
</div>
</div>
)
}

View file

@ -0,0 +1,367 @@
import React, { useState, useEffect } from 'react'
import {
X,
Sparkles,
BarChart3,
Clock,
CheckCircle2,
PieChart,
Target,
Flame,
ListOrdered,
Layers,
} from 'lucide-react'
import type { DashboardV2Card, BiMetricType, BiPeriodFilter } from '../../lib/userPreferences'
import type { Project, Category } from '../../types/task'
interface CreateBiCardModalProps {
isOpen: boolean
onClose: () => void
onSave: (card: DashboardV2Card) => void
editingCard?: DashboardV2Card | null
projects: Project[]
categories: Category[]
}
const METRIC_OPTIONS: {
type: BiMetricType
label: string
desc: string
icon: React.FC<{ className?: string }>
}[] = [
{
type: 'HOURS_TRACKED',
label: 'Horas Dedicadas',
desc: 'Total de tempo cronometrado nas tarefas com os filtros selecionados.',
icon: Clock,
},
{
type: 'TASK_COUNT',
label: 'Quantidade de Tarefas',
desc: 'Total numérico de tarefas criadas ou filtradas.',
icon: Layers,
},
{
type: 'COMPLETION_RATE',
label: 'Taxa de Conclusão (%)',
desc: 'Percentual de demandas entregues em relação ao total.',
icon: CheckCircle2,
},
{
type: 'PROJECT_BREAKDOWN',
label: 'Distribuição por Projeto',
desc: 'Comparativo visual de horas e tarefas por iniciativa.',
icon: BarChart3,
},
{
type: 'STATUS_DISTRIBUTION',
label: 'Distribuição por Status',
desc: 'Divisão entre A Fazer, Em Andamento e Concluído.',
icon: PieChart,
},
{
type: 'WORK_GOAL',
label: 'Meta de Trabalho & Jornada',
desc: 'Progresso frente à meta diária, semanal ou mensal de horas.',
icon: Target,
},
{
type: 'ACTIVE_TIMER',
label: 'Cronômetro & Foco Atual',
desc: 'Monitor do tempo ativo com botão de controle direto no card.',
icon: Flame,
},
{
type: 'TOP_TASKS',
label: 'Demandas Mais Dedicadas',
desc: 'Top 3 tarefas com maior volume de horas acumuladas.',
icon: ListOrdered,
},
]
const COLOR_OPTIONS: { id: DashboardV2Card['colorTheme']; label: string; bg: string }[] = [
{ id: 'brand', label: 'Teal (Brand)', bg: 'bg-teal-500' },
{ id: 'emerald', label: 'Esmeralda', bg: 'bg-emerald-500' },
{ id: 'accent', label: 'Âmbar', bg: 'bg-amber-500' },
{ id: 'indigo', label: 'Índigo', bg: 'bg-indigo-500' },
{ id: 'cyan', label: 'Ciano', bg: 'bg-cyan-500' },
{ id: 'rose', label: 'Rose', bg: 'bg-rose-500' },
]
export const CreateBiCardModal: React.FC<CreateBiCardModalProps> = ({
isOpen,
onClose,
onSave,
editingCard,
projects,
categories,
}) => {
const [title, setTitle] = useState('')
const [metricType, setMetricType] = useState<BiMetricType>('HOURS_TRACKED')
const [period, setPeriod] = useState<BiPeriodFilter>('WEEK')
const [projectId, setProjectId] = useState<number | undefined>(undefined)
const [categoryId, setCategoryId] = useState<number | undefined>(undefined)
const [statusFilter, setStatusFilter] = useState<'ALL' | 'COMPLETED' | 'IN_PROGRESS' | 'NOT_STARTED'>('ALL')
const [colorTheme, setColorTheme] = useState<DashboardV2Card['colorTheme']>('brand')
useEffect(() => {
if (editingCard) {
setTitle(editingCard.title)
setMetricType(editingCard.metricType)
setPeriod(editingCard.period)
setProjectId(editingCard.projectId)
setCategoryId(editingCard.categoryId)
setStatusFilter(editingCard.statusFilter || 'ALL')
setColorTheme(editingCard.colorTheme || 'brand')
} else {
setTitle('')
setMetricType('HOURS_TRACKED')
setPeriod('WEEK')
setProjectId(undefined)
setCategoryId(undefined)
setStatusFilter('ALL')
setColorTheme('brand')
}
}, [editingCard, isOpen])
if (!isOpen) return null
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
const finalTitle = title.trim() || getDefaultTitleForMetric(metricType)
const card: DashboardV2Card = {
id: editingCard?.id || `card-${Date.now()}`,
title: finalTitle,
metricType,
period,
projectId,
categoryId,
statusFilter,
colorTheme,
}
onSave(card)
onClose()
}
function getDefaultTitleForMetric(type: BiMetricType): string {
const found = METRIC_OPTIONS.find((m) => m.type === type)
return found ? found.label : 'Novo Indicador'
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs animate-in fade-in duration-200">
<div
className="w-full max-w-2xl bg-surface border border-border-soft rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[92vh] animate-in zoom-in-95 duration-200"
role="dialog"
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border-soft">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-brand/10 text-brand border border-brand/20 flex items-center justify-center shadow-xs">
<Sparkles className="w-5 h-5 text-accent" />
</div>
<div>
<h2 className="text-base font-extrabold text-foreground">
{editingCard ? 'Editar Card de BI' : 'Criar Card de BI do Zero'}
</h2>
<p className="text-xs text-muted">
Combine métricas, filtros de projeto e períodos no mesmo card.
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-2 rounded-xl text-muted hover:text-foreground hover:bg-surface-raised transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Form Body */}
<form onSubmit={handleSubmit} className="p-6 overflow-y-auto space-y-5 flex-1">
{/* Card Title */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">
Título do Card <span className="text-muted font-normal">(Opcional)</span>
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={getDefaultTitleForMetric(metricType)}
className="w-full px-4 py-2.5 rounded-xl border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-brand focus:ring-1 focus:ring-brand shadow-xs"
/>
</div>
{/* Metric Type Selector */}
<div className="space-y-2">
<label className="text-xs font-bold text-foreground block">
Tipo de Indicador / Métrica
</label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
{METRIC_OPTIONS.map((opt) => {
const IconComponent = opt.icon
const isSelected = metricType === opt.type
return (
<button
key={opt.type}
type="button"
onClick={() => setMetricType(opt.type)}
className={`p-3 rounded-2xl border text-left transition-all flex items-start gap-3 ${
isSelected
? 'border-brand bg-brand/10 ring-1 ring-brand/30 shadow-xs'
: 'border-border-soft bg-surface-raised/60 hover:bg-surface-raised hover:border-brand/30'
}`}
>
<div
className={`w-8 h-8 rounded-xl flex items-center justify-center shrink-0 border ${
isSelected
? 'bg-brand text-white border-brand'
: 'bg-surface text-muted border-border-soft'
}`}
>
<IconComponent className="w-4 h-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-xs font-bold text-foreground leading-tight">
{opt.label}
</p>
<p className="text-[10px] text-muted line-clamp-2 mt-0.5">
{opt.desc}
</p>
</div>
</button>
)
})}
</div>
</div>
{/* Combined Filters (BI Dimensions) */}
<div className="pt-2 border-t border-border-soft space-y-4">
<h4 className="text-xs font-bold uppercase tracking-wider text-muted">
Filtros Combináveis (Dimensões de BI)
</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Period Filter */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">Período</label>
<select
value={period}
onChange={(e) => setPeriod(e.target.value as BiPeriodFilter)}
className="w-full px-3.5 py-2.5 rounded-xl border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-brand shadow-xs"
>
<option value="ALL">Todo o Histórico</option>
<option value="TODAY">Apenas Hoje</option>
<option value="WEEK">Esta Semana</option>
<option value="MONTH">Este Mês</option>
</select>
</div>
{/* Status Filter */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">Filtro por Status</label>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as any)}
className="w-full px-3.5 py-2.5 rounded-xl border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-brand shadow-xs"
>
<option value="ALL">Todos os Status</option>
<option value="COMPLETED">Apenas Concluídas</option>
<option value="IN_PROGRESS">Apenas Em Andamento</option>
<option value="NOT_STARTED">Apenas A Fazer</option>
</select>
</div>
{/* Project Filter */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">Iniciativa / Projeto</label>
<select
value={projectId ?? ''}
onChange={(e) =>
setProjectId(e.target.value ? Number(e.target.value) : undefined)
}
className="w-full px-3.5 py-2.5 rounded-xl border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-brand shadow-xs"
>
<option value="">Todos os Projetos</option>
{projects.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
{/* Category Filter */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">Categoria / Tag</label>
<select
value={categoryId ?? ''}
onChange={(e) =>
setCategoryId(e.target.value ? Number(e.target.value) : undefined)
}
className="w-full px-3.5 py-2.5 rounded-xl border border-border-soft bg-surface text-xs text-foreground focus:outline-hidden focus:border-brand shadow-xs"
>
<option value="">Todas as Categorias</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
</div>
</div>
{/* Color Theme Selector */}
<div className="pt-2 border-t border-border-soft space-y-2">
<label className="text-xs font-bold text-foreground block">
Cor de Destaque do Card
</label>
<div className="flex items-center gap-3 flex-wrap">
{COLOR_OPTIONS.map((c) => {
const isSelected = colorTheme === c.id
return (
<button
key={c.id}
type="button"
onClick={() => setColorTheme(c.id)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-xl border text-xs font-semibold transition-all ${
isSelected
? 'border-brand bg-surface shadow-xs ring-2 ring-brand/40 text-foreground font-bold'
: 'border-border-soft bg-surface-raised text-muted hover:text-foreground'
}`}
>
<span className={`w-3 h-3 rounded-full ${c.bg}`} />
<span>{c.label}</span>
</button>
)
})}
</div>
</div>
</form>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-border-soft bg-surface-muted/30">
<button
type="button"
onClick={onClose}
className="px-4 py-2.5 rounded-xl border border-border-soft bg-surface hover:bg-surface-raised text-foreground text-xs font-bold transition-colors shadow-xs"
>
Cancelar
</button>
<button
type="button"
onClick={handleSubmit}
className="px-5 py-2.5 rounded-xl bg-brand hover:bg-brand-strong text-white text-xs font-bold shadow-lg shadow-brand/25 transition-all duration-200 hover:scale-[1.02] active:scale-[0.98]"
>
{editingCard ? 'Salvar Alterações' : 'Adicionar ao Painel'}
</button>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,308 @@
import React, { useState, useEffect } from 'react'
import {
X,
SlidersHorizontal,
ChevronUp,
ChevronDown,
Eye,
EyeOff,
RotateCcw,
Check,
TrendingUp,
Flame,
Target,
Zap,
FolderKanban,
Sparkles,
} from 'lucide-react'
import type { DashboardCardConfig, DashboardWidgetId } from '../../lib/userPreferences'
interface CustomizeDashboardModalProps {
isOpen: boolean
onClose: () => void
currentCards: DashboardCardConfig[]
onSave: (cards: DashboardCardConfig[]) => Promise<void>
onReset: () => Promise<void>
}
const WIDGET_ICONS: Record<DashboardWidgetId, React.FC<{ className?: string }>> = {
kpis: TrendingUp,
focus_session: Flame,
work_goal: Target,
recent_tasks: Zap,
quick_shortcuts: FolderKanban,
}
export const CustomizeDashboardModal: React.FC<CustomizeDashboardModalProps> = ({
isOpen,
onClose,
currentCards,
onSave,
onReset,
}) => {
const [cards, setCards] = useState<DashboardCardConfig[]>([])
const [saving, setSaving] = useState(false)
const [resetting, setResetting] = useState(false)
const [savedSuccess, setSavedSuccess] = useState(false)
useEffect(() => {
if (isOpen) {
setCards([...currentCards])
setSavedSuccess(false)
}
}, [isOpen, currentCards])
if (!isOpen) return null
const handleMoveUp = (index: number) => {
if (index === 0) return
const next = [...cards]
const temp = next[index - 1]
next[index - 1] = next[index]
next[index] = temp
setCards(next)
}
const handleMoveDown = (index: number) => {
if (index === cards.length - 1) return
const next = [...cards]
const temp = next[index + 1]
next[index + 1] = next[index]
next[index] = temp
setCards(next)
}
const handleToggleVisible = (id: DashboardWidgetId) => {
setCards((prev) =>
prev.map((c) => (c.id === id ? { ...c, visible: !c.visible } : c))
)
}
const handleSave = async () => {
try {
setSaving(true)
await onSave(cards)
setSavedSuccess(true)
setTimeout(() => {
onClose()
}, 700)
} catch (err) {
console.error(err)
} finally {
setSaving(false)
}
}
const handleReset = async () => {
try {
setResetting(true)
await onReset()
setSavedSuccess(true)
setTimeout(() => {
onClose()
}, 700)
} catch (err) {
console.error(err)
} finally {
setResetting(false)
}
}
const visibleCount = cards.filter((c) => c.visible).length
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs animate-in fade-in duration-200">
<div
className="w-full max-w-xl bg-surface border border-border-soft rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh] animate-in zoom-in-95 duration-200"
role="dialog"
aria-modal="true"
>
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-border-soft">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-2xl bg-brand/10 text-brand border border-brand/20 flex items-center justify-center shadow-xs">
<SlidersHorizontal className="w-5 h-5" />
</div>
<div>
<h2 className="text-base font-extrabold text-foreground flex items-center gap-2">
Personalizar Dashboard
<Sparkles className="w-4 h-4 text-accent" />
</h2>
<p className="text-xs text-muted">
Escolha a ordem e a visibilidade dos cards para sua rotina.
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-2 rounded-xl text-muted hover:text-foreground hover:bg-surface-raised transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Info Banner */}
<div className="px-6 py-3 bg-surface-muted/50 border-b border-border-soft flex items-center justify-between text-xs text-muted">
<span>
Exibindo <strong className="text-foreground">{visibleCount}</strong> de {cards.length} cards no painel
</span>
<span className="text-[11px] text-muted font-medium">
Salvo na sua conta (Banco de Dados)
</span>
</div>
{/* Card List with Order Controls */}
<div className="p-6 overflow-y-auto space-y-3 flex-1">
{cards.map((card, idx) => {
const IconComponent = WIDGET_ICONS[card.id] || TrendingUp
const isFirst = idx === 0
const isLast = idx === cards.length - 1
return (
<div
key={card.id}
className={`flex items-center justify-between p-3.5 rounded-2xl border transition-all duration-200 ${
card.visible
? 'bg-surface-raised/90 border-border-soft shadow-xs hover:border-brand/30'
: 'bg-surface-muted/30 border-border-soft/60 opacity-60'
}`}
>
{/* Left: Reorder Controls + Icon + Info */}
<div className="flex items-center gap-3 flex-1 min-w-0 pr-3">
{/* Order & Steppers */}
<div className="flex flex-col items-center gap-0.5 shrink-0">
<button
type="button"
disabled={isFirst}
onClick={() => handleMoveUp(idx)}
title="Mover para cima"
className="p-1 rounded-md text-muted hover:text-foreground hover:bg-surface-sunken disabled:opacity-20 disabled:hover:bg-transparent transition-colors"
>
<ChevronUp className="w-3.5 h-3.5" />
</button>
<span className="text-[11px] font-bold font-mono text-muted">
{idx + 1}
</span>
<button
type="button"
disabled={isLast}
onClick={() => handleMoveDown(idx)}
title="Mover para baixo"
className="p-1 rounded-md text-muted hover:text-foreground hover:bg-surface-sunken disabled:opacity-20 disabled:hover:bg-transparent transition-colors"
>
<ChevronDown className="w-3.5 h-3.5" />
</button>
</div>
{/* Icon */}
<div
className={`w-9 h-9 rounded-xl flex items-center justify-center shrink-0 border ${
card.visible
? 'bg-brand/10 text-brand border-brand/20'
: 'bg-surface-sunken text-muted border-border-soft'
}`}
>
<IconComponent className="w-4 h-4" />
</div>
{/* Texts */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h4
className={`text-xs font-bold truncate ${
card.visible ? 'text-foreground' : 'text-muted line-through'
}`}
>
{card.title}
</h4>
{card.visible ? (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded-md bg-teal-500/10 text-teal-700 dark:text-teal-400 border border-teal-600/20">
Visível
</span>
) : (
<span className="text-[9px] font-bold px-1.5 py-0.5 rounded-md bg-surface-sunken text-muted border border-border-soft">
Oculto
</span>
)}
</div>
<p className="text-[11px] text-muted truncate mt-0.5">
{card.description}
</p>
</div>
</div>
{/* Right: Toggle Button */}
<button
type="button"
onClick={() => handleToggleVisible(card.id)}
title={card.visible ? 'Ocultar card' : 'Exibir card'}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl border text-xs font-bold transition-all shadow-xs ${
card.visible
? 'bg-teal-500/10 text-teal-700 dark:text-teal-400 border-teal-600/25 hover:bg-teal-500/20'
: 'bg-surface-sunken text-muted border-border-soft hover:text-foreground hover:bg-surface-raised'
}`}
>
{card.visible ? (
<>
<Eye className="w-3.5 h-3.5 text-teal-700 dark:text-teal-400" />
<span>Exibido</span>
</>
) : (
<>
<EyeOff className="w-3.5 h-3.5 text-muted" />
<span>Ocultado</span>
</>
)}
</button>
</div>
)
})}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-border-soft bg-surface-muted/30">
<button
type="button"
disabled={saving || resetting}
onClick={handleReset}
className="flex items-center gap-1.5 px-3.5 py-2.5 rounded-xl border border-border-soft bg-surface hover:bg-surface-raised text-muted hover:text-foreground text-xs font-bold transition-colors disabled:opacity-50 shadow-xs"
>
<RotateCcw className={`w-3.5 h-3.5 ${resetting ? 'animate-spin' : ''}`} />
<span>Restaurar Padrão</span>
</button>
<div className="flex items-center gap-2.5">
<button
type="button"
onClick={onClose}
disabled={saving}
className="px-4 py-2.5 rounded-xl border border-border-soft bg-surface hover:bg-surface-raised text-foreground text-xs font-bold transition-colors shadow-xs"
>
Cancelar
</button>
<button
type="button"
disabled={saving || resetting}
onClick={handleSave}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-brand hover:bg-brand-strong text-white text-xs font-bold shadow-lg shadow-brand/25 transition-all duration-200 hover:scale-[1.02] active:scale-[0.98] disabled:opacity-50"
>
{savedSuccess ? (
<>
<Check className="w-4 h-4 text-white" />
<span>Salvo com Sucesso!</span>
</>
) : saving ? (
<span>Salvando no Banco...</span>
) : (
<span>Salvar Personalização</span>
)}
</button>
</div>
</div>
</div>
</div>
)
}

View file

@ -77,6 +77,7 @@ export const Sidebar = ({ isMobileOpen, onCloseMobile }: SidebarProps) => {
group: 'Geral', group: 'Geral',
links: [ links: [
{ to: '/dashboard', label: 'Dashboard', icon: <LayoutDashboard className="w-4 h-4" /> }, { to: '/dashboard', label: 'Dashboard', icon: <LayoutDashboard className="w-4 h-4" /> },
{ to: '/dashboard-v2', label: 'BI', icon: <BarChart3 className="w-4 h-4" /> },
{ to: '/tasks', label: 'Tarefas', icon: <ListTodo className="w-4 h-4" /> }, { to: '/tasks', label: 'Tarefas', icon: <ListTodo className="w-4 h-4" /> },
], ],
}, },
@ -233,14 +234,14 @@ export const Sidebar = ({ isMobileOpen, onCloseMobile }: SidebarProps) => {
{/* Active Running Task Activity Status Banner */} {/* Active Running Task Activity Status Banner */}
{activeTask && ( {activeTask && (
<div className="px-4 py-2 shrink-0"> <div className="px-4 py-2 shrink-0">
<div className="p-3 rounded-2xl bg-teal-500/10 border border-teal-500/30 text-teal-300 shadow-xs relative overflow-hidden"> <div className="p-3 rounded-2xl bg-teal-500/10 border border-teal-600/25 dark:border-teal-500/30 shadow-xs relative overflow-hidden">
<div className="flex items-center justify-between gap-2 mb-1.5"> <div className="flex items-center justify-between gap-2 mb-1.5">
<div className="flex items-center gap-2 truncate"> <div className="flex items-center gap-2 truncate">
<span className="relative flex h-2 w-2 shrink-0"> <span className="relative flex h-2 w-2 shrink-0">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-teal-400 opacity-75" /> <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-teal-600 dark:bg-teal-400 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-teal-400" /> <span className="relative inline-flex rounded-full h-2 w-2 bg-teal-600 dark:bg-teal-400" />
</span> </span>
<span className="text-[10px] font-bold uppercase tracking-wider text-teal-400"> <span className="text-[10px] font-bold uppercase tracking-wider text-teal-700 dark:text-teal-400">
Atividade Ativa Atividade Ativa
</span> </span>
</div> </div>
@ -260,8 +261,8 @@ export const Sidebar = ({ isMobileOpen, onCloseMobile }: SidebarProps) => {
{activeTask.title} {activeTask.title}
</p> </p>
<div className="flex items-center gap-1.5 font-mono text-sm font-extrabold text-teal-400"> <div className="flex items-center gap-1.5 font-mono text-sm font-extrabold text-teal-700 dark:text-teal-400">
<Clock className="w-3.5 h-3.5 text-teal-400 animate-pulse" /> <Clock className="w-3.5 h-3.5 text-teal-700 dark:text-teal-400 animate-pulse" />
<span>{formatSecondsToTimer(activeSeconds)}</span> <span>{formatSecondsToTimer(activeSeconds)}</span>
</div> </div>
</div> </div>

View file

@ -1,17 +0,0 @@
import type { TaskStatus } from '../types/task'
export interface TaskFilters {
searchQuery: string
statuses: TaskStatus[]
categoryIds: number[]
projectIds: number[]
userIds: number[]
}
export const DEFAULT_TASK_FILTERS: TaskFilters = {
searchQuery: '',
statuses: [],
categoryIds: [],
projectIds: [],
userIds: [],
}

View file

@ -0,0 +1,956 @@
import React, { useState, useEffect, useMemo } from 'react'
import {
X,
Folder,
Tag,
User,
Loader2,
Sparkles,
Clock,
Calendar,
Plus,
Pencil,
Trash2,
Check,
FileText,
AlertCircle,
ExternalLink,
} from 'lucide-react'
import type { Task, TaskStatus, Project, Category, TaskHistoryItem, TaskHistoryItemInput } from '../../types/task'
import type { OrganizationMember } from '../../services/organizationService'
import { projectService } from '../../services/projectService'
import { categoryService } from '../../services/categoryService'
import { organizationService } from '../../services/organizationService'
import { CustomSelect } from '../ui/Select'
import { useAuth } from '../../contexts/AuthContext'
import {
calculateTaskTotalSeconds,
formatSecondsToTimer,
formatSecondsToHuman,
formatIsoDateOnly,
formatIsoTimeOnly,
splitIsoToDateTime,
combineDateAndTimeToIso,
} from '../../lib/timeUtils'
interface CreateTaskModalProps {
isOpen: boolean
onClose: () => void
onSubmit: (taskData: {
title: string
description: string
status: TaskStatus
projectId?: number
categoryId?: number
assigneeUserId?: number
history?: TaskHistoryItemInput[]
externalUrl?: string
}) => Promise<void>
organizationId: number
initialStatus?: TaskStatus
initialTask?: Task | null
initialTab?: 'details' | 'history'
}
export const CreateTaskModal: React.FC<CreateTaskModalProps> = ({
isOpen,
onClose,
onSubmit,
organizationId,
initialStatus = 'NOT_STARTED',
initialTask = null,
initialTab = 'details',
}) => {
const { user } = useAuth()
const [activeTab, setActiveTab] = useState<'details' | 'history'>('details')
// Task details
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [status, setStatus] = useState<TaskStatus>(initialStatus)
const [projectId, setProjectId] = useState<number | undefined>(undefined)
const [categoryId, setCategoryId] = useState<number | undefined>(undefined)
const [assigneeUserId, setAssigneeUserId] = useState<number | undefined>(undefined)
const [externalUrl, setExternalUrl] = useState('')
// Local history state (saved together with task)
const [localHistory, setLocalHistory] = useState<TaskHistoryItem[]>([])
// Inline history item edit / add states
const [isHistoryFormOpen, setIsHistoryFormOpen] = useState(false)
const [editingHistoryId, setEditingHistoryId] = useState<number | null>(null)
const [histStartDate, setHistStartDate] = useState('')
const [histStartTime, setHistStartTime] = useState('')
const [histEndDate, setHistEndDate] = useState('')
const [histEndTime, setHistEndTime] = useState('')
const [histIsOngoing, setHistIsOngoing] = useState(false)
const [historyFormError, setHistoryFormError] = useState<string | null>(null)
const [projects, setProjects] = useState<Project[]>([])
const [categories, setCategories] = useState<Category[]>([])
const [members, setMembers] = useState<OrganizationMember[]>([])
const [loading, setLoading] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
// Quick inline creation states
const [newProjectName, setNewProjectName] = useState('')
const [showAddProject, setShowAddProject] = useState(false)
const [newCategoryName, setNewCategoryName] = useState('')
const [showAddCategory, setShowAddCategory] = useState(false)
// Populate data when modal opens
useEffect(() => {
if (!isOpen) return
if (initialTask) {
setTitle(initialTask.title)
setDescription(initialTask.description || '')
setStatus(initialTask.status)
setProjectId(initialTask.project?.id)
setCategoryId(initialTask.category?.id)
setAssigneeUserId(initialTask.user?.id)
setExternalUrl(initialTask.externalUrl || '')
setLocalHistory(initialTask.history ? [...initialTask.history] : [])
setActiveTab(initialTab)
} else {
setTitle('')
setDescription('')
setStatus(initialStatus)
setProjectId(undefined)
setCategoryId(undefined)
setExternalUrl('')
setLocalHistory([])
setActiveTab('details')
const currentMember = members.find(
(m) => m.userEmail?.toLowerCase() === user?.email?.toLowerCase()
)
setAssigneeUserId(currentMember ? currentMember.userId : undefined)
}
setError(null)
setShowAddProject(false)
setShowAddCategory(false)
setIsHistoryFormOpen(false)
setEditingHistoryId(null)
setHistoryFormError(null)
// Load projects, categories, members
const loadMetadata = async () => {
setLoading(true)
try {
const [projRes, catRes, memRes] = await Promise.allSettled([
projectService.listProjects(organizationId),
categoryService.listCategories(organizationId),
organizationService.listMembers(organizationId),
])
if (projRes.status === 'fulfilled') setProjects(projRes.value || [])
if (catRes.status === 'fulfilled') setCategories(catRes.value || [])
if (memRes.status === 'fulfilled') {
const memberList = memRes.value || []
setMembers(memberList)
if (!initialTask && user?.email) {
const currentMember = memberList.find(
(m) => m.userEmail?.toLowerCase() === user.email.toLowerCase()
)
if (currentMember) {
setAssigneeUserId(currentMember.userId)
}
}
}
} catch (err) {
console.error('Error fetching metadata', err)
} finally {
setLoading(false)
}
}
loadMetadata()
}, [isOpen, organizationId, initialStatus, initialTask, initialTab, user?.email])
// Total seconds calculated dynamically from localHistory
const totalCalculatedSeconds = useMemo(() => {
const dummyTask: Task = {
id: initialTask?.id || 0,
title: title,
status: status,
history: localHistory,
}
return calculateTaskTotalSeconds(dummyTask)
}, [initialTask?.id, title, status, localHistory])
// Duration preview for history form
const historyFormPreview = useMemo(() => {
if (!histStartDate || !histStartTime) return null
if (histIsOngoing) return 'Em andamento (sem horário de término)'
if (!histEndDate || !histEndTime) return null
const startIso = combineDateAndTimeToIso(histStartDate, histStartTime)
const endIso = combineDateAndTimeToIso(histEndDate, histEndTime)
const diff = Math.floor((new Date(endIso).getTime() - new Date(startIso).getTime()) / 1000)
if (diff < 0) return 'Horário final anterior ao inicial'
return formatSecondsToHuman(diff) + ` (${formatSecondsToTimer(diff)})`
}, [histStartDate, histStartTime, histEndDate, histEndTime, histIsOngoing])
if (!isOpen) return null
const handleCreateProject = async (e: React.FormEvent) => {
e.preventDefault()
if (!newProjectName.trim()) return
try {
const created = await projectService.createProject(organizationId, {
name: newProjectName.trim(),
color: '#0f766e',
})
setProjects((prev) => [...prev, created])
setProjectId(created.id)
setNewProjectName('')
setShowAddProject(false)
} catch (err: unknown) {
console.error(err)
setError('Falha ao criar projeto.')
}
}
const handleCreateCategory = async (e: React.FormEvent) => {
e.preventDefault()
if (!newCategoryName.trim()) return
try {
const created = await categoryService.createCategory(organizationId, {
name: newCategoryName.trim(),
color: '#d97706',
})
setCategories((prev) => [...prev, created])
setCategoryId(created.id)
setNewCategoryName('')
setShowAddCategory(false)
} catch (err: unknown) {
console.error(err)
setError('Falha ao criar categoria.')
}
}
// History item helper methods
const handleOpenAddHistory = () => {
const { date, time } = splitIsoToDateTime()
setHistStartDate(date)
setHistStartTime(time)
setHistEndDate(date)
const [hh, mm] = time.split(':').map(Number)
const endH = (hh + 1) % 24
setHistEndTime(`${String(endH).padStart(2, '0')}:${String(mm).padStart(2, '0')}`)
setHistIsOngoing(false)
setEditingHistoryId(null)
setHistoryFormError(null)
setIsHistoryFormOpen(true)
}
const handleOpenEditHistory = (item: TaskHistoryItem) => {
const start = splitIsoToDateTime(item.startAt)
setHistStartDate(start.date)
setHistStartTime(start.time)
if (item.endAt) {
const end = splitIsoToDateTime(item.endAt)
setHistEndDate(end.date)
setHistEndTime(end.time)
setHistIsOngoing(false)
} else {
setHistEndDate(start.date)
setHistEndTime(start.time)
setHistIsOngoing(true)
}
setEditingHistoryId(item.id)
setHistoryFormError(null)
setIsHistoryFormOpen(true)
}
const handleSaveHistoryItemLocally = () => {
if (!histStartDate || !histStartTime) {
setHistoryFormError('Data e hora de início são obrigatórias.')
return
}
const startIso = combineDateAndTimeToIso(histStartDate, histStartTime)
let endIso: string | null = null
if (!histIsOngoing) {
if (!histEndDate || !histEndTime) {
setHistoryFormError('Data e hora de término são obrigatórias.')
return
}
endIso = combineDateAndTimeToIso(histEndDate, histEndTime)
if (new Date(endIso).getTime() < new Date(startIso).getTime()) {
setHistoryFormError('O horário de término não pode ser anterior ao início.')
return
}
}
let calculatedSec = 0
if (endIso) {
calculatedSec = Math.max(0, Math.floor((new Date(endIso).getTime() - new Date(startIso).getTime()) / 1000))
}
if (editingHistoryId !== null) {
// Update existing item in local state
setLocalHistory((prev) =>
prev.map((item) =>
item.id === editingHistoryId
? { ...item, startAt: startIso, endAt: endIso, elapsedSeconds: calculatedSec }
: item
)
)
} else {
// Add new item to local state (with temporary negative ID to be recognized as new)
const newItem: TaskHistoryItem = {
id: -Date.now(),
startAt: startIso,
endAt: endIso,
elapsedSeconds: calculatedSec,
}
setLocalHistory((prev) => [newItem, ...prev])
}
setIsHistoryFormOpen(false)
setEditingHistoryId(null)
setHistoryFormError(null)
}
const handleDeleteHistoryLocally = (itemId: number) => {
setLocalHistory((prev) => prev.filter((item) => item.id !== itemId))
}
// Submit all together via single endpoint
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim()) {
setError('O título da tarefa é obrigatório.')
return
}
setSubmitting(true)
setError(null)
try {
// Map local history items for backend
const mappedHistory: TaskHistoryItemInput[] = localHistory.map((item) => ({
id: item.id > 0 ? item.id : undefined,
startAt: item.startAt,
endAt: item.endAt,
}))
await onSubmit({
title: title.trim(),
description: description.trim(),
status,
projectId,
categoryId,
assigneeUserId,
history: initialTask ? mappedHistory : undefined,
externalUrl: externalUrl.trim() || undefined,
})
onClose()
} catch (err: unknown) {
console.error(err)
setError(err instanceof Error ? err.message : 'Erro ao salvar tarefa.')
} finally {
setSubmitting(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 overflow-y-auto">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/75 backdrop-blur-xs transition-opacity"
onClick={onClose}
/>
{/* Modal Dialog */}
<div className="relative w-full max-w-2xl rounded-2xl border border-border bg-surface-raised shadow-2xl z-10 overflow-hidden flex flex-col max-h-[90vh]">
{/* Header */}
<div className="flex items-center justify-between p-4 sm:p-5 border-b border-border bg-surface">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-lg bg-brand/15 border border-brand/30 flex items-center justify-center text-brand">
<Sparkles className="w-4 h-4" />
</div>
<div>
<h2 className="text-base font-bold text-foreground">
{initialTask ? 'Editar Tarefa' : 'Nova Tarefa'}
</h2>
<p className="text-xs text-muted">
{initialTask
? 'Atualize as informações e o histórico de tempo da demanda.'
: 'Preencha os campos para cadastrar no quadro de atividades.'}
</p>
</div>
</div>
<button
type="button"
onClick={onClose}
className="p-1.5 rounded-lg text-muted hover:text-foreground hover:bg-surface-raised transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Tabs switcher when editing task */}
{initialTask && (
<div className="flex border-b border-border bg-surface/50 px-4 pt-2 gap-2">
<button
type="button"
onClick={() => setActiveTab('details')}
className={`flex items-center gap-2 px-3.5 py-2 text-xs font-semibold rounded-t-xl transition-all border-b-2 ${
activeTab === 'details'
? 'border-brand text-brand bg-surface'
: 'border-transparent text-muted hover:text-foreground'
}`}
>
<FileText className="w-3.5 h-3.5" />
<span>Informações Gerais</span>
</button>
<button
type="button"
onClick={() => setActiveTab('history')}
className={`flex items-center gap-2 px-3.5 py-2 text-xs font-semibold rounded-t-xl transition-all border-b-2 ${
activeTab === 'history'
? 'border-brand text-brand bg-surface'
: 'border-transparent text-muted hover:text-foreground'
}`}
>
<Clock className="w-3.5 h-3.5" />
<span>Histórico de Tempo</span>
<span className="px-1.5 py-0.2 rounded-full text-[10px] bg-brand/15 text-brand font-mono font-bold">
{localHistory.length}
</span>
</button>
</div>
)}
{/* Body Form */}
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-4 sm:p-5 space-y-4">
{error && (
<div className="p-3 rounded-xl bg-rose-500/10 border border-rose-500/20 text-rose-300 text-xs">
{error}
</div>
)}
{/* TAB 1: General Details */}
{activeTab === 'details' && (
<div className="space-y-4">
{/* Title */}
<div>
<label className="block text-xs font-semibold text-foreground mb-1.5">
Título da Tarefa <span className="text-brand">*</span>
</label>
<input
type="text"
required
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Ex: Implementar autenticação via OAuth2..."
className="w-full px-3.5 py-2.5 rounded-xl border border-border bg-surface text-sm text-foreground placeholder-muted/60 focus:outline-none focus:ring-2 focus:ring-brand/40 focus:border-brand transition-all"
/>
</div>
{/* Description */}
<div>
<label className="block text-xs font-semibold text-foreground mb-1.5">
Descrição detalhada
</label>
<textarea
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Descreva o escopo, critérios de aceite ou observações importantes..."
className="w-full px-3.5 py-2.5 rounded-xl border border-border bg-surface text-xs text-foreground placeholder-muted/60 focus:outline-none focus:ring-2 focus:ring-brand/40 focus:border-brand transition-all resize-none"
/>
</div>
{/* Project & Category row */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Project */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-xs font-semibold text-foreground flex items-center gap-1.5">
<Folder className="w-3.5 h-3.5 text-brand" />
Projeto
</label>
<button
type="button"
onClick={() => setShowAddProject(!showAddProject)}
className="text-[11px] text-brand hover:underline font-medium"
>
{showAddProject ? 'Cancelar' : '+ Novo'}
</button>
</div>
{showAddProject ? (
<div className="flex items-center gap-1.5 mb-1">
<input
type="text"
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
placeholder="Nome do projeto..."
className="flex-1 px-2.5 py-1.5 rounded-lg border border-border bg-surface text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-brand"
/>
<button
type="button"
onClick={handleCreateProject}
className="px-2.5 py-1.5 rounded-lg bg-brand text-white text-xs font-medium hover:bg-brand-hover"
>
Salvar
</button>
</div>
) : (
<CustomSelect
value={projectId ?? 0}
onChange={(val) => setProjectId(Number(val) === 0 ? undefined : Number(val))}
placeholder="Sem projeto"
leftIcon={<Folder className="w-3.5 h-3.5 text-brand" />}
options={[
{ value: 0, label: 'Sem projeto' },
...projects.map((p) => ({
value: p.id,
label: p.name,
color: p.color || '#0f766e',
})),
]}
/>
)}
</div>
{/* Category */}
<div>
<div className="flex items-center justify-between mb-1.5">
<label className="text-xs font-semibold text-foreground flex items-center gap-1.5">
<Tag className="w-3.5 h-3.5 text-accent" />
Categoria
</label>
<button
type="button"
onClick={() => setShowAddCategory(!showAddCategory)}
className="text-[11px] text-accent hover:underline font-medium cursor-pointer"
>
{showAddCategory ? 'Cancelar' : '+ Nova'}
</button>
</div>
{showAddCategory ? (
<div className="flex items-center gap-1.5 mb-1">
<input
type="text"
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
placeholder="Nome da categoria..."
className="flex-1 px-2.5 py-1.5 rounded-lg border border-border bg-surface text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-brand"
/>
<button
type="button"
onClick={handleCreateCategory}
className="px-2.5 py-1.5 rounded-lg bg-accent text-white text-xs font-medium hover:bg-accent-hover"
>
Salvar
</button>
</div>
) : (
<CustomSelect
value={categoryId ?? 0}
onChange={(val) => setCategoryId(Number(val) === 0 ? undefined : Number(val))}
placeholder="Sem categoria"
leftIcon={<Tag className="w-3.5 h-3.5 text-accent" />}
options={[
{ value: 0, label: 'Sem categoria' },
...categories.map((c) => ({
value: c.id,
label: c.name,
color: c.color || '#d97706',
})),
]}
/>
)}
</div>
</div>
{/* Assignee & Status */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{/* Assignee */}
<div>
<label className="block text-xs font-semibold text-foreground mb-1.5 flex items-center gap-1.5">
<User className="w-3.5 h-3.5 text-muted" />
Responsável
</label>
<CustomSelect
value={assigneeUserId ?? 0}
onChange={(val) => setAssigneeUserId(Number(val) === 0 ? undefined : Number(val))}
placeholder="Atribuir a mim mesmo / Sem responsável"
leftIcon={<User className="w-3.5 h-3.5 text-muted" />}
options={[
{ value: 0, label: 'Sem responsável (atribuir a mim)' },
...members.map((m) => ({
value: m.userId,
label: m.userName,
description: m.userEmail,
})),
]}
/>
</div>
{/* Status */}
<div>
<label className="block text-xs font-semibold text-foreground mb-1.5">
Status da Tarefa
</label>
<CustomSelect
value={status}
onChange={(val) => setStatus(val as TaskStatus)}
options={[
{ value: 'NOT_STARTED', label: 'A Fazer', color: '#71717a' },
{ value: 'IN_PROGRESS', label: 'Em Andamento', color: '#14b8a6' },
{ value: 'COMPLETED', label: 'Concluído', color: '#10b981' },
{ value: 'CANCELED', label: 'Cancelado', color: '#f43f5e' },
]}
/>
</div>
</div>
{/* External Link (GitHub, Jira, etc.) */}
<div>
<label className="block text-xs font-semibold text-foreground mb-1.5 flex items-center justify-between">
<span className="flex items-center gap-1.5">
<ExternalLink className="w-3.5 h-3.5 text-brand" />
Link de outra plataforma (GitHub, Jira, etc.)
</span>
<span className="text-[11px] text-muted font-normal">Opcional</span>
</label>
<div className="relative">
<input
type="url"
value={externalUrl}
onChange={(e) => setExternalUrl(e.target.value)}
placeholder="https://github.com/org/repo/issues/123 ou link Jira"
className="w-full px-3.5 py-2.5 rounded-xl border border-border bg-surface text-xs text-foreground placeholder-muted/60 focus:outline-none focus:ring-2 focus:ring-brand/40 focus:border-brand transition-all pr-9"
/>
{externalUrl.trim() && (
<a
href={externalUrl.startsWith('http') ? externalUrl : `https://${externalUrl}`}
target="_blank"
rel="noopener noreferrer"
className="absolute right-2.5 top-1/2 -translate-y-1/2 p-1 text-muted hover:text-brand transition-colors"
title="Abrir link externo"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
</div>
</div>
</div>
)}
{/* TAB 2: History & Time Sessions */}
{activeTab === 'history' && (
<div className="space-y-4">
{/* KPI Header Bar */}
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2.5">
<div className="p-3 rounded-xl border border-border-soft bg-surface/80 flex flex-col justify-between">
<span className="text-[11px] font-semibold text-muted flex items-center gap-1">
<Clock className="w-3.5 h-3.5 text-brand" />
Tempo Acumulado
</span>
<div className="mt-1">
<div className="text-base sm:text-lg font-bold font-mono text-foreground">
{formatSecondsToTimer(totalCalculatedSeconds)}
</div>
<div className="text-[10px] text-muted">
{formatSecondsToHuman(totalCalculatedSeconds)}
</div>
</div>
</div>
<div className="p-3 rounded-xl border border-border-soft bg-surface/80 flex flex-col justify-between">
<span className="text-[11px] font-semibold text-muted flex items-center gap-1">
<Calendar className="w-3.5 h-3.5 text-accent" />
Sessões
</span>
<div className="mt-1">
<div className="text-base sm:text-lg font-bold text-foreground">
{localHistory.length}
</div>
<div className="text-[10px] text-muted">
{localHistory.length === 1 ? '1 lançamento' : `${localHistory.length} lançamentos`}
</div>
</div>
</div>
<div className="col-span-2 sm:col-span-1 p-3 rounded-xl border border-border-soft bg-surface/80 flex flex-col justify-between">
<span className="text-[11px] font-semibold text-muted">Ação</span>
<div className="mt-1">
{!isHistoryFormOpen ? (
<button
type="button"
onClick={handleOpenAddHistory}
className="w-full inline-flex items-center justify-center gap-1.5 px-2.5 py-1.5 rounded-lg bg-brand/15 text-brand border border-brand/30 hover:bg-brand hover:text-white transition-all text-xs font-semibold shadow-xs"
>
<Plus className="w-3.5 h-3.5" />
<span>+ Novo Registro</span>
</button>
) : (
<span className="text-xs text-brand font-medium">Editando registro...</span>
)}
</div>
</div>
</div>
{/* Form to add/edit session in local state */}
{isHistoryFormOpen && (
<div className="p-3.5 rounded-xl border border-brand/40 bg-surface shadow-md space-y-3">
<div className="flex items-center justify-between pb-1.5 border-b border-border">
<span className="text-xs font-bold text-foreground flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5 text-brand" />
{editingHistoryId !== null ? 'Editar Registro de Horário' : 'Novo Registro de Horário'}
</span>
<button
type="button"
onClick={() => {
setIsHistoryFormOpen(false)
setEditingHistoryId(null)
setHistoryFormError(null)
}}
className="text-xs text-muted hover:text-foreground"
>
Cancelar
</button>
</div>
{historyFormError && (
<div className="p-2.5 rounded-lg bg-rose-500/10 border border-rose-500/25 text-rose-300 text-xs flex items-center gap-1.5">
<AlertCircle className="w-3.5 h-3.5 shrink-0" />
<span>{historyFormError}</span>
</div>
)}
{/* Start Date & Time */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
<div>
<label className="block text-[11px] font-semibold text-foreground mb-1">
Data de Início <span className="text-brand">*</span>
</label>
<input
type="date"
required
value={histStartDate}
onChange={(e) => setHistStartDate(e.target.value)}
className="w-full px-2.5 py-1.5 rounded-lg border border-border bg-surface-raised text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-brand"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-foreground mb-1">
Hora de Início <span className="text-brand">*</span>
</label>
<input
type="time"
required
value={histStartTime}
onChange={(e) => setHistStartTime(e.target.value)}
className="w-full px-2.5 py-1.5 rounded-lg border border-border bg-surface-raised text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-brand"
/>
</div>
</div>
{/* Ongoing toggle */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="hist-ongoing-toggle"
checked={histIsOngoing}
onChange={(e) => setHistIsOngoing(e.target.checked)}
className="w-4 h-4 rounded text-brand border-border focus:ring-brand"
/>
<label
htmlFor="hist-ongoing-toggle"
className="text-xs font-medium text-foreground cursor-pointer select-none"
>
Atividade em andamento (cronômetro em aberto)
</label>
</div>
{/* End Date & Time */}
{!histIsOngoing && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
<div>
<label className="block text-[11px] font-semibold text-foreground mb-1">
Data de Término <span className="text-brand">*</span>
</label>
<input
type="date"
value={histEndDate}
onChange={(e) => setHistEndDate(e.target.value)}
className="w-full px-2.5 py-1.5 rounded-lg border border-border bg-surface-raised text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-brand"
/>
</div>
<div>
<label className="block text-[11px] font-semibold text-foreground mb-1">
Hora de Término <span className="text-brand">*</span>
</label>
<input
type="time"
value={histEndTime}
onChange={(e) => setHistEndTime(e.target.value)}
className="w-full px-2.5 py-1.5 rounded-lg border border-border bg-surface-raised text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-brand"
/>
</div>
</div>
)}
{historyFormPreview && (
<div className="px-2.5 py-1 rounded-lg bg-surface-raised border border-border text-[11px] text-muted flex items-center justify-between">
<span>Duração Estimada:</span>
<span className="font-semibold text-brand font-mono">
{historyFormPreview}
</span>
</div>
)}
<div className="flex items-center justify-end gap-2 pt-1">
<button
type="button"
onClick={() => {
setIsHistoryFormOpen(false)
setEditingHistoryId(null)
setHistoryFormError(null)
}}
className="px-2.5 py-1 rounded-lg border border-border bg-surface-raised text-xs text-muted hover:text-foreground"
>
Cancelar
</button>
<button
type="button"
onClick={handleSaveHistoryItemLocally}
className="px-3 py-1 rounded-lg bg-brand text-white text-xs font-semibold hover:bg-brand-hover shadow-xs flex items-center gap-1"
>
<Check className="w-3.5 h-3.5" />
<span>{editingHistoryId !== null ? 'Atualizar Linha' : 'Inserir Registro'}</span>
</button>
</div>
</div>
)}
{/* History Items Table */}
{localHistory.length === 0 ? (
<div className="py-8 text-center rounded-xl border border-dashed border-border bg-surface/40 p-4">
<Clock className="w-7 h-7 text-muted/50 mx-auto mb-1.5" />
<p className="text-xs font-semibold text-foreground">
Nenhum registro de tempo nesta tarefa
</p>
<p className="text-[11px] text-muted mt-0.5">
Clique em "+ Novo Registro" para adicionar horários trabalhados.
</p>
</div>
) : (
<div className="rounded-xl border border-border bg-surface overflow-hidden divide-y divide-border/60 shadow-xs max-h-60 overflow-y-auto">
{localHistory.map((item, idx) => {
const isRunning = !item.endAt
const durationText = item.elapsedSeconds
? formatSecondsToHuman(item.elapsedSeconds)
: isRunning
? 'Contando...'
: '0m'
return (
<div
key={item.id || idx}
className={`p-2.5 sm:px-3 flex items-center justify-between gap-2 transition-colors ${
isRunning ? 'bg-teal-500/5' : 'hover:bg-surface-raised/40'
}`}
>
<div className="flex items-center gap-2.5">
<div className="w-6 h-6 rounded-md bg-surface-raised border border-border flex items-center justify-center text-muted shrink-0">
<Calendar className="w-3 h-3" />
</div>
<div>
<div className="text-xs font-semibold text-foreground flex items-center gap-1.5">
<span>{formatIsoDateOnly(item.startAt)}</span>
{isRunning && (
<span className="inline-flex items-center gap-1 px-1.5 py-0.2 rounded-full text-[9px] font-bold bg-teal-500/15 text-teal-600 dark:text-teal-400 border border-teal-500/30">
<span className="w-1.5 h-1.5 rounded-full bg-teal-400 animate-pulse" />
AGORA
</span>
)}
</div>
<div className="text-[11px] text-muted font-mono flex items-center gap-1">
<span>{formatIsoTimeOnly(item.startAt)}</span>
<span>→</span>
<span>{item.endAt ? formatIsoTimeOnly(item.endAt) : 'Em aberto'}</span>
</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[11px] font-mono font-medium border bg-surface-raised text-foreground border-border">
<Clock className="w-3 h-3 text-muted" />
{durationText}
</span>
<button
type="button"
onClick={() => handleOpenEditHistory(item)}
title="Editar este registro"
className="p-1 rounded-md text-muted hover:text-foreground hover:bg-surface-raised transition-colors"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => handleDeleteHistoryLocally(item.id)}
title="Remover este registro"
className="p-1 rounded-md text-muted hover:text-rose-400 hover:bg-rose-500/10 transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
)
})}
</div>
)}
</div>
)}
{/* Footer Controls */}
<div className="pt-4 border-t border-border flex items-center justify-between gap-2.5">
<div className="text-[11px] text-muted">
{initialTask && (
<span>
Tempo total: <strong className="text-foreground font-mono">{formatSecondsToHuman(totalCalculatedSeconds)}</strong>
</span>
)}
</div>
<div className="flex items-center gap-2.5">
<button
type="button"
onClick={onClose}
disabled={submitting}
className="px-4 py-2 rounded-xl border border-border bg-surface text-xs font-medium text-foreground hover:bg-surface-raised transition-colors"
>
Cancelar
</button>
<button
type="submit"
disabled={submitting || loading}
className="px-5 py-2 rounded-xl bg-brand text-white text-xs font-semibold hover:bg-brand-hover shadow-xs transition-all flex items-center gap-2 disabled:opacity-50"
>
{submitting && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
{initialTask ? 'Salvar Alterações' : 'Criar Tarefa'}
</button>
</div>
</div>
</form>
</div>
</div>
)
}

View file

@ -1,41 +1,22 @@
import React from 'react' import React from 'react'
import { Play, Square, Clock, Folder, Tag, MoreVertical, CheckCircle2, CircleDot } from 'lucide-react' import { Play, Square, Clock, Folder, Tag, MoreVertical, CircleDot, ExternalLink } from 'lucide-react'
import type { Task, TaskStatus } from '../../types/task' import type { Task } from '../../types/task'
import { isTaskRunning, calculateTaskTotalSeconds, formatSecondsToTimer } from '../../lib/timeUtils' import { isTaskRunning, calculateTaskTotalSeconds, formatSecondsToTimer } from '../../lib/timeUtils'
interface TaskCardProps { interface TaskCardProps {
task: Task task: Task
onToggleTimer: (task: Task) => void onToggleTimer: (task: Task) => void
onStatusChange: (task: Task, newStatus: TaskStatus) => void
onEdit?: (task: Task) => void onEdit?: (task: Task) => void
onOpenHistory?: (task: Task) => void
isTrackingThis?: boolean isTrackingThis?: boolean
currentSeconds?: number currentSeconds?: number
} }
const statusConfig: Record<TaskStatus, { label: string; badgeClass: string }> = {
NOT_STARTED: {
label: 'Não Iniciado',
badgeClass: 'bg-surface-sunken text-muted border-border-soft',
},
IN_PROGRESS: {
label: 'Em Andamento',
badgeClass: 'bg-teal-500/15 text-teal-300 border-teal-500/30',
},
COMPLETED: {
label: 'Concluído',
badgeClass: 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30',
},
CANCELED: {
label: 'Cancelado',
badgeClass: 'bg-rose-500/15 text-rose-300 border-rose-500/30',
},
}
export const TaskCard: React.FC<TaskCardProps> = ({ export const TaskCard: React.FC<TaskCardProps> = ({
task, task,
onToggleTimer, onToggleTimer,
onStatusChange,
onEdit, onEdit,
onOpenHistory,
isTrackingThis, isTrackingThis,
currentSeconds, currentSeconds,
}) => { }) => {
@ -92,58 +73,48 @@ export const TaskCard: React.FC<TaskCardProps> = ({
{task.category.name} {task.category.name}
</span> </span>
)} )}
{task.externalUrl && (
<a
href={task.externalUrl.startsWith('http') ? task.externalUrl : `https://${task.externalUrl}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
title={task.externalUrl}
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-[11px] font-medium border border-border bg-surface-raised text-muted hover:text-brand hover:border-brand/40 transition-colors cursor-pointer"
>
<ExternalLink className="w-3 h-3 text-brand" />
<span>Link</span>
</a>
)}
</div> </div>
{/* More Actions Menu */} {/* More Actions Menu */}
<div className="relative"> {(onEdit || onOpenHistory) && (
<button <div className="relative">
type="button" <button
onClick={(e) => { type="button"
e.stopPropagation() onClick={(e) => {
setMenuOpen(!menuOpen) e.stopPropagation()
}} setMenuOpen(!menuOpen)
className="p-1 rounded-md text-muted hover:text-foreground hover:bg-surface-raised transition-colors" }}
title="Ações" className="p-1 rounded-md text-muted hover:text-foreground hover:bg-surface-raised transition-colors"
> title="Ações"
<MoreVertical className="w-4 h-4" /> >
</button> <MoreVertical className="w-4 h-4" />
</button>
{menuOpen && ( {menuOpen && (
<> <>
<div <div
className="fixed inset-0 z-40" className="fixed inset-0 z-40"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
setMenuOpen(false) setMenuOpen(false)
}} }}
/> />
<div className="absolute right-0 top-6 z-50 w-44 rounded-xl border border-border bg-surface-raised p-1.5 shadow-xl"> <div className="absolute right-0 top-6 z-50 w-44 rounded-xl border border-border bg-surface-raised p-1.5 shadow-xl">
<div className="px-2 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted"> {onEdit && (
Alterar Status
</div>
{(['NOT_STARTED', 'IN_PROGRESS', 'COMPLETED', 'CANCELED'] as TaskStatus[]).map((st) => (
<button
key={st}
type="button"
onClick={(e) => {
e.stopPropagation()
onStatusChange(task, st)
setMenuOpen(false)
}}
className={`flex items-center justify-between w-full px-2 py-1.5 rounded-lg text-xs text-left transition-colors ${
task.status === st
? 'bg-brand/15 text-brand font-medium'
: 'text-foreground/80 hover:bg-surface-sunken hover:text-foreground'
}`}
>
<span>{statusConfig[st].label}</span>
{task.status === st && <CheckCircle2 className="w-3.5 h-3.5 text-brand" />}
</button>
))}
{onEdit && (
<>
<div className="my-1 border-t border-border" />
<button <button
type="button" type="button"
onClick={(e) => { onClick={(e) => {
@ -155,12 +126,27 @@ export const TaskCard: React.FC<TaskCardProps> = ({
> >
Editar Detalhes Editar Detalhes
</button> </button>
</> )}
)}
</div> {onOpenHistory && (
</> <button
)} type="button"
</div> onClick={(e) => {
e.stopPropagation()
onOpenHistory(task)
setMenuOpen(false)
}}
className="w-full flex items-center justify-between px-2 py-1.5 rounded-lg text-xs text-left text-foreground/80 hover:bg-surface-sunken hover:text-foreground transition-colors"
>
<span>Histórico de Tempo</span>
<Clock className="w-3.5 h-3.5 text-muted" />
</button>
)}
</div>
</>
)}
</div>
)}
</div> </div>
{/* Title */} {/* Title */}
@ -205,13 +191,18 @@ export const TaskCard: React.FC<TaskCardProps> = ({
{/* Timer button & display */} {/* Timer button & display */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <div
className={`flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-mono transition-colors ${ onClick={(e) => {
e.stopPropagation()
onOpenHistory?.(task)
}}
title="Clique para ver o histórico de tempo"
className={`flex items-center gap-1 px-2 py-1 rounded-md text-[11px] font-mono transition-colors cursor-pointer hover:opacity-85 ${
running running
? 'bg-brand/20 text-teal-300 font-semibold' ? 'bg-teal-500/15 text-teal-700 dark:bg-brand/20 dark:text-teal-300 font-semibold'
: 'bg-surface-sunken text-muted' : 'bg-surface-sunken text-muted'
}`} }`}
> >
<Clock className={`w-3 h-3 ${running ? 'animate-pulse text-brand' : ''}`} /> <Clock className={`w-3 h-3 ${running ? 'animate-pulse text-teal-700 dark:text-brand' : ''}`} />
<span>{formatSecondsToTimer(totalSeconds)}</span> <span>{formatSecondsToTimer(totalSeconds)}</span>
</div> </div>
@ -225,13 +216,13 @@ export const TaskCard: React.FC<TaskCardProps> = ({
className={`p-1.5 rounded-lg transition-all duration-200 ${ className={`p-1.5 rounded-lg transition-all duration-200 ${
running running
? 'bg-rose-500/20 text-rose-300 hover:bg-rose-500/30 ring-1 ring-rose-500/40' ? 'bg-rose-500/20 text-rose-300 hover:bg-rose-500/30 ring-1 ring-rose-500/40'
: 'bg-brand/15 text-teal-300 hover:bg-brand/25 ring-1 ring-brand/30' : 'bg-teal-500/10 text-teal-700 hover:bg-teal-500/20 dark:bg-brand/15 dark:text-teal-300 dark:hover:bg-brand/25 ring-1 ring-teal-600/20 dark:ring-brand/30'
}`} }`}
> >
{running ? ( {running ? (
<Square className="w-3.5 h-3.5 fill-rose-300" /> <Square className="w-3.5 h-3.5 fill-rose-300" />
) : ( ) : (
<Play className="w-3.5 h-3.5 fill-teal-300" /> <Play className="w-3.5 h-3.5 fill-teal-700 dark:fill-teal-300" />
)} )}
</button> </button>
</div> </div>

View file

@ -0,0 +1,295 @@
import React, { useState } from 'react'
import { Plus, CheckCircle2, Clock, CircleDot, AlertCircle, Ban, Eye, EyeOff, MoreVertical } from 'lucide-react'
import type { Task, TaskStatus } from '../../types/task'
import { TaskCard } from './TaskCard'
import { calculateTaskTotalSeconds, formatSecondsToHuman } from '../../lib/timeUtils'
interface TaskKanbanViewProps {
tasks: Task[]
onToggleTimer: (task: Task) => void
onStatusChange: (task: Task, newStatus: TaskStatus) => void
onEditTask: (task: Task) => void
onOpenHistory?: (task: Task) => void
onAddTaskToStatus: (status: TaskStatus) => void
activeTaskId?: number | null
activeTaskSeconds?: number
hiddenColumns?: TaskStatus[]
showAllColumns?: boolean
onHideColumn?: (status: TaskStatus) => void
onUnhideColumn?: (status: TaskStatus) => void
}
export interface ColumnDef {
status: TaskStatus
label: string
icon: React.ElementType
colorClass: string
bgLightClass: string
borderColor: string
}
export const COLUMNS: ColumnDef[] = [
{
status: 'NOT_STARTED',
label: 'A Fazer',
icon: CircleDot,
colorClass: 'text-muted',
bgLightClass: 'bg-surface-sunken',
borderColor: 'border-border-soft',
},
{
status: 'IN_PROGRESS',
label: 'Em Andamento',
icon: Clock,
colorClass: 'text-teal-700 dark:text-teal-400',
bgLightClass: 'bg-teal-500/10 dark:bg-teal-500/15',
borderColor: 'border-teal-600/25 dark:border-teal-500/20',
},
{
status: 'COMPLETED',
label: 'Concluído',
icon: CheckCircle2,
colorClass: 'text-emerald-700 dark:text-emerald-400',
bgLightClass: 'bg-emerald-500/10 dark:bg-emerald-500/15',
borderColor: 'border-emerald-600/25 dark:border-emerald-500/20',
},
{
status: 'CANCELED',
label: 'Cancelado',
icon: Ban,
colorClass: 'text-rose-700 dark:text-rose-400',
bgLightClass: 'bg-rose-500/10 dark:bg-rose-500/15',
borderColor: 'border-rose-600/25 dark:border-rose-500/20',
},
]
export const TaskKanbanView: React.FC<TaskKanbanViewProps> = ({
tasks,
onToggleTimer,
onStatusChange,
onEditTask,
onOpenHistory,
onAddTaskToStatus,
activeTaskId,
activeTaskSeconds,
hiddenColumns = [],
showAllColumns = false,
onHideColumn,
onUnhideColumn,
}) => {
const [dragOverColumn, setDragOverColumn] = useState<TaskStatus | null>(null)
const [columnMenuOpen, setColumnMenuOpen] = useState<TaskStatus | null>(null)
// Visible columns based on hidden state unless showAllColumns is active
const visibleColumns = COLUMNS.filter((col) => {
if (showAllColumns) return true
return !hiddenColumns.includes(col.status)
})
const handleDragOver = (e: React.DragEvent, status: TaskStatus) => {
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
if (dragOverColumn !== status) {
setDragOverColumn(status)
}
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
setDragOverColumn(null)
}
const handleDrop = (e: React.DragEvent, targetStatus: TaskStatus) => {
e.preventDefault()
setDragOverColumn(null)
const taskIdStr = e.dataTransfer.getData('text/plain')
if (!taskIdStr) return
const taskId = Number(taskIdStr)
const targetTask = tasks.find((t) => t.id === taskId)
if (targetTask && targetTask.status !== targetStatus) {
onStatusChange(targetTask, targetStatus)
}
}
const getGridColsClass = (count: number) => {
if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'grid-cols-1 md:grid-cols-2'
if (count === 3) return 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3'
return 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4'
}
if (visibleColumns.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-16 px-4 text-center rounded-3xl border border-dashed border-border-soft bg-surface/50 backdrop-blur-md">
<EyeOff className="w-10 h-10 text-muted/60 mb-3" />
<h3 className="text-sm font-semibold text-foreground mb-1">Todas as colunas estão ocultas</h3>
<p className="text-xs text-muted mb-4 max-w-sm">
Você ocultou todas as colunas do quadro. Ative a exibição de colunas ocultas ou reative as colunas desejadas.
</p>
</div>
)
}
return (
<div className={`grid ${getGridColsClass(visibleColumns.length)} gap-4 items-start pb-6`}>
{visibleColumns.map((col) => {
const columnTasks = tasks.filter((t) => t.status === col.status)
const isHidden = hiddenColumns.includes(col.status)
const colTotalSeconds = columnTasks.reduce(
(acc, t) =>
acc +
(t.id === activeTaskId && activeTaskSeconds !== undefined
? activeTaskSeconds
: calculateTaskTotalSeconds(t)),
0
)
const Icon = col.icon
const isOver = dragOverColumn === col.status
const isMenuOpen = columnMenuOpen === col.status
return (
<div
key={col.status}
onDragOver={(e) => handleDragOver(e, col.status)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, col.status)}
className={`flex flex-col rounded-3xl border transition-all duration-200 h-[680px] lg:h-[calc(100vh-270px)] lg:min-h-[520px] overflow-hidden bg-surface/60 backdrop-blur-md shadow-xs ${
isHidden ? 'opacity-70 ring-1 ring-amber-500/30' : ''
} ${
isOver
? 'border-brand ring-2 ring-brand/30 bg-brand/5'
: col.borderColor
}`}
>
{/* Column Header */}
<div className="shrink-0 flex items-center justify-between p-3.5 border-b border-border-soft bg-surface/40">
<div className="flex items-center gap-2 min-w-0">
<div
className={`w-7 h-7 rounded-lg ${col.bgLightClass} flex items-center justify-center shrink-0 ${col.colorClass}`}
>
<Icon className="w-4 h-4" />
</div>
<div className="min-w-0">
<h3 className="font-semibold text-xs tracking-tight text-foreground flex items-center gap-1.5 truncate">
<span>{col.label}</span>
<span className="px-1.5 py-0.5 rounded-full text-[10px] font-bold bg-surface-raised border border-border-soft text-muted shrink-0">
{columnTasks.length}
</span>
{isHidden && (
<span className="px-1.5 py-0.2 rounded-full text-[9px] font-bold bg-amber-500/15 text-amber-500 border border-amber-500/30 shrink-0">
Oculta
</span>
)}
</h3>
{colTotalSeconds > 0 && (
<span className="text-[10px] text-muted font-mono truncate block">
{formatSecondsToHuman(colTotalSeconds)} investidos
</span>
)}
</div>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={() => onAddTaskToStatus(col.status)}
title={`Adicionar tarefa em ${col.label}`}
className="w-7 h-7 rounded-lg border border-border-soft bg-surface flex items-center justify-center text-muted hover:text-foreground hover:bg-surface-raised transition-colors"
>
<Plus className="w-3.5 h-3.5" />
</button>
{/* Column options menu (hide/unhide) */}
<div className="relative">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setColumnMenuOpen(isMenuOpen ? null : col.status)
}}
title="Opções da coluna"
className="w-7 h-7 rounded-lg border border-border-soft bg-surface flex items-center justify-center text-muted hover:text-foreground hover:bg-surface-raised transition-colors"
>
<MoreVertical className="w-3.5 h-3.5" />
</button>
{isMenuOpen && (
<>
<div
className="fixed inset-0 z-40"
onClick={(e) => {
e.stopPropagation()
setColumnMenuOpen(null)
}}
/>
<div className="absolute right-0 top-8 z-50 w-44 rounded-xl border border-border bg-surface-raised p-1 shadow-xl text-left">
{isHidden ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onUnhideColumn?.(col.status)
setColumnMenuOpen(null)
}}
className="w-full flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-xs text-foreground hover:bg-surface-sunken transition-colors"
>
<Eye className="w-3.5 h-3.5 text-brand" />
<span>Reexibir coluna</span>
</button>
) : (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onHideColumn?.(col.status)
setColumnMenuOpen(null)
}}
className="w-full flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-xs text-rose-400 hover:bg-surface-sunken transition-colors"
>
<EyeOff className="w-3.5 h-3.5" />
<span>Ocultar coluna</span>
</button>
)}
</div>
</>
)}
</div>
</div>
</div>
{/* Column Body / Cards List */}
<div className="flex-1 p-3 flex flex-col gap-3 min-h-0 overflow-y-auto custom-scrollbar">
{columnTasks.map((task) => (
<TaskCard
key={task.id}
task={task}
onToggleTimer={onToggleTimer}
onEdit={onEditTask}
onOpenHistory={onOpenHistory}
isTrackingThis={activeTaskId === task.id}
currentSeconds={activeTaskId === task.id ? activeTaskSeconds : undefined}
/>
))}
{columnTasks.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 px-4 text-center rounded-xl border border-dashed border-border-soft">
<AlertCircle className="w-6 h-6 text-muted/60 mb-2" />
<p className="text-xs text-muted">Nenhuma tarefa nesta coluna</p>
<button
type="button"
onClick={() => onAddTaskToStatus(col.status)}
className="mt-2 text-xs font-medium text-brand hover:underline inline-flex items-center gap-1"
>
<Plus className="w-3 h-3" />
Criar tarefa
</button>
</div>
)}
</div>
</div>
)
})}
</div>
)
}

View file

@ -1,5 +1,5 @@
import React from 'react' import React from 'react'
import { Play, Square, Clock, Folder, Tag, MoreHorizontal, CheckCircle2 } from 'lucide-react' import { Play, Square, Clock, Folder, Tag, MoreHorizontal, CheckCircle2, ExternalLink } from 'lucide-react'
import type { Task, TaskStatus } from '../../types/task' import type { Task, TaskStatus } from '../../types/task'
import { isTaskRunning, calculateTaskTotalSeconds, formatSecondsToTimer } from '../../lib/timeUtils' import { isTaskRunning, calculateTaskTotalSeconds, formatSecondsToTimer } from '../../lib/timeUtils'
import { CustomSelect } from '../ui/Select' import { CustomSelect } from '../ui/Select'
@ -9,6 +9,7 @@ interface TaskListViewProps {
onToggleTimer: (task: Task) => void onToggleTimer: (task: Task) => void
onStatusChange: (task: Task, newStatus: TaskStatus) => void onStatusChange: (task: Task, newStatus: TaskStatus) => void
onEditTask: (task: Task) => void onEditTask: (task: Task) => void
onOpenHistory?: (task: Task) => void
activeTaskId?: number | null activeTaskId?: number | null
activeTaskSeconds?: number activeTaskSeconds?: number
} }
@ -19,6 +20,7 @@ export const TaskListView: React.FC<TaskListViewProps> = ({
onToggleTimer, onToggleTimer,
onStatusChange, onStatusChange,
onEditTask, onEditTask,
onOpenHistory,
activeTaskId, activeTaskId,
activeTaskSeconds, activeTaskSeconds,
}) => { }) => {
@ -58,12 +60,26 @@ export const TaskListView: React.FC<TaskListViewProps> = ({
{/* Task Title & Description */} {/* Task Title & Description */}
<td className="py-3.5 px-4 max-w-xs"> <td className="py-3.5 px-4 max-w-xs">
<div className="flex flex-col"> <div className="flex flex-col">
<span <div className="flex items-center gap-1.5 min-w-0">
onClick={() => onEditTask(task)} <span
className="font-medium text-foreground hover:text-brand cursor-pointer transition-colors truncate" onClick={() => onEditTask(task)}
> className="font-medium text-foreground hover:text-brand cursor-pointer transition-colors truncate"
{task.title} >
</span> {task.title}
</span>
{task.externalUrl && (
<a
href={task.externalUrl.startsWith('http') ? task.externalUrl : `https://${task.externalUrl}`}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
title={`Link externo: ${task.externalUrl}`}
className="inline-flex items-center text-muted hover:text-brand transition-colors flex-shrink-0 cursor-pointer"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
</div>
{task.description && ( {task.description && (
<span className="text-muted text-[11px] truncate max-w-sm"> <span className="text-muted text-[11px] truncate max-w-sm">
{task.description} {task.description}
@ -146,13 +162,15 @@ export const TaskListView: React.FC<TaskListViewProps> = ({
{/* Time Spent */} {/* Time Spent */}
<td className="py-3.5 px-4 whitespace-nowrap"> <td className="py-3.5 px-4 whitespace-nowrap">
<div <div
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg font-mono text-[11px] ${ onClick={() => onOpenHistory?.(task)}
title="Clique para ver o histórico de tempo"
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg font-mono text-[11px] cursor-pointer hover:opacity-85 transition-opacity ${
isRunning isRunning
? 'bg-brand/20 text-teal-300 font-bold' ? 'bg-teal-500/15 text-teal-700 dark:bg-brand/20 dark:text-teal-300 font-bold'
: 'bg-surface-sunken text-muted' : 'bg-surface-sunken text-muted'
}`} }`}
> >
<Clock className={`w-3.5 h-3.5 ${isRunning ? 'animate-pulse text-brand' : ''}`} /> <Clock className={`w-3.5 h-3.5 ${isRunning ? 'animate-pulse text-teal-700 dark:text-brand' : ''}`} />
<span>{formatSecondsToTimer(totalSec)}</span> <span>{formatSecondsToTimer(totalSec)}</span>
</div> </div>
</td> </td>
@ -168,13 +186,13 @@ export const TaskListView: React.FC<TaskListViewProps> = ({
className={`p-1.5 rounded-lg transition-colors ${ className={`p-1.5 rounded-lg transition-colors ${
isRunning isRunning
? 'bg-rose-500/20 text-rose-300 hover:bg-rose-500/30' ? 'bg-rose-500/20 text-rose-300 hover:bg-rose-500/30'
: 'bg-brand/15 text-teal-300 hover:bg-brand/25' : 'bg-teal-500/10 text-teal-700 hover:bg-teal-500/20 dark:bg-brand/15 dark:text-teal-300 dark:hover:bg-brand/25'
}`} }`}
> >
{isRunning ? ( {isRunning ? (
<Square className="w-3.5 h-3.5 fill-rose-300" /> <Square className="w-3.5 h-3.5 fill-rose-300" />
) : ( ) : (
<Play className="w-3.5 h-3.5 fill-teal-300" /> <Play className="w-3.5 h-3.5 fill-teal-700 dark:fill-teal-300" />
)} )}
</button> </button>
@ -194,7 +212,7 @@ export const TaskListView: React.FC<TaskListViewProps> = ({
className="fixed inset-0 z-40" className="fixed inset-0 z-40"
onClick={() => setActiveMenuId(null)} onClick={() => setActiveMenuId(null)}
/> />
<div className="absolute right-0 top-7 z-50 w-36 rounded-xl border border-border bg-surface-raised p-1 shadow-xl text-left"> <div className="absolute right-0 top-7 z-50 w-40 rounded-xl border border-border bg-surface-raised p-1 shadow-xl text-left">
<button <button
type="button" type="button"
onClick={() => { onClick={() => {
@ -205,6 +223,19 @@ export const TaskListView: React.FC<TaskListViewProps> = ({
> >
Editar Detalhes Editar Detalhes
</button> </button>
{onOpenHistory && (
<button
type="button"
onClick={() => {
onOpenHistory(task)
setActiveMenuId(null)
}}
className="w-full flex items-center justify-between px-2.5 py-1.5 rounded-lg text-xs text-foreground hover:bg-surface-sunken transition-colors"
>
<span>Histórico de Tempo</span>
<Clock className="w-3.5 h-3.5 text-muted" />
</button>
)}
<button <button
type="button" type="button"
onClick={() => { onClick={() => {

View file

@ -1,157 +1,162 @@
import React, { import {
createContext, createContext,
useContext, useContext,
useState, useState,
useEffect, useEffect,
useCallback, useCallback,
} from "react"; useMemo,
import { authService, type LoginResponse } from "../services/authService"; type ReactNode,
} from 'react'
import {
authService,
type LoginResponse,
type CreateAccountResponse,
type OrganizationMembership,
} from '../services/authService'
interface AuthContextType { interface AuthContextType {
user: LoginResponse | null; user: LoginResponse | null
isAuthenticated: boolean; isAuthenticated: boolean
isLoading: boolean; isLoading: boolean
activeOrg: LoginResponse["organizations"][number] | null; activeOrg: OrganizationMembership | null
login: (email: string, password: string) => Promise<LoginResponse>; login: (email: string, password: string) => Promise<LoginResponse>
logout: () => void; register: (name: string, email: string, password: string) => Promise<CreateAccountResponse>
refreshUser: () => Promise<void>; logout: () => void
setActiveOrg: (id: number) => void; refreshUser: () => Promise<void>
setActiveOrg: (id: number) => void
} }
const STORAGE_KEYS = { const STORAGE_KEYS = {
USER: "user", USER: 'user',
ACTIVE_ORG_ID: "activeOrgId", ACTIVE_ORG_ID: 'activeOrgId',
} as const; } as const
const AuthContext = createContext<AuthContextType | undefined>(undefined); const AuthContext = createContext<AuthContextType | undefined>(undefined)
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ export const AuthProvider = ({ children }: { children: ReactNode }) => {
children, const [user, setUser] = useState<LoginResponse | null>(null)
}) => { const [activeOrgId, setActiveOrgId] = useState<number | null>(null)
const [user, setUser] = useState<LoginResponse | null>(null); const [isLoading, setIsLoading] = useState(true)
const [activeOrgId, setActiveOrgId] = useState<number | null>(null);
const [isLoading, setIsLoading] = useState(true); const setupActiveOrg = useCallback((organizations: OrganizationMembership[]) => {
const storedOrgId = localStorage.getItem(STORAGE_KEYS.ACTIVE_ORG_ID)
const setupActiveOrg = useCallback((organizations: LoginResponse["organizations"]) => { const parsedStoredId = storedOrgId ? Number(storedOrgId) : null
const storedOrgId = localStorage.getItem(STORAGE_KEYS.ACTIVE_ORG_ID);
const parsedStoredId = storedOrgId ? Number(storedOrgId) : null; const isValidOrg = organizations.some((org) => org.id === parsedStoredId)
const isValidOrg = organizations.some(org => org.id === parsedStoredId);
if (isValidOrg && parsedStoredId !== null) { if (isValidOrg && parsedStoredId !== null) {
setActiveOrgId(parsedStoredId); setActiveOrgId(parsedStoredId)
} else if (organizations.length > 0) { } else if (organizations.length > 0) {
const firstOrgId = organizations[0].id; const firstOrgId = organizations[0].id
setActiveOrgId(firstOrgId); setActiveOrgId(firstOrgId)
localStorage.setItem(STORAGE_KEYS.ACTIVE_ORG_ID, firstOrgId.toString()); localStorage.setItem(STORAGE_KEYS.ACTIVE_ORG_ID, firstOrgId.toString())
} }
}, []); }, [])
const logout = useCallback(() => { const logout = useCallback(() => {
localStorage.removeItem(STORAGE_KEYS.USER); localStorage.removeItem(STORAGE_KEYS.USER)
setUser(null); localStorage.removeItem(STORAGE_KEYS.ACTIVE_ORG_ID)
window.location.href = "/login"; setUser(null)
}, []); setActiveOrgId(null)
}, [])
const renewToken = useCallback(async (refreshToken: string) => {
try {
const response = await authService.refresh(refreshToken)
response.issuedAt = new Date().toISOString()
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(response))
setUser(response)
} catch {
logout()
}
}, [logout])
const checkTokenExpiration = useCallback( const checkTokenExpiration = useCallback(
(userData: LoginResponse) => { (userData: LoginResponse) => {
const issuedAt = new Date(userData.issuedAt).getTime(); const issuedAt = new Date(userData.issuedAt).getTime()
const expiresAt = issuedAt + userData.expiresIn * 1000; const expiresAt = issuedAt + userData.expiresIn * 1000
const now = Date.now(); const now = Date.now()
// Se já expirou
if (now >= expiresAt) { if (now >= expiresAt) {
logout(); logout()
return false; return false
} }
// Se falta menos de 5 minutos para vencer, tenta renovar // Se expirar em menos de 5 min, renova
const timeToExpire = expiresAt - now; if (expiresAt - now < 300000) {
if (timeToExpire < 300000) { renewToken(userData.refreshToken)
// 5 min
renewToken(userData.refreshToken);
} }
return true; return true
}, },
[logout], [logout, renewToken]
); )
const renewToken = async (refreshToken: string) => {
try {
const response = await authService.refresh(refreshToken);
response.issuedAt = new Date().toISOString();
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(response));
setUser(response);
} catch (error) {
console.error("Failed to renew token", error);
logout();
}
};
useEffect(() => { useEffect(() => {
const storedUser = localStorage.getItem(STORAGE_KEYS.USER); const storedUser = localStorage.getItem(STORAGE_KEYS.USER)
if (storedUser) { if (storedUser) {
try { try {
const userData: LoginResponse = JSON.parse(storedUser); const userData: LoginResponse = JSON.parse(storedUser)
if (checkTokenExpiration(userData)) { if (checkTokenExpiration(userData)) {
setUser(userData); setUser(userData)
setupActiveOrg(userData.organizations); setupActiveOrg(userData.organizations)
} }
} catch (e) { } catch {
localStorage.removeItem(STORAGE_KEYS.USER); localStorage.removeItem(STORAGE_KEYS.USER)
} }
} }
setIsLoading(false); setIsLoading(false)
}, [checkTokenExpiration]); }, [checkTokenExpiration, setupActiveOrg])
// Timer para verificar expiração periodicamente // Verificação periódica a cada 60 segundos
useEffect(() => { useEffect(() => {
if (!user) return; if (!user) return
const interval = setInterval(() => { const interval = setInterval(() => {
checkTokenExpiration(user); checkTokenExpiration(user)
}, 60000); // Verifica a cada minuto }, 60000)
return () => clearInterval(interval); return () => clearInterval(interval)
}, [user, checkTokenExpiration]); }, [user, checkTokenExpiration])
const login = async (email: string, password: string) => { const login = async (email: string, password: string) => {
const response = await authService.login(email, password); const response = await authService.login(email, password)
response.issuedAt = new Date().toISOString(); response.issuedAt = new Date().toISOString()
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(response)); localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(response))
setUser(response); setUser(response)
setupActiveOrg(response.organizations); setupActiveOrg(response.organizations)
return response; return response
}; }
const register = async (name: string, email: string, password: string) => {
return authService.register(name, email, password)
}
const refreshUser = async () => { const refreshUser = async () => {
try { try {
const response = await authService.me(); const response = await authService.me()
const storedUser = localStorage.getItem(STORAGE_KEYS.USER); const storedUser = localStorage.getItem(STORAGE_KEYS.USER)
if (storedUser) { if (storedUser) {
const userData = JSON.parse(storedUser); const userData = JSON.parse(storedUser)
const updatedUser = { ...userData, ...response }; const updatedUser = { ...userData, ...response }
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(updatedUser)); localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(updatedUser))
setUser(updatedUser); setUser(updatedUser)
setupActiveOrg(updatedUser.organizations)
// Update active org if not set or if current active org is gone
setupActiveOrg(updatedUser.organizations);
} }
} catch (error) { } catch {
console.error("Failed to refresh user profile", error); // Ignora erro
} }
}; }
const setActiveOrg = (id: number) => { const setActiveOrg = (id: number) => {
setActiveOrgId(id); setActiveOrgId(id)
localStorage.setItem(STORAGE_KEYS.ACTIVE_ORG_ID, id.toString()); localStorage.setItem(STORAGE_KEYS.ACTIVE_ORG_ID, id.toString())
}; }
const activeOrg = React.useMemo( const activeOrg = useMemo(
() => user?.organizations.find((org) => org.id === activeOrgId) || null, () => user?.organizations.find((org) => org.id === activeOrgId) || null,
[user, activeOrgId], [user, activeOrgId]
); )
return ( return (
<AuthContext.Provider <AuthContext.Provider
@ -161,6 +166,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
isLoading, isLoading,
activeOrg, activeOrg,
login, login,
register,
logout, logout,
refreshUser, refreshUser,
setActiveOrg, setActiveOrg,
@ -168,13 +174,13 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
> >
{children} {children}
</AuthContext.Provider> </AuthContext.Provider>
); )
}; }
export const useAuth = () => { export const useAuth = () => {
const context = useContext(AuthContext); const context = useContext(AuthContext)
if (context === undefined) { if (!context) {
throw new Error("useAuth must be used within an AuthProvider"); throw new Error('useAuth must be used within an AuthProvider')
} }
return context; return context
}; }

View file

@ -1,152 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { taskService } from '../services/taskService'
import type { Project } from '../services/projectService'
import { projectService } from '../services/projectService'
import { categoryService } from '../services/categoryService'
import { organizationService } from '../services/organizationService'
import type { Category } from '../types/category'
import type { TaskAssignee, TaskResponse } from '../types/task'
import { DEFAULT_TASK_FILTERS, type TaskFilters } from '../components/taskFilters'
import { useAuth } from '../contexts/AuthContext'
interface UseTaskPageOptions {
fetchProjects?: boolean
}
interface UseTaskPageResult {
tasks: TaskResponse[]
isLoading: boolean
projects: Project[]
categories: Category[]
assignees: TaskAssignee[]
filters: TaskFilters
currentUserAssigneeId: number | null
loadTasks: (filters: TaskFilters, silent?: boolean) => Promise<void>
handleFiltersChange: (newFilters: TaskFilters) => void
}
function mergeProjects(existing: Project[], tasks: TaskResponse[]) {
const byId = new Map(existing.map((project) => [project.id, project]))
tasks.forEach((task) => {
if (task.project) {
byId.set(task.project.id, {
id: task.project.id,
name: task.project.name,
color: task.project.color,
icon: task.project.icon,
})
}
})
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name))
}
function extractAssignees(tasks: TaskResponse[]) {
const byId = new Map<number, TaskAssignee>()
tasks.forEach((task) => {
if (task.user) {
byId.set(task.user.id, task.user)
}
})
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name))
}
export function useTaskPage({ fetchProjects = false }: UseTaskPageOptions = {}): UseTaskPageResult {
const [tasks, setTasks] = useState<TaskResponse[]>([])
const [isLoading, setIsLoading] = useState(true)
const [projects, setProjects] = useState<Project[]>([])
const [categories, setCategories] = useState<Category[]>([])
const [assignees, setAssignees] = useState<TaskAssignee[]>([])
const [filters, setFilters] = useState<TaskFilters>(DEFAULT_TASK_FILTERS)
const [currentUserAssigneeId, setCurrentUserAssigneeId] = useState<number | null>(null)
const { activeOrg, user } = useAuth()
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const loadTasks = async (currentFilters: TaskFilters, silent = false) => {
if (!activeOrg) return
if (!silent) setIsLoading(true)
try {
const response = await taskService.getTasks({
organizationId: activeOrg.id,
text: currentFilters.searchQuery || undefined,
projectIds: currentFilters.projectIds.length > 0 ? currentFilters.projectIds : undefined,
statuses: currentFilters.statuses.length > 0 ? currentFilters.statuses : undefined,
categoryIds: currentFilters.categoryIds.length > 0 ? currentFilters.categoryIds : undefined,
userIds: currentFilters.userIds.length > 0 ? currentFilters.userIds : undefined,
})
setTasks(response)
setProjects((current) => mergeProjects(current, response))
setAssignees((current) => {
const byId = new Map(current.map((assignee) => [assignee.id, assignee]))
extractAssignees(response).forEach((assignee) => byId.set(assignee.id, assignee))
return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name))
})
} catch (error) {
console.error('Erro ao carregar tarefas', error)
} finally {
setIsLoading(false)
}
}
useEffect(() => {
if (!activeOrg) return
const init = async () => {
setFilters(DEFAULT_TASK_FILTERS)
setCategories([])
const promises: Promise<any>[] = [organizationService.listMembers(activeOrg.id)]
promises.push(categoryService.listByOrganization(activeOrg.id).then(setCategories).catch(() => {}))
if (fetchProjects) {
promises.push(projectService.listByOrganization(activeOrg.id).then(setProjects).catch(() => {}))
}
const [members] = await Promise.all(promises)
const assigneesData = members.map((m: any) => ({
id: m.userId,
name: m.userName,
email: m.userEmail,
photo: m.userPhoto ?? null,
}))
setAssignees(assigneesData)
const currentMember = members.find((m: any) => m.userEmail === user?.email)
const userId = currentMember?.userId ?? null
setCurrentUserAssigneeId(userId)
const initialFilters = userId
? { ...DEFAULT_TASK_FILTERS, userIds: [userId] }
: DEFAULT_TASK_FILTERS
setFilters(initialFilters)
void loadTasks(initialFilters)
}
void init()
}, [activeOrg, user?.email, fetchProjects])
const handleFiltersChange = (newFilters: TaskFilters) => {
setFilters(newFilters)
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => {
void loadTasks(newFilters)
}, 300)
}
return {
tasks,
isLoading,
projects,
categories,
assignees,
filters,
currentUserAssigneeId,
loadTasks,
handleFiltersChange,
}
}

View file

@ -1,36 +1,85 @@
@import 'tailwindcss'; @import "tailwindcss";
@config "../tailwind.config.js";
:root {
--app-bg: #f5f1e8;
--app-bg-strong: #ebe4d7;
--surface: #ffffff;
--surface-muted: #f6efe2;
--surface-raised: #fbf8f1;
--surface-sunken: #ece5d6;
--border: #e2d7c3;
--border-soft: #e7dcc9;
--border-strong: #cdbba0;
--text-main: #1c1917;
--text-muted: #78716c;
--foreground: #1c1917;
--muted: #78716c;
--brand-soft: #ccfbf1;
--accent-soft: #ffedd5;
--shadow-panel: 0 22px 60px rgba(79, 62, 35, 0.08), 0 6px 18px rgba(79, 62, 35, 0.06);
color-scheme: light;
}
.dark {
--app-bg: #090d16;
--app-bg-strong: #0f141f;
--surface: #111726;
--surface-muted: #171e30;
--surface-raised: #182033;
--surface-sunken: #0c111c;
--border: #1e293b;
--border-soft: #1e293b;
--border-strong: #334155;
--text-main: #f8fafc;
--text-muted: #94a3b8;
--foreground: #f8fafc;
--muted: #94a3b8;
--brand-soft: rgba(15, 118, 110, 0.2);
--accent-soft: rgba(217, 119, 6, 0.2);
--shadow-panel: 0 22px 60px rgba(0, 0, 0, 0.4), 0 6px 18px rgba(0, 0, 0, 0.25);
color-scheme: dark;
}
@theme { @theme {
--font-sans: 'Plus Jakarta Sans', ui-sans-serif, system-ui, sans-serif; --font-sans: 'Plus Jakarta Sans', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace; --font-mono: 'JetBrains Mono', ui-monospace, monospace;
--color-app-bg: #f5f1e8; --color-app-bg: var(--app-bg);
--color-app-bg-strong: #ebe4d7; --color-app-bg-strong: var(--app-bg-strong);
--color-surface: #fffdf8; --color-surface: var(--surface);
--color-surface-muted: #f6efe2; --color-surface-muted: var(--surface-muted);
--color-border-soft: #e7dcc9; --color-surface-raised: var(--surface-raised);
--color-border-strong: #cdbba0; --color-surface-sunken: var(--surface-sunken);
--color-text-main: #1f2937; --color-border: var(--border);
--color-text-muted: #6b7280; --color-border-soft: var(--border-soft);
--color-border-strong: var(--border-strong);
--color-text-main: var(--text-main);
--color-text-muted: var(--text-muted);
--color-foreground: var(--foreground);
--color-muted: var(--muted);
--color-brand: #0f766e; --color-brand: #0f766e;
--color-brand-hover: #115e59;
--color-brand-strong: #115e59; --color-brand-strong: #115e59;
--color-brand-soft: #ccfbf1; --color-brand-soft: var(--brand-soft);
--color-accent: #d97706; --color-accent: #d97706;
--color-accent-soft: #ffedd5; --color-accent-hover: #b45309;
--color-accent-soft: var(--accent-soft);
--color-success: #10b981; --color-success: #10b981;
--color-success-soft: #ecfdf5; --color-success-soft: rgba(16, 185, 129, 0.15);
--color-warning: #f59e0b; --color-warning: #f59e0b;
--color-warning-soft: #fffbeb; --color-warning-soft: rgba(245, 158, 11, 0.15);
--color-danger: #ef4444; --color-danger: #ef4444;
--color-danger-soft: #fef2f2; --color-danger-soft: rgba(239, 68, 68, 0.15);
--shadow-panel: --shadow-panel: var(--shadow-panel);
0 22px 60px rgba(79, 62, 35, 0.08), 0 6px 18px rgba(79, 62, 35, 0.06);
} }
:root { :root {
color: var(--color-text-main); color: var(--color-foreground);
background-color: var(--color-app-bg); background-color: var(--color-app-bg);
font-family: var(--font-sans); font-family: var(--font-sans);
font-synthesis: none; font-synthesis: none;
@ -51,8 +100,64 @@ body,
body { body {
margin: 0; margin: 0;
background: transition: background-color 0.2s ease, color 0.2s ease;
radial-gradient(circle at top left, rgba(217, 119, 6, 0.12), transparent 28%),
radial-gradient(circle at top right, rgba(15, 118, 110, 0.14), transparent 24%),
linear-gradient(180deg, var(--color-app-bg) 0%, #f8f5ef 100%);
} }
html.dark body {
background:
radial-gradient(circle at top left, rgba(217, 119, 6, 0.08), transparent 28%),
radial-gradient(circle at top right, rgba(15, 118, 110, 0.12), transparent 24%),
linear-gradient(180deg, var(--app-bg) 0%, #060910 100%);
}
html:not(.dark) body {
background:
radial-gradient(circle at top left, rgba(217, 119, 6, 0.08), transparent 28%),
radial-gradient(circle at top right, rgba(15, 118, 110, 0.08), transparent 24%),
linear-gradient(180deg, var(--app-bg) 0%, #faf7f0 100%);
}
@keyframes jiggle-even {
0% { transform: rotate(-0.6deg) translate(-0.5px, -0.5px); }
50% { transform: rotate(0.6deg) translate(0.5px, 0.5px); }
100% { transform: rotate(-0.6deg) translate(-0.5px, -0.5px); }
}
@keyframes jiggle-odd {
0% { transform: rotate(0.6deg) translate(0.5px, 0.5px); }
50% { transform: rotate(-0.6deg) translate(-0.5px, -0.5px); }
100% { transform: rotate(0.6deg) translate(0.5px, 0.5px); }
}
.jiggle-even {
animation: jiggle-even 0.26s ease-in-out infinite alternate;
}
.jiggle-odd {
animation: jiggle-odd 0.3s ease-in-out infinite alternate;
}
/* Custom sleek scrollbar */
.custom-scrollbar {
scrollbar-width: thin;
scrollbar-color: rgba(148, 163, 184, 0.35) transparent;
}
.custom-scrollbar::-webkit-scrollbar {
width: 5px;
height: 5px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.3);
border-radius: 9999px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(148, 163, 184, 0.5);
}

View file

@ -1,23 +1,29 @@
const API_BASE_URL = (import.meta.env.VITE_API_URL as string | undefined) ?? 'http://localhost:8080' const API_BASE_URL = (import.meta.env.VITE_API_URL as string | undefined) ?? 'http://localhost:8081'
function buildUrl(path: string) { function buildUrl(path: string) {
return `${API_BASE_URL}${path}` return `${API_BASE_URL}${path}`
} }
type RequestOptions = Omit<RequestInit, 'body'> & { type RequestOptions = Omit<RequestInit, 'body'> & {
body?: any body?: unknown
skipAuth?: boolean skipAuth?: boolean
} }
export async function apiFetch<T>(path: string, options: RequestOptions = {}) { export async function apiFetch<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { body, headers, skipAuth, ...rest } = options const { body, headers, skipAuth, ...rest } = options
let authHeader = {} let authHeader: Record<string, string> = {}
if (!skipAuth) { if (!skipAuth) {
const storedUser = localStorage.getItem('user') const storedUser = localStorage.getItem('user')
if (storedUser) { if (storedUser) {
const userData = JSON.parse(storedUser) try {
authHeader = { 'Authorization': `Bearer ${userData.jwt}` } const userData = JSON.parse(storedUser)
if (userData?.jwt) {
authHeader = { Authorization: `Bearer ${userData.jwt}` }
}
} catch {
// Ignora erro de parsing
}
} }
} }
@ -29,14 +35,22 @@ export async function apiFetch<T>(path: string, options: RequestOptions = {}) {
headers: { headers: {
...defaultHeaders, ...defaultHeaders,
...authHeader, ...authHeader,
...headers, ...((headers as Record<string, string>) || {}),
}, },
body: isFormData ? body : (body !== undefined && body !== null ? JSON.stringify(body) : undefined), body: isFormData ? body : (body !== undefined && body !== null ? JSON.stringify(body) : undefined),
}) })
const text = await response.text() const text = await response.text()
if (!response.ok) { if (!response.ok) {
throw new Error(text || 'Nao foi possivel concluir a requisicao.') let errorMessage = text || 'Não foi possível concluir a requisição.'
try {
const parsed = JSON.parse(text)
if (parsed.message) errorMessage = parsed.message
if (parsed.error) errorMessage = parsed.error
} catch {
// Usar texto puro
}
throw new Error(errorMessage)
} }
return text ? (JSON.parse(text) as T) : (undefined as T) return text ? (JSON.parse(text) as T) : (undefined as T)

View file

@ -0,0 +1,135 @@
import type { Task } from '../types/task'
export function isTaskRunning(task: Task): boolean {
if (!task.history || task.history.length === 0) return false
return task.history.some((h) => !h.endAt)
}
export function calculateTaskTotalSeconds(task: Task): number {
if (!task.history || task.history.length === 0) return 0
let total = 0
for (const item of task.history) {
if (item.endAt) {
total += item.elapsedSeconds || 0
} else {
// Running interval: calculate based on start timestamp if available or elapsedSeconds
if (item.startAt) {
// Backend stores UTC LocalDateTime without timezone offset suffix. Append 'Z' to parse as UTC.
const utcDateStr = item.startAt.endsWith('Z') ? item.startAt : `${item.startAt}Z`
const start = new Date(utcDateStr).getTime()
if (!isNaN(start)) {
const diffSec = Math.max(0, Math.floor((Date.now() - start) / 1000))
total += Math.max(diffSec, item.elapsedSeconds || 0)
} else {
total += item.elapsedSeconds || 0
}
} else {
total += item.elapsedSeconds || 0
}
}
}
return total
}
export function calculateBaseSeconds(task: Task): number {
if (!task.history || task.history.length === 0) return 0
let total = 0
for (const item of task.history) {
if (item.elapsedSeconds) {
total += item.elapsedSeconds
}
}
return total
}
export function formatSecondsToTimer(totalSeconds: number): string {
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
const seconds = totalSeconds % 60
const hh = hours.toString().padStart(2, '0')
const mm = minutes.toString().padStart(2, '0')
const ss = seconds.toString().padStart(2, '0')
return `${hh}:${mm}:${ss}`
}
export function formatSecondsToHuman(totalSeconds: number): string {
if (totalSeconds <= 0) return '0m'
const hours = Math.floor(totalSeconds / 3600)
const minutes = Math.floor((totalSeconds % 3600) / 60)
if (hours > 0 && minutes > 0) {
return `${hours}h ${minutes}m`
}
if (hours > 0) {
return `${hours}h`
}
return `${minutes}m`
}
// Parses a backend ISO string (which represents UTC) into a local JavaScript Date object
export function parseUtcToLocalDate(isoString: string): Date | null {
if (!isoString) return null
const utcStr = isoString.endsWith('Z') ? isoString : `${isoString}Z`
const d = new Date(utcStr)
return isNaN(d.getTime()) ? null : d
}
// Formats a backend UTC timestamp into local DD/MM/YYYY
export function formatIsoDateOnly(isoString: string): string {
if (!isoString) return ''
const d = parseUtcToLocalDate(isoString)
if (!d) return ''
const day = String(d.getDate()).padStart(2, '0')
const month = String(d.getMonth() + 1).padStart(2, '0')
const year = d.getFullYear()
return `${day}/${month}/${year}`
}
// Formats a backend UTC timestamp into local HH:mm
export function formatIsoTimeOnly(isoString: string): string {
if (!isoString) return ''
const d = parseUtcToLocalDate(isoString)
if (!d) return ''
const hours = String(d.getHours()).padStart(2, '0')
const minutes = String(d.getMinutes()).padStart(2, '0')
return `${hours}:${minutes}`
}
// Splits a backend UTC ISO string into local Date (YYYY-MM-DD) and local Time (HH:mm) for HTML form inputs
export function splitIsoToDateTime(isoString?: string | null): { date: string; time: string } {
if (!isoString) {
const now = new Date()
const y = now.getFullYear()
const m = String(now.getMonth() + 1).padStart(2, '0')
const d = String(now.getDate()).padStart(2, '0')
const hh = String(now.getHours()).padStart(2, '0')
const mm = String(now.getMinutes()).padStart(2, '0')
return { date: `${y}-${m}-${d}`, time: `${hh}:${mm}` }
}
const d = parseUtcToLocalDate(isoString)
if (!d) {
return { date: '', time: '00:00' }
}
const y = d.getFullYear()
const m = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hh = String(d.getHours()).padStart(2, '0')
const mm = String(d.getMinutes()).padStart(2, '0')
return { date: `${y}-${m}-${day}`, time: `${hh}:${mm}` }
}
// Combines local date and time inputs into a UTC ISO string (without milliseconds) expected by the backend
export function combineDateAndTimeToIso(dateStr: string, timeStr: string): string {
const [year, month, day] = dateStr.split('-').map(Number)
const [hours, minutes] = timeStr.split(':').map(Number)
// Create Date in user's local timezone
const localDate = new Date(year, month - 1, day, hours, minutes, 0, 0)
// Convert to UTC ISO string: e.g. "2026-10-14T13:30:00.000Z" -> "2026-10-14T13:30:00"
return localDate.toISOString().split('.')[0]
}

View file

@ -0,0 +1,120 @@
import type { Task } from '../types/task'
export type WorkGoalPeriod = 'DAILY' | 'WEEKLY' | 'MONTHLY'
export interface WorkGoal {
targetHours: number
period: WorkGoalPeriod
}
const STORAGE_PREFIX = 'beetask_global_work_goal_'
export const DEFAULT_WORK_GOAL: WorkGoal = {
targetHours: 8,
period: 'DAILY',
}
export function getUserWorkGoal(userId?: number | string): WorkGoal {
if (!userId) return DEFAULT_WORK_GOAL
try {
const raw = localStorage.getItem(`${STORAGE_PREFIX}${userId}`)
if (raw) {
const parsed = JSON.parse(raw) as WorkGoal
if (parsed && typeof parsed.targetHours === 'number' && parsed.period) {
return parsed
}
}
} catch (err) {
console.warn('Could not parse user work goal from localStorage', err)
}
return DEFAULT_WORK_GOAL
}
export function saveUserWorkGoal(userId: number | string, goal: WorkGoal): void {
try {
localStorage.setItem(`${STORAGE_PREFIX}${userId}`, JSON.stringify(goal))
// Dispatch custom event to notify other components in same window
window.dispatchEvent(new CustomEvent('beetask_work_goal_changed', { detail: goal }))
} catch (err) {
console.error('Failed to save user work goal to localStorage', err)
}
}
export function isSameDay(d1: Date, d2: Date): boolean {
return (
d1.getFullYear() === d2.getFullYear() &&
d1.getMonth() === d2.getMonth() &&
d1.getDate() === d2.getDate()
)
}
export function isSameWeek(d: Date, now: Date): boolean {
// Start of current week (Monday at 00:00:00)
const startOfWeek = new Date(now)
const day = startOfWeek.getDay()
const diffToMonday = (day === 0 ? -6 : 1) - day
startOfWeek.setDate(startOfWeek.getDate() + diffToMonday)
startOfWeek.setHours(0, 0, 0, 0)
// End of current week (Sunday at 23:59:59)
const endOfWeek = new Date(startOfWeek)
endOfWeek.setDate(endOfWeek.getDate() + 6)
endOfWeek.setHours(23, 59, 59, 999)
return d >= startOfWeek && d <= endOfWeek
}
export function isSameMonth(d: Date, now: Date): boolean {
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth()
}
/**
* Calculates the total seconds worked by the user in the specified goal period (Daily, Weekly, or Monthly).
*/
export function calculateUserPeriodWorkedSeconds(
tasks: Task[],
period: WorkGoalPeriod,
activeTaskId?: number,
activeTaskSeconds?: number
): number {
const now = new Date()
let totalSeconds = 0
for (const task of tasks) {
if (!task.history || task.history.length === 0) continue
for (const item of task.history) {
if (!item.startAt) continue
const utcDateStr = item.startAt.endsWith('Z') ? item.startAt : `${item.startAt}Z`
const startDate = new Date(utcDateStr)
if (isNaN(startDate.getTime())) continue
let inPeriod = false
if (period === 'DAILY') {
inPeriod = isSameDay(startDate, now)
} else if (period === 'WEEKLY') {
inPeriod = isSameWeek(startDate, now)
} else if (period === 'MONTHLY') {
inPeriod = isSameMonth(startDate, now)
}
if (!inPeriod) continue
if (item.endAt) {
totalSeconds += item.elapsedSeconds || 0
} else {
// Active item
if (task.id === activeTaskId && activeTaskSeconds !== undefined) {
// If active task, calculate time elapsed in this running session
const diffSec = Math.max(0, Math.floor((Date.now() - startDate.getTime()) / 1000))
totalSeconds += Math.max(diffSec, item.elapsedSeconds || 0)
} else {
totalSeconds += item.elapsedSeconds || 0
}
}
}
}
return totalSeconds
}

View file

@ -0,0 +1,231 @@
import { authService } from '../services/authService'
import type { TaskStatus } from '../types/task'
export type DashboardWidgetId =
| 'kpis'
| 'focus_session'
| 'work_goal'
| 'recent_tasks'
| 'quick_shortcuts'
export interface DashboardCardConfig {
id: DashboardWidgetId
title: string
description: string
visible: boolean
}
export type BiMetricType =
| 'HOURS_TRACKED'
| 'TASK_COUNT'
| 'COMPLETION_RATE'
| 'PROJECT_BREAKDOWN'
| 'STATUS_DISTRIBUTION'
| 'WORK_GOAL'
| 'ACTIVE_TIMER'
| 'TOP_TASKS'
export type BiPeriodFilter = 'ALL' | 'TODAY' | 'WEEK' | 'MONTH'
export interface DashboardV2Card {
id: string
title: string
metricType: BiMetricType
period: BiPeriodFilter
projectId?: number
categoryId?: number
statusFilter?: 'ALL' | 'COMPLETED' | 'IN_PROGRESS' | 'NOT_STARTED'
colorTheme?: 'brand' | 'accent' | 'emerald' | 'indigo' | 'rose' | 'cyan'
}
export const DEFAULT_V2_CARDS: DashboardV2Card[] = [
{
id: 'card-hours-week',
title: 'Horas Dedicadas (Semana)',
metricType: 'HOURS_TRACKED',
period: 'WEEK',
colorTheme: 'brand',
},
{
id: 'card-completion-rate',
title: 'Taxa de Entrega',
metricType: 'COMPLETION_RATE',
period: 'ALL',
colorTheme: 'emerald',
},
{
id: 'card-tasks-status',
title: 'Status das Demandas',
metricType: 'STATUS_DISTRIBUTION',
period: 'ALL',
colorTheme: 'accent',
},
{
id: 'card-project-breakdown',
title: 'Horas por Projeto',
metricType: 'PROJECT_BREAKDOWN',
period: 'ALL',
colorTheme: 'indigo',
},
{
id: 'card-work-goal',
title: 'Meta de Jornada',
metricType: 'WORK_GOAL',
period: 'TODAY',
colorTheme: 'brand',
},
{
id: 'card-active-timer',
title: 'Cronômetro em Foco',
metricType: 'ACTIVE_TIMER',
period: 'TODAY',
colorTheme: 'cyan',
},
]
export interface UserPreferences {
dashboard?: {
cards: DashboardCardConfig[]
}
dashboardV2?: {
cards: DashboardV2Card[]
}
workGoal?: {
period: 'DAILY' | 'WEEKLY' | 'MONTHLY'
targetHours: number
}
kanban?: {
hiddenColumns?: TaskStatus[]
}
}
export const DEFAULT_DASHBOARD_CARDS: DashboardCardConfig[] = [
{
id: 'kpis',
title: 'Indicadores Principais (KPIs)',
description: 'Métricas de tarefas em andamento, concluídas, tempo e projetos.',
visible: true,
},
{
id: 'focus_session',
title: 'Sessão em Foco',
description: 'Cronômetro em tempo real para foco imediato na tarefa selecionada.',
visible: true,
},
{
id: 'work_goal',
title: 'Meta de Horas (Jornada)',
description: 'Progresso em tempo real da meta configurada (diária, semanal ou mensal).',
visible: true,
},
{
id: 'recent_tasks',
title: 'Tarefas Recentes da Equipe',
description: 'Lista das atividades mais recentes com play/pause e status rápido.',
visible: true,
},
{
id: 'quick_shortcuts',
title: 'Acesso Rápido ao Workspace',
description: 'Atalhos diretos para Projetos e Analytics do workspace.',
visible: true,
},
]
const PREF_STORAGE_PREFIX = 'beetask_user_preferences_'
export function getLocalPreferences(userEmail?: string): UserPreferences {
if (!userEmail) {
return { dashboard: { cards: [...DEFAULT_DASHBOARD_CARDS] } }
}
try {
const raw = localStorage.getItem(`${PREF_STORAGE_PREFIX}${userEmail}`)
if (raw) {
const parsed = JSON.parse(raw) as UserPreferences
return ensureValidPreferences(parsed)
}
} catch (err) {
console.error('Failed to parse local preferences:', err)
}
return { dashboard: { cards: [...DEFAULT_DASHBOARD_CARDS] } }
}
function ensureValidPreferences(prefs: UserPreferences): UserPreferences {
const result: UserPreferences = { ...prefs }
if (!result.dashboard?.cards || !Array.isArray(result.dashboard.cards)) {
result.dashboard = { cards: [...DEFAULT_DASHBOARD_CARDS] }
} else {
// Ensure all default cards exist in user cards list
const existingIds = new Set(result.dashboard.cards.map((c) => c.id))
const missing = DEFAULT_DASHBOARD_CARDS.filter((c) => !existingIds.has(c.id))
if (missing.length > 0) {
result.dashboard.cards = [...result.dashboard.cards, ...missing]
}
}
if (!result.dashboardV2?.cards || !Array.isArray(result.dashboardV2.cards)) {
result.dashboardV2 = { cards: [...DEFAULT_V2_CARDS] }
}
return result
}
export function saveLocalPreferences(userEmail: string, prefs: UserPreferences) {
try {
localStorage.setItem(`${PREF_STORAGE_PREFIX}${userEmail}`, JSON.stringify(prefs))
window.dispatchEvent(
new CustomEvent('beetask_preferences_changed', {
detail: prefs,
})
)
} catch (err) {
console.error('Failed to save preferences locally:', err)
}
}
/**
* Loads preferences from backend, caches in localStorage, and returns them.
*/
export async function syncPreferencesFromBackend(userEmail?: string): Promise<UserPreferences> {
const local = getLocalPreferences(userEmail)
if (!userEmail) return local
try {
const res = await authService.getPreferences()
if (res?.preferences) {
const serverPrefs = JSON.parse(res.preferences) as UserPreferences
const validated = ensureValidPreferences(serverPrefs)
saveLocalPreferences(userEmail, validated)
return validated
} else {
// Backend doesn't have preferences yet, persist local default to backend
await authService.updatePreferences(JSON.stringify(local))
}
} catch (err) {
console.warn('Could not sync preferences from backend, using local:', err)
}
return local
}
/**
* Saves preferences both locally and to the backend database.
*/
export async function savePreferencesToBackend(
userEmail: string,
prefs: UserPreferences
): Promise<UserPreferences> {
const validated = ensureValidPreferences(prefs)
saveLocalPreferences(userEmail, validated)
try {
await authService.updatePreferences(JSON.stringify(validated))
} catch (err) {
console.error('Failed to sync preferences to backend:', err)
}
return validated
}

View file

@ -1,6 +0,0 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View file

@ -1,11 +1,13 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { HeroUIProvider } from '@heroui/system'
import App from './App'
import './index.css' import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <HeroUIProvider>
<App />
</HeroUIProvider>
</StrictMode>, </StrictMode>,
) )

View file

@ -269,7 +269,7 @@ export const AnalyticsPage: React.FC = () => {
<div className="p-6 rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md flex flex-col justify-between shadow-panel hover:-translate-y-0.5 hover:shadow-xl transition-all duration-200"> <div className="p-6 rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md flex flex-col justify-between shadow-panel hover:-translate-y-0.5 hover:shadow-xl transition-all duration-200">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold text-muted">Tempo Total Dedicado</span> <span className="text-xs font-semibold text-muted">Tempo Total Dedicado</span>
<div className="w-9 h-9 rounded-xl bg-teal-500/15 border border-teal-500/30 flex items-center justify-center text-teal-400"> <div className="w-9 h-9 rounded-xl bg-teal-500/15 border border-teal-500/30 flex items-center justify-center text-teal-700 dark:text-teal-400">
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
</div> </div>
</div> </div>
@ -287,12 +287,12 @@ export const AnalyticsPage: React.FC = () => {
<div className="p-6 rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md flex flex-col justify-between shadow-panel hover:-translate-y-0.5 hover:shadow-xl transition-all duration-200"> <div className="p-6 rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md flex flex-col justify-between shadow-panel hover:-translate-y-0.5 hover:shadow-xl transition-all duration-200">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<span className="text-xs font-semibold text-muted">Taxa de Conclusão</span> <span className="text-xs font-semibold text-muted">Taxa de Conclusão</span>
<div className="w-9 h-9 rounded-xl bg-emerald-500/15 border border-emerald-500/30 flex items-center justify-center text-emerald-400"> <div className="w-9 h-9 rounded-xl bg-emerald-500/15 border border-emerald-500/30 flex items-center justify-center text-emerald-700 dark:text-emerald-400">
<TrendingUp className="w-4 h-4" /> <TrendingUp className="w-4 h-4" />
</div> </div>
</div> </div>
<div> <div>
<p className="text-2xl sm:text-3xl font-extrabold text-emerald-400 tracking-tight"> <p className="text-2xl sm:text-3xl font-extrabold text-emerald-700 dark:text-emerald-400 tracking-tight">
{kpis.completionRate}% {kpis.completionRate}%
</p> </p>
<p className="text-[11px] text-muted mt-1"> <p className="text-[11px] text-muted mt-1">
@ -310,7 +310,7 @@ export const AnalyticsPage: React.FC = () => {
</div> </div>
</div> </div>
<div> <div>
<p className="text-2xl sm:text-3xl font-extrabold text-teal-300 tracking-tight"> <p className="text-2xl sm:text-3xl font-extrabold text-teal-700 dark:text-teal-300 tracking-tight">
{kpis.inProgressTasks} tarefas {kpis.inProgressTasks} tarefas
</p> </p>
<p className="text-[11px] text-muted mt-1"> <p className="text-[11px] text-muted mt-1">
@ -515,12 +515,12 @@ export const AnalyticsPage: React.FC = () => {
{member.totalCount} {member.totalCount}
</td> </td>
<td className="py-3.5 px-4"> <td className="py-3.5 px-4">
<span className="inline-flex items-center gap-1 text-emerald-400 font-semibold"> <span className="inline-flex items-center gap-1 text-emerald-700 dark:text-emerald-400 font-semibold">
<CheckCircle2 className="w-3.5 h-3.5" /> <CheckCircle2 className="w-3.5 h-3.5" />
{member.completedCount} {member.completedCount}
</span> </span>
</td> </td>
<td className="py-3.5 px-4 font-mono font-bold text-teal-300"> <td className="py-3.5 px-4 font-mono font-bold text-teal-700 dark:text-teal-300">
{formatSecondsToTimer(member.seconds)} {formatSecondsToTimer(member.seconds)}
</td> </td>
</tr> </tr>

View file

@ -1,294 +1,447 @@
import React, { useEffect, useState } from 'react' import React, { useState, useEffect, useCallback, useMemo } from 'react'
import { AnimatePresence, motion } from 'framer-motion' import { useNavigate } from 'react-router-dom'
import { ArrowRight, Loader2, Pencil, Plus, Search, Tag, X } from 'lucide-react' import {
Tag,
Plus,
Search,
RefreshCw,
Clock,
ListTodo,
Sparkles,
ArrowRight,
Edit2,
X,
Loader2,
AlertCircle,
} from 'lucide-react'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
import { categoryService } from '../services/categoryService' import { categoryService } from '../services/categoryService'
import type { Category, CategoryPayload } from '../types/category' import { taskService } from '../services/taskService'
import { CategoryIcon } from '../components/CategoryIcon' import type { Category, Task } from '../types/task'
import { IconPicker } from '../components/IconPicker' import { calculateTaskTotalSeconds, formatSecondsToHuman } from '../lib/timeUtils'
const DEFAULT_COLOR = '#7C3AED' const COLOR_PRESETS = [
const DEFAULT_ICON = 'Tag' '#d97706', // Amber / Accent
'#0f766e', // Teal / Brand
'#10b981', // Emerald
'#3b82f6', // Blue
'#6366f1', // Indigo
'#8b5cf6', // Violet
'#ec4899', // Pink
'#f43f5e', // Rose
'#f59e0b', // Yellow
'#64748b', // Slate
]
export const CategoriesPage: React.FC = () => { export const CategoriesPage: React.FC = () => {
const { activeOrg } = useAuth() const { activeOrg } = useAuth()
const [categories, setCategories] = useState<Category[]>([]) const navigate = useNavigate()
const [isLoading, setIsLoading] = useState(true)
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState<Category | null>(null)
const [form, setForm] = useState<CategoryPayload>({ name: '', color: DEFAULT_COLOR, icon: DEFAULT_ICON })
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const fetchCategories = async () => { const [categories, setCategories] = useState<Category[]>([])
if (!activeOrg) return const [tasks, setTasks] = useState<Task[]>([])
setIsLoading(true) const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [error, setError] = useState<string | null>(null)
// Modal
const [isModalOpen, setIsModalOpen] = useState(false)
const [editingCategory, setEditingCategory] = useState<Category | null>(null)
const [name, setName] = useState('')
const [color, setColor] = useState(COLOR_PRESETS[0])
const [submitting, setSubmitting] = useState(false)
const activeOrgId = activeOrg?.id
const loadData = useCallback(async () => {
if (!activeOrgId) return
try { try {
const data = await categoryService.listByOrganization(activeOrg.id) setRefreshing(true)
setCategories(data) const [catsRes, tasksRes] = await Promise.all([
} catch (err) { categoryService.listCategories(activeOrgId),
console.error('Falha ao carregar categorias', err) taskService.listTasks({ organizationId: activeOrgId }).catch(() => []),
setError('Não foi possível carregar as categorias.') ])
setCategories(catsRes || [])
setTasks(tasksRes || [])
} catch (err: unknown) {
console.error(err)
setError('Falha ao carregar categorias.')
} finally { } finally {
setIsLoading(false) setLoading(false)
setRefreshing(false)
} }
} }, [activeOrgId])
useEffect(() => { useEffect(() => {
fetchCategories() loadData()
}, [activeOrg]) }, [loadData])
const openCreate = () => { // Aggregate category statistics
setEditing(null) const categoryStats = useMemo(() => {
setForm({ name: '', color: DEFAULT_COLOR, icon: DEFAULT_ICON }) const statsMap = new Map<
setError(null) number,
setShowForm(true) { totalTasks: number; totalSeconds: number }
>()
tasks.forEach((task) => {
if (!task.category?.id) return
const cId = task.category.id
const current = statsMap.get(cId) || {
totalTasks: 0,
totalSeconds: 0,
}
current.totalTasks += 1
current.totalSeconds += calculateTaskTotalSeconds(task)
statsMap.set(cId, current)
})
return statsMap
}, [tasks])
const filteredCategories = useMemo(() => {
if (!searchQuery.trim()) return categories
const q = searchQuery.toLowerCase()
return categories.filter((c) => c.name.toLowerCase().includes(q))
}, [categories, searchQuery])
const handleOpenCreateModal = () => {
setEditingCategory(null)
setName('')
setColor(COLOR_PRESETS[0])
setIsModalOpen(true)
} }
const openEdit = (category: Category) => { const handleOpenEditModal = (cat: Category) => {
setEditing(category) setEditingCategory(cat)
setForm({ name: category.name, color: category.color, icon: category.icon }) setName(cat.name)
setError(null) setColor(cat.color || COLOR_PRESETS[0])
setShowForm(true) setIsModalOpen(true)
} }
const handleSubmit = async (e: React.FormEvent) => { const handleSaveCategory = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!activeOrg) return if (!activeOrgId || !name.trim()) return
setIsSubmitting(true)
setSubmitting(true)
setError(null) setError(null)
try { try {
if (editing) { if (editingCategory) {
const updated = await categoryService.update(activeOrg.id, editing.id, form) const updated = await categoryService.updateCategory(activeOrgId, editingCategory.id, {
setCategories((prev) => prev.map((c) => (c.id === updated.id ? updated : c))) name: name.trim(),
color,
icon: 'tag',
})
setCategories((prev) =>
prev.map((c) => (c.id === updated.id ? updated : c))
)
} else { } else {
const created = await categoryService.create(activeOrg.id, form) const created = await categoryService.createCategory(activeOrgId, {
name: name.trim(),
color,
icon: 'tag',
})
setCategories((prev) => [...prev, created]) setCategories((prev) => [...prev, created])
} }
setShowForm(false) setIsModalOpen(false)
} catch (err) { } catch (err: unknown) {
console.error('Falha ao salvar categoria', err) console.error(err)
setError(err instanceof Error ? err.message : 'Falha ao salvar categoria.') setError(err instanceof Error ? err.message : 'Falha ao salvar categoria.')
} finally { } finally {
setIsSubmitting(false) setSubmitting(false)
} }
} }
const filtered = categories.filter((c) =>
c.name.toLowerCase().includes(searchQuery.toLowerCase()),
)
return ( return (
<div className="space-y-10 pb-20"> <div className="space-y-6 max-w-7xl mx-auto">
<header className="flex flex-col gap-6 md:flex-row md:items-center md:justify-between"> {/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div> <div>
<div className="flex items-center gap-2 text-brand font-black uppercase tracking-widest text-[10px] mb-2"> <div className="flex items-center gap-2 mb-1.5 flex-wrap">
<Tag size={14} /> <h1 className="text-2xl sm:text-3xl font-extrabold tracking-tight bg-gradient-to-r from-accent via-amber-400 to-brand bg-clip-text text-transparent">
Workspace Categorias & Tags
</h1>
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold bg-accent/15 text-accent border border-accent/25 shadow-xs">
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-pulse" />
{activeOrg?.name || 'Workspace'}
</span>
</div> </div>
<h1 className="text-4xl font-black tracking-tight text-text-main"> <p className="text-xs text-muted">
Categorias em <span className="text-brand">{activeOrg?.name ?? '...'}</span> Classifique suas tarefas por tipos de trabalho (Bug, Feature, Reunião, Design) para métricas precisas.
</h1>
<p className="mt-2 text-lg text-text-muted">
Crie categorias para classificar e medir o tempo das suas tarefas.
</p> </p>
</div> </div>
<div className="flex items-center gap-3">
<div className="relative hidden md:block"> <div className="flex items-center gap-2.5">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" size={16} />
<input
type="text"
placeholder="Buscar categorias..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 pr-4 py-2.5 bg-surface border border-border-soft rounded-xl text-sm outline-none focus:border-brand focus:ring-4 focus:ring-brand/5 transition-all w-56 font-medium"
/>
</div>
<button <button
onClick={openCreate} type="button"
className="inline-flex items-center justify-center gap-2 rounded-xl bg-brand px-6 py-3 text-sm font-bold text-white shadow-lg shadow-brand/20 transition-all hover:scale-[1.02] hover:bg-brand-dark active:scale-95" onClick={loadData}
disabled={refreshing}
title="Recarregar categorias"
className="p-2.5 rounded-xl border border-border-soft bg-surface/90 backdrop-blur-md text-muted hover:text-foreground hover:bg-surface-raised transition-all shadow-panel disabled:opacity-50"
> >
<Plus size={18} /> <RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin text-accent' : ''}`} />
Nova Categoria </button>
<button
type="button"
onClick={handleOpenCreateModal}
className="flex items-center gap-1.5 px-4 py-2.5 rounded-xl bg-accent text-white font-semibold text-xs hover:bg-accent-hover shadow-md shadow-accent/25 transition-all hover:-translate-y-0.5 active:translate-y-0"
>
<Plus className="w-4 h-4" />
<span>Nova Categoria</span>
</button> </button>
</div> </div>
</header> </div>
{isLoading ? ( {error && (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="flex items-center justify-between p-4 rounded-2xl bg-rose-500/10 border border-rose-500/25 text-rose-300 text-xs shadow-panel backdrop-blur-md">
{Array.from({ length: 6 }).map((_, i) => ( <div className="flex items-center gap-2">
<div key={i} className="h-32 animate-pulse rounded-[2rem] border border-border-soft bg-surface-muted" /> <AlertCircle className="w-4 h-4 text-rose-400" />
<span>{error}</span>
</div>
<button
type="button"
onClick={() => setError(null)}
className="text-rose-400 hover:text-rose-200 text-xs font-bold"
>
Fechar
</button>
</div>
)}
{/* Search Bar */}
<div className="p-4 rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md flex items-center justify-between gap-3 shadow-panel">
<div className="relative w-full sm:w-80">
<Search className="w-4 h-4 text-muted absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Buscar categoria por nome..."
className="w-full pl-9 pr-3.5 py-2 rounded-xl border border-border-soft bg-surface-raised/80 text-xs text-foreground placeholder-muted focus:outline-none focus:ring-2 focus:ring-accent/40 focus:border-accent transition-all"
/>
</div>
<div className="text-xs text-muted font-medium">
{filteredCategories.length} {filteredCategories.length === 1 ? 'categoria' : 'categorias'}
</div>
</div>
{/* Grid of Categories */}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{[1, 2, 3].map((i) => (
<div
key={i}
className="h-44 rounded-3xl border border-border-soft bg-surface-sunken/40 animate-pulse p-6 space-y-3"
>
<div className="h-5 bg-surface-raised rounded-md w-1/2" />
<div className="h-3 bg-surface-raised rounded-md w-3/4" />
</div>
))} ))}
</div> </div>
) : ( ) : filteredCategories.length === 0 ? (
<section className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="flex flex-col items-center justify-center py-20 px-4 text-center rounded-3xl border border-dashed border-border-soft bg-surface/60 backdrop-blur-md shadow-panel">
<AnimatePresence mode="popLayout"> <div className="w-14 h-14 rounded-2xl bg-accent/10 border border-accent/20 flex items-center justify-center text-accent mb-4">
{filtered.map((category, index) => ( <Tag className="w-7 h-7" />
<motion.div
key={category.id}
layout
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ delay: index * 0.04 }}
className="group relative flex items-center gap-4 rounded-[2rem] border border-border-soft bg-surface p-6 transition-all hover:shadow-xl hover:shadow-brand/5"
>
<div
className="flex h-14 w-14 items-center justify-center rounded-2xl shrink-0"
style={{ background: `${category.color}1a`, color: category.color }}
>
<CategoryIcon iconName={category.icon} color={category.color} size={26} />
</div>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-black text-text-main truncate">{category.name}</h3>
<p className="text-[10px] font-bold uppercase tracking-widest text-text-muted mt-1">
{category.icon} · {category.color}
</p>
</div>
<button
onClick={() => openEdit(category)}
className="flex h-9 w-9 items-center justify-center rounded-xl bg-app-bg text-text-muted transition-all hover:bg-brand hover:text-white"
title="Editar categoria"
>
<Pencil size={16} />
</button>
</motion.div>
))}
<button
onClick={openCreate}
className="flex min-h-[7rem] flex-col items-center justify-center gap-2 rounded-[2rem] border-2 border-dashed border-border-soft bg-surface/50 p-6 text-center transition-all hover:border-brand/30 hover:bg-brand/5 active:scale-[0.98] group"
>
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-app-bg text-text-muted transition-colors group-hover:bg-brand group-hover:text-white">
<Plus size={24} />
</div>
<span className="text-sm font-bold text-text-main">Nova categoria</span>
</button>
</AnimatePresence>
</section>
)}
{!isLoading && filtered.length === 0 && categories.length === 0 && (
<div className="text-center py-16 bg-surface/50 rounded-[3rem] border-2 border-dashed border-border-soft">
<div className="w-16 h-16 bg-surface-muted rounded-2xl flex items-center justify-center mx-auto mb-6 text-text-muted/40">
<Tag size={32} />
</div> </div>
<h3 className="text-xl font-bold text-text-main mb-2">Nenhuma categoria cadastrada</h3> <h3 className="text-base font-bold text-foreground mb-1">
<p className="text-text-muted max-w-xs mx-auto font-medium"> Nenhum categoria cadastrada
Crie sua primeira categoria para começar a classificar tarefas e medir tempo. </h3>
<p className="text-xs text-muted max-w-sm mb-5 leading-relaxed">
{searchQuery
? 'Nenhuma categoria encontrada com este termo.'
: 'Categorias permitem segmentar seus fluxos de trabalho e auditar onde o tempo é consumido.'}
</p> </p>
<button
type="button"
onClick={handleOpenCreateModal}
className="flex items-center gap-2 px-5 py-2.5 rounded-xl bg-accent text-white text-xs font-semibold hover:bg-accent-hover shadow-md shadow-accent/25 transition-all hover:-translate-y-0.5"
>
<Plus className="w-4 h-4" />
<span>Criar Primeira Categoria</span>
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{filteredCategories.map((category) => {
const stats = categoryStats.get(category.id) || {
totalTasks: 0,
totalSeconds: 0,
}
return (
<div
key={category.id}
className="group rounded-3xl border border-border-soft bg-surface/90 backdrop-blur-md p-6 hover:border-accent/40 shadow-panel hover:shadow-xl hover:-translate-y-1 transition-all duration-200 flex flex-col justify-between"
>
<div>
<div className="flex items-start justify-between gap-3 mb-4">
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-xl flex items-center justify-center border shadow-xs"
style={{
backgroundColor: category.color ? `${category.color}18` : 'rgba(217,119,6,0.15)',
borderColor: category.color ? `${category.color}40` : 'rgba(217,119,6,0.3)',
color: category.color || '#d97706',
}}
>
<Tag className="w-5 h-5" />
</div>
<div>
<h3 className="font-bold text-sm text-foreground group-hover:text-accent transition-colors">
{category.name}
</h3>
<span className="text-[11px] text-muted font-medium">
ID #{category.id}
</span>
</div>
</div>
<button
type="button"
onClick={() => handleOpenEditModal(category)}
className="p-1.5 rounded-lg text-muted hover:text-foreground hover:bg-surface-raised transition-colors"
title="Editar categoria"
>
<Edit2 className="w-4 h-4" />
</button>
</div>
{/* Metrics Bar */}
<div className="grid grid-cols-2 gap-2 p-3.5 rounded-2xl bg-surface-sunken/60 border border-border-soft mb-4">
<div className="flex items-center gap-2">
<ListTodo className="w-3.5 h-3.5 text-muted" />
<div>
<p className="text-[10px] text-muted">Tarefas</p>
<p className="text-xs font-bold text-foreground">
{stats.totalTasks} associadas
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Clock className="w-3.5 h-3.5 text-amber-400" />
<div>
<p className="text-[10px] text-muted">Tempo Total</p>
<p className="text-xs font-bold text-amber-400">
{formatSecondsToHuman(stats.totalSeconds)}
</p>
</div>
</div>
</div>
</div>
{/* Footer Link */}
<div className="pt-3 border-t border-border-soft/60 flex items-center justify-between">
<span
className="inline-block w-3 h-3 rounded-full"
style={{ backgroundColor: category.color || '#d97706' }}
/>
<button
type="button"
onClick={() => navigate('/tasks')}
className="inline-flex items-center gap-1 text-xs font-semibold text-accent hover:underline"
>
<span>Ver no Quadro</span>
<ArrowRight className="w-3.5 h-3.5" />
</button>
</div>
</div>
)
})}
</div> </div>
)} )}
<AnimatePresence> {/* Modal Criar/Editar Categoria */}
{showForm && ( {isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-app-bg/80 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6">
<motion.div <div
initial={{ opacity: 0, scale: 0.95, y: 20 }} className="fixed inset-0 bg-black/70 backdrop-blur-xs"
animate={{ opacity: 1, scale: 1, y: 0 }} onClick={() => setIsModalOpen(false)}
exit={{ opacity: 0, scale: 0.95, y: 20 }} />
className="w-full max-w-2xl bg-surface border border-border-soft rounded-[2.5rem] shadow-panel p-8 relative" <div className="relative w-full max-w-md rounded-2xl border border-border bg-surface-raised shadow-2xl z-10 overflow-hidden">
> <div className="flex items-center justify-between p-5 border-b border-border bg-surface">
<button <div className="flex items-center gap-2.5">
onClick={() => setShowForm(false)} <div className="w-8 h-8 rounded-lg bg-accent/15 border border-accent/30 flex items-center justify-center text-accent">
className="absolute top-6 right-6 text-text-muted hover:text-brand transition-colors" <Sparkles className="w-4 h-4" />
>
<X size={22} />
</button>
<div className="mb-8 flex items-center gap-3">
<div
className="flex h-12 w-12 items-center justify-center rounded-2xl"
style={{ background: `${form.color}1a`, color: form.color }}
>
<CategoryIcon iconName={form.icon} color={form.color} size={24} />
</div> </div>
<div> <div>
<h2 className="text-2xl font-black text-text-main"> <h2 className="text-base font-bold text-foreground">
{editing ? 'Editar categoria' : 'Nova categoria'} {editingCategory ? 'Editar Categoria' : 'Nova Categoria'}
</h2> </h2>
<p className="text-sm text-text-muted"> <p className="text-xs text-muted">
Defina nome, cor e ícone para identificar suas tarefas. {editingCategory
? 'Atualize o nome ou cor da tag.'
: 'Crie uma categoria para agrupar tipos de trabalho.'}
</p> </p>
</div> </div>
</div> </div>
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="p-1.5 rounded-lg text-muted hover:text-foreground hover:bg-surface-raised transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSaveCategory} className="p-5 space-y-4">
<div className="grid gap-6 md:grid-cols-2"> <div>
<label className="block space-y-2"> <label className="block text-xs font-semibold text-foreground mb-1.5">
<span className="text-sm font-bold text-text-main">Nome</span> Nome da Categoria <span className="text-accent">*</span>
<input </label>
autoFocus <input
required type="text"
value={form.name} required
onChange={(e) => setForm({ ...form, name: e.target.value })} value={name}
placeholder="Ex.: Bug, Feature, Reunião" onChange={(e) => setName(e.target.value)}
className="block w-full h-12 px-4 bg-app-bg border border-border-soft rounded-xl text-sm font-medium outline-none focus:border-brand focus:ring-4 focus:ring-brand/10" placeholder="Ex: Bugfix, Feature, Reunião, Documentação..."
className="w-full px-3.5 py-2.5 rounded-xl border border-border bg-surface text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-accent/40 focus:border-accent"
/>
</div>
<div>
<label className="block text-xs font-semibold text-foreground mb-2">
Cor da Tag
</label>
<div className="flex items-center gap-2 flex-wrap">
{COLOR_PRESETS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
className={`w-7 h-7 rounded-full border-2 transition-transform ${
color === c
? 'scale-115 border-white ring-2 ring-accent/50'
: 'border-transparent hover:scale-105'
}`}
style={{ backgroundColor: c }}
/> />
</label> ))}
<label className="block space-y-2">
<span className="text-sm font-bold text-text-main">Cor</span>
<div className="flex items-center gap-3">
<input
type="color"
value={form.color}
onChange={(e) => setForm({ ...form, color: e.target.value })}
className="h-12 w-16 cursor-pointer rounded-xl border border-border-soft bg-app-bg"
/>
<input
type="text"
value={form.color}
onChange={(e) => setForm({ ...form, color: e.target.value })}
className="flex-1 h-12 px-4 bg-app-bg border border-border-soft rounded-xl text-sm font-mono outline-none focus:border-brand"
/>
</div>
</label>
</div> </div>
</div>
<div className="space-y-2"> <div className="pt-4 border-t border-border flex items-center justify-end gap-2.5">
<span className="text-sm font-bold text-text-main">Ícone</span> <button
<IconPicker type="button"
value={form.icon} onClick={() => setIsModalOpen(false)}
onChange={(icon) => setForm({ ...form, icon })} disabled={submitting}
color={form.color} className="px-4 py-2 rounded-xl border border-border bg-surface text-xs font-medium text-foreground hover:bg-surface-raised"
/> >
</div> Cancelar
</button>
{error && ( <button
<div className="p-4 bg-rose-500/10 border border-rose-500/20 rounded-2xl text-rose-500 text-xs font-bold flex items-center gap-3"> type="submit"
<X size={16} /> disabled={submitting || !name.trim()}
{error} className="px-5 py-2 rounded-xl bg-accent text-white text-xs font-semibold hover:bg-accent-hover shadow-xs flex items-center gap-2 disabled:opacity-50"
</div> >
)} {submitting && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
{editingCategory ? 'Salvar Alterações' : 'Criar Categoria'}
<div className="flex items-center justify-end gap-3"> </button>
<button </div>
type="button" </form>
onClick={() => setShowForm(false)}
className="px-5 py-3 text-sm font-bold text-text-muted hover:text-text-main"
>
Cancelar
</button>
<button
type="submit"
disabled={isSubmitting || !form.name.trim() || !form.icon}
className="inline-flex min-w-44 items-center justify-center gap-2 rounded-xl bg-brand px-6 py-3 text-sm font-black text-white shadow-lg shadow-brand/20 transition-all hover:scale-[1.02] hover:bg-brand-dark active:scale-95 disabled:opacity-50"
>
{isSubmitting ? (
<Loader2 className="animate-spin" size={18} />
) : (
<>
{editing ? 'Salvar' : 'Criar categoria'}
<ArrowRight size={18} />
</>
)}
</button>
</div>
</form>
</motion.div>
</div> </div>
)} </div>
</AnimatePresence> )}
</div> </div>
) )
} }

View file

@ -1,88 +0,0 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { motion } from 'framer-motion'
import { Layout, Building2, ArrowRight } from 'lucide-react'
import { organizationService } from '../services/organizationService'
import { useAuth } from '../contexts/AuthContext'
export const CreateOrganizationPage: React.FC = () => {
const [name, setName] = useState('')
const [isLoading, setIsLoading] = useState(false)
const navigate = useNavigate()
const { refreshUser } = useAuth()
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault()
setIsLoading(true)
try {
await organizationService.create(name)
await refreshUser()
alert('Organização criada com sucesso!')
navigate('/')
} catch (error) {
console.error('Failed to create organization', error)
alert('Erro ao criar organização.')
} finally {
setIsLoading(false)
}
}
return (
<div className="min-h-screen bg-app-bg flex items-center justify-center p-4 relative overflow-hidden font-sans">
<div className="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none opacity-50">
<div className="absolute top-0 left-0 w-[40%] h-[40%] bg-brand/15 blur-[100px] rounded-full" />
</div>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="w-full max-w-md relative z-10"
>
<div className="bg-surface border border-border-soft p-10 rounded-[2.5rem] shadow-panel">
<div className="flex flex-col items-center mb-10 text-center">
<div className="w-14 h-14 bg-brand text-white rounded-2xl flex items-center justify-center mb-6 shadow-lg shadow-brand/20">
<Building2 size={28} />
</div>
<h1 className="text-3xl font-black text-text-main tracking-tight mb-3">Nova Organização</h1>
<p className="text-text-muted font-medium">Dê um nome ao seu novo workspace</p>
</div>
<form onSubmit={handleCreate} className="space-y-6">
<div className="space-y-2">
<label className="text-sm font-bold text-text-main ml-1">Nome da Organização</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<Layout className="h-5 w-5 text-text-muted group-focus-within:text-brand transition-colors" />
</div>
<input
type="text"
required
value={name}
onChange={(e) => setName(e.target.value)}
className="block w-full pl-12 pr-4 py-3.5 bg-surface-muted/50 border border-border-soft rounded-2xl text-text-main placeholder-text-muted/60 focus:outline-none focus:ring-4 focus:ring-brand/10 focus:border-brand transition-all font-medium"
placeholder="Ex: Bee Tech"
/>
</div>
</div>
<button
type="submit"
disabled={isLoading || !name}
className="w-full flex items-center justify-center gap-3 bg-brand hover:bg-brand-strong disabled:bg-brand/50 disabled:cursor-not-allowed text-white font-bold py-4 px-6 rounded-2xl transition-all shadow-lg shadow-brand/20 active:scale-[0.98]"
>
{isLoading ? (
<div className="w-5 h-5 border-2 border-surface/30 border-t-surface rounded-full animate-spin" />
) : (
<>
Criar Agora
<ArrowRight size={20} />
</>
)}
</button>
</form>
</div>
</motion.div>
</div>
)
}

View file

@ -1,559 +0,0 @@
import { motion } from "framer-motion";
import {
ArrowLeft,
CheckCircle2,
Clock,
FileText,
Layout,
PlusCircle,
Send,
Users,
XCircle,
} from "lucide-react";
import { format } from "date-fns";
import { useState } from "react";
import type { ChangeEvent, FormEvent } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { taskService } from "../services/taskService";
import { cn } from "../lib/utils";
import { TASK_STATUS_LABELS, TASK_STATUS_OPTIONS } from "../types/task";
import type { CreateTaskPayload, TaskResponse, TaskHistoryItem } from "../types/task";
import { useAuth } from "../contexts/AuthContext";
import { useEffect } from "react";
import { projectService, type Project } from "../services/projectService";
import { categoryService } from "../services/categoryService";
import {
organizationService,
type OrganizationMember,
} from "../services/organizationService";
import type { Category } from "../types/category";
import { CategoryIcon } from "../components/CategoryIcon";
import { UserAvatar } from "../components/UserAvatar";
type FormState = Omit<CreateTaskPayload, 'organizationId' | 'projectId' | 'categoryId' | 'assigneeUserId'> & {
projectId: string;
categoryId: string;
assigneeUserId: string;
history: TaskHistoryItem[];
};
const initialFormState: FormState = {
title: "",
description: "",
status: "NOT_STARTED",
projectId: "",
categoryId: "",
assigneeUserId: "",
history: [],
};
export function CreateTaskPage() {
const [form, setForm] = useState<FormState>(initialFormState);
const [isSubmitting, setIsSubmitting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [createdTask, setCreatedTask] = useState<TaskResponse | null>(null);
const [projects, setProjects] = useState<Project[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [members, setMembers] = useState<OrganizationMember[]>([]);
const [assignToLoggedUser, setAssignToLoggedUser] = useState(false);
const { activeOrg, user } = useAuth();
const { id } = useParams();
const navigate = useNavigate();
const isEdit = !!id;
useEffect(() => {
if (isEdit) {
async function loadTask() {
try {
const data = await taskService.getTask(Number(id));
setForm({
title: data.title,
description: data.description || "",
status: data.status,
projectId: data.project?.id?.toString() || "",
categoryId: data.category?.id?.toString() || "",
assigneeUserId: data.user?.id?.toString() || "",
history: data.history || [],
});
setAssignToLoggedUser(Boolean(user?.email && data.user?.email === user.email));
} catch (error) {
console.error("Erro ao carregar tarefa:", error);
setErrorMessage("Não foi possível carregar os dados da tarefa.");
}
}
loadTask();
}
}, [id, isEdit, user?.email]);
useEffect(() => {
async function loadProjects() {
if (!activeOrg) return;
try {
const data = await projectService.listByOrganization(activeOrg.id);
setProjects(data);
} catch (error) {
console.error("Erro ao carregar projetos:", error);
}
}
async function loadCategories() {
if (!activeOrg) return;
try {
const data = await categoryService.listByOrganization(activeOrg.id);
setCategories(data);
} catch (error) {
console.error("Erro ao carregar categorias:", error);
}
}
async function loadMembers() {
if (!activeOrg) return;
try {
const data = await organizationService.listMembers(activeOrg.id);
setMembers(data);
} catch (error) {
console.error("Erro ao carregar membros:", error);
}
}
loadProjects();
loadCategories();
loadMembers();
}, [activeOrg]);
useEffect(() => {
if (!activeOrg) return;
if (assignToLoggedUser && user) {
const currentMember = members.find((member) => member.userEmail === user.email);
if (currentMember) {
setForm((current) => ({ ...current, assigneeUserId: currentMember.userId.toString() }));
}
}
}, [assignToLoggedUser, user, members, activeOrg]);
function updateField(
event: ChangeEvent<
HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement
>,
) {
const { name, value } = event.target;
setForm((current) => ({
...current,
[name]: value,
}));
}
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setErrorMessage(null);
setIsSubmitting(true);
try {
if (!activeOrg) {
throw new Error("Nenhuma organização ativa selecionada.");
}
const payload = {
title: form.title.trim(),
description: form.description.trim(),
status: form.status,
organizationId: activeOrg.id,
projectId: form.projectId ? Number(form.projectId) : null,
categoryId: form.categoryId ? Number(form.categoryId) : null,
assigneeUserId: form.assigneeUserId ? Number(form.assigneeUserId) : null,
history: form.history,
};
if (isEdit) {
await taskService.updateTask(Number(id), payload);
navigate("/");
} else {
const response = await taskService.createTask(payload);
setCreatedTask(response);
setForm(initialFormState);
setAssignToLoggedUser(false);
}
} catch (error) {
setErrorMessage(
error instanceof Error
? error.message
: "Não foi possível criar a task.",
);
} finally {
setIsSubmitting(false);
}
}
if (createdTask) {
return (
<div className="flex min-h-[60vh] items-center justify-center">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="max-w-md overflow-hidden rounded-[2.5rem] border border-border-soft bg-surface p-8 text-center shadow-2xl shadow-brand/10"
>
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-brand-soft text-brand">
<CheckCircle2 size={40} />
</div>
<h2 className="mt-6 text-3xl font-black tracking-tight text-text-main">
Tarefa Criada!
</h2>
<p className="mt-3 text-text-muted">
Sua nova tarefa foi registrada com sucesso e já está disponível na
lista.
</p>
<div className="mt-8 rounded-2xl bg-app-bg p-4 text-left">
<p className="text-[10px] font-black uppercase tracking-widest text-text-muted">
#{createdTask.id}
</p>
<h4 className="mt-1 font-bold text-text-main">
{createdTask.title}
</h4>
<div className="mt-3 flex items-center gap-2">
<span className="rounded-md bg-surface px-2 py-1 text-[10px] font-bold text-brand shadow-sm">
{TASK_STATUS_LABELS[createdTask.status]}
</span>
<span className="text-[10px] font-bold text-text-muted">
{createdTask.project?.name || "Geral"}
</span>
</div>
</div>
<div className="mt-8 flex flex-col gap-3">
<Link
to="/"
className="inline-flex items-center justify-center gap-2 rounded-xl bg-brand py-3 font-bold text-white shadow-lg shadow-brand/20 transition-all hover:bg-brand-strong"
>
Ver Minhas Tarefas
</Link>
<button
onClick={() => setCreatedTask(null)}
className="text-sm font-bold text-text-muted hover:text-text-main"
>
Criar outra tarefa
</button>
</div>
</motion.div>
</div>
);
}
const selectedAssignee = members.find(
(member) => member.userId === Number(form.assigneeUserId),
);
return (
<div className="mx-auto max-w-3xl pb-20">
<div className="mb-8 flex items-center justify-between">
<Link
to="/"
className="inline-flex items-center gap-2 text-sm font-bold text-text-muted transition-colors hover:text-brand"
>
<ArrowLeft size={16} />
Voltar para a lista
</Link>
</div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="overflow-hidden rounded-[2.5rem] border border-border-soft bg-surface shadow-2xl shadow-brand/5"
>
<div className="bg-brand p-8 text-white">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-surface/20 backdrop-blur-sm">
<PlusCircle size={24} />
</div>
<div>
<h1 className="text-2xl font-black tracking-tight">
{isEdit ? "Editar Tarefa" : "Nova Tarefa"}
</h1>
<p className="text-sm font-medium text-white/80">
{isEdit ? `Editando tarefa #${id}` : "Preencha os detalhes abaixo para começar."}
</p>
</div>
</div>
</div>
<form onSubmit={handleSubmit} className="p-8 lg:p-10">
<div className="space-y-8">
{/* Title Section */}
<div className="space-y-4">
<label className="group block">
<div className="mb-2 flex items-center gap-2 text-sm font-bold text-text-main group-focus-within:text-brand">
<FileText size={16} />
Título da Tarefa
</div>
<input
required
name="title"
value={form.title}
onChange={updateField}
placeholder="Ex.: Implementar autenticação JWT"
className="h-14 w-full rounded-2xl border border-border-soft bg-app-bg px-5 text-lg font-medium outline-none transition-all focus:border-brand focus:ring-4 focus:ring-brand/10"
/>
</label>
</div>
{/* Project, Category & Status Row */}
<div className="grid gap-6 md:grid-cols-3">
<label className="group block">
<div className="mb-2 flex items-center gap-2 text-sm font-bold text-text-main group-focus-within:text-brand">
<Layout size={16} />
Projeto
</div>
<select
name="projectId"
value={form.projectId || ""}
onChange={updateField}
className="h-12 w-full appearance-none rounded-xl border border-border-soft bg-app-bg px-4 text-sm font-medium outline-none transition-all focus:border-brand focus:ring-4 focus:ring-brand/10"
>
<option value="">Sem Projeto (Geral)</option>
{projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
</label>
<label className="group block">
<div className="mb-2 flex items-center gap-2 text-sm font-bold text-text-main group-focus-within:text-brand">
{form.categoryId ? (
(() => {
const c = categories.find((cat) => cat.id === Number(form.categoryId));
return c ? (
<CategoryIcon iconName={c.icon} color={c.color} size={16} />
) : (
<Layout size={16} />
);
})()
) : (
<Layout size={16} />
)}
Categoria
</div>
<select
name="categoryId"
value={form.categoryId || ""}
onChange={updateField}
className="h-12 w-full appearance-none rounded-xl border border-border-soft bg-app-bg px-4 text-sm font-medium outline-none transition-all focus:border-brand focus:ring-4 focus:ring-brand/10"
>
<option value="">Sem Categoria</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
</label>
<label className="group block">
<div className="mb-2 flex items-center gap-2 text-sm font-bold text-text-main group-focus-within:text-brand">
<Layout size={16} />
Status Inicial
</div>
<select
name="status"
value={form.status}
onChange={updateField}
className="h-12 w-full appearance-none rounded-xl border border-border-soft bg-app-bg px-4 text-sm font-medium outline-none transition-all focus:border-brand focus:ring-4 focus:ring-brand/10"
>
{TASK_STATUS_OPTIONS.map((status) => (
<option key={status} value={status}>
{TASK_STATUS_LABELS[status]}
</option>
))}
</select>
</label>
</div>
<div className="space-y-4 rounded-[2rem] border border-border-soft bg-app-bg/40 p-5">
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<label className="group block flex-1">
<div className="mb-2 flex items-center gap-2 text-sm font-bold text-text-main group-focus-within:text-brand">
<Users size={16} />
Funcionário Responsável
</div>
<select
name="assigneeUserId"
value={form.assigneeUserId}
onChange={updateField}
disabled={assignToLoggedUser}
className="h-12 w-full appearance-none rounded-xl border border-border-soft bg-app-bg px-4 text-sm font-medium outline-none transition-all disabled:cursor-not-allowed disabled:opacity-60 focus:border-brand focus:ring-4 focus:ring-brand/10"
>
<option value="">Sem responsável</option>
{members.map((member) => (
<option key={member.userId} value={member.userId}>
{member.userName} - {member.userEmail}
</option>
))}
</select>
</label>
<label className="flex items-center gap-3 rounded-xl border border-border-soft bg-surface px-4 py-3 text-sm font-bold text-text-main">
<input
type="checkbox"
checked={assignToLoggedUser}
onChange={(event) => {
const checked = event.target.checked;
setAssignToLoggedUser(checked);
if (!checked && user?.email && selectedAssignee?.userEmail === user.email) {
setForm((current) => ({ ...current, assigneeUserId: current.assigneeUserId }));
}
}}
className="h-4 w-4 rounded border-border-soft text-brand focus:ring-brand"
/>
Associar ao usuário logado
</label>
</div>
{selectedAssignee ? (
<div className="flex items-center gap-3 rounded-2xl border border-border-soft bg-surface p-4">
<UserAvatar
name={selectedAssignee.userName}
photo={selectedAssignee.userPhoto}
size="md"
/>
<div>
<p className="text-sm font-black text-text-main">
{selectedAssignee.userName}
</p>
<p className="text-xs font-medium text-text-muted">
{selectedAssignee.userEmail}
</p>
</div>
</div>
) : (
<p className="text-xs font-medium text-text-muted">
Esta tarefa pode ficar sem responsável.
</p>
)}
</div>
{/* Description Section */}
<label className="group block">
<div className="mb-2 flex items-center gap-2 text-sm font-bold text-text-main group-focus-within:text-brand">
<FileText size={16} />
Descrição
</div>
<textarea
name="description"
value={form.description}
onChange={updateField}
rows={5}
placeholder="Descreva o que precisa ser feito..."
className="w-full rounded-2xl border border-border-soft bg-app-bg px-5 py-4 text-sm font-medium outline-none transition-all focus:border-brand focus:ring-4 focus:ring-brand/10"
/>
</label>
{/* History Section */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-bold text-text-main">
<Clock size={16} />
Histórico de Execução
</div>
<button
type="button"
onClick={() => {
setForm(prev => ({
...prev,
history: [...prev.history, { startAt: format(new Date(), "yyyy-MM-dd'T'HH:mm:ss"), endAt: null }]
}))
}}
className="text-xs font-bold text-brand hover:underline"
>
+ Adicionar Registro Manual
</button>
</div>
<div className="space-y-3">
{form.history.map((item, index) => (
<div key={index} className="flex flex-wrap items-end gap-4 rounded-xl border border-border-soft bg-app-bg/50 p-4">
<div className="flex-1 space-y-1">
<label className="text-[10px] font-black uppercase text-text-muted">Início</label>
<input
type="datetime-local"
value={item.startAt ? item.startAt.slice(0, 16) : ""}
onChange={(e) => {
const newHistory = [...form.history];
newHistory[index] = { ...item, startAt: e.target.value };
setForm(prev => ({ ...prev, history: newHistory }));
}}
className="w-full rounded-lg border border-border-soft bg-surface px-3 py-2 text-xs outline-none focus:border-brand"
/>
</div>
<div className="flex-1 space-y-1">
<label className="text-[10px] font-black uppercase text-text-muted">Fim</label>
<input
type="datetime-local"
value={item.endAt ? item.endAt.slice(0, 16) : ""}
onChange={(e) => {
const newHistory = [...form.history];
newHistory[index] = { ...item, endAt: e.target.value || null };
setForm(prev => ({ ...prev, history: newHistory }));
}}
className="w-full rounded-lg border border-border-soft bg-surface px-3 py-2 text-xs outline-none focus:border-brand"
/>
</div>
<button
type="button"
onClick={() => {
setForm(prev => ({
...prev,
history: prev.history.filter((_, i) => i !== index)
}))
}}
className="mb-1 rounded-lg p-2 text-danger hover:bg-danger-soft"
>
<XCircle size={16} />
</button>
</div>
))}
{form.history.length === 0 && (
<p className="py-4 text-center text-xs font-medium text-text-muted">Nenhum registro de execução encontrado.</p>
)}
</div>
</div>
</div>
{errorMessage && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="mt-8 rounded-xl border border-danger/20 bg-danger-soft p-4 text-sm font-bold text-danger"
>
{errorMessage}
</motion.div>
)}
<div className="mt-10 flex items-center justify-end gap-4">
<Link
to="/"
className="px-6 py-3 text-sm font-bold text-text-muted hover:text-text-main"
>
Cancelar
</Link>
<button
type="submit"
disabled={isSubmitting}
className={cn(
"inline-flex min-w-44 items-center justify-center gap-2 rounded-xl bg-brand px-8 py-3.5 text-sm font-black text-white shadow-lg shadow-brand/20 transition-all hover:scale-[1.02] hover:bg-brand-strong active:scale-95 disabled:opacity-50",
isSubmitting && "cursor-wait",
)}
>
{isSubmitting ? (
isEdit ? "Salvando..." : "Criando..."
) : (
<>
<Send size={18} />
{isEdit ? "Salvar Alterações" : "Criar Tarefa"}
</>
)}
</button>
</div>
</form>
</motion.div>
</div>
);
}

File diff suppressed because it is too large Load diff

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