feat: migrate to frontend v2, add task external links, auto-assign logged user, and preferences support
|
|
@ -6,6 +6,7 @@ public class User {
|
|||
private String email;
|
||||
private String password;
|
||||
private String photo;
|
||||
private String preferences;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
|
|
@ -46,4 +47,12 @@ public class User {
|
|||
public void setPhoto(String photo) {
|
||||
this.photo = photo;
|
||||
}
|
||||
|
||||
public String getPreferences() {
|
||||
return preferences;
|
||||
}
|
||||
|
||||
public void setPreferences(String preferences) {
|
||||
this.preferences = preferences;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,15 @@ public class Task {
|
|||
private User user;
|
||||
private LocalDateTime finishedAt;
|
||||
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() {
|
||||
return id;
|
||||
|
|
|
|||
|
|
@ -53,4 +53,14 @@ public class UserUseCaseConfig {
|
|||
public UploadUserPhotoUseCase uploadUserPhotoUseCase(UserRepository userRepository, StorageRepository 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ public class TaskEntity {
|
|||
private TaskStatus status;
|
||||
@Column(name = "finished_at")
|
||||
private java.time.LocalDateTime finishedAt;
|
||||
@Column(name = "external_url", length = 1000)
|
||||
private String externalUrl;
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "project_id")
|
||||
|
|
@ -112,4 +114,12 @@ public class TaskEntity {
|
|||
public void setUser(UserEntity user) {
|
||||
this.user = user;
|
||||
}
|
||||
|
||||
public String getExternalUrl() {
|
||||
return externalUrl;
|
||||
}
|
||||
|
||||
public void setExternalUrl(String externalUrl) {
|
||||
this.externalUrl = externalUrl;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ public class UserEntity {
|
|||
private String email;
|
||||
private String password;
|
||||
private String photo;
|
||||
@jakarta.persistence.Column(columnDefinition = "jsonb")
|
||||
@org.hibernate.annotations.JdbcTypeCode(org.hibernate.type.SqlTypes.JSON)
|
||||
private String preferences;
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
|
|
@ -56,4 +59,12 @@ public class UserEntity {
|
|||
public void setPhoto(String photo) {
|
||||
this.photo = photo;
|
||||
}
|
||||
|
||||
public String getPreferences() {
|
||||
return preferences;
|
||||
}
|
||||
|
||||
public void setPreferences(String preferences) {
|
||||
this.preferences = preferences;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ public class TaskEntityMapper {
|
|||
entity.setDescription(task.getDescription());
|
||||
entity.setStatus(task.getStatus());
|
||||
entity.setFinishedAt(task.getFinishedAt());
|
||||
entity.setExternalUrl(task.getExternalUrl());
|
||||
entity.setProject(ProjectEntityMapper.toEntity(task.getProject()));
|
||||
entity.setCategory(CategoryEntityMapper.toEntity(task.getCategory()));
|
||||
entity.setUser(UserEntityMapper.toEntity(task.getUser()));
|
||||
|
|
@ -36,6 +37,7 @@ public class TaskEntityMapper {
|
|||
task.setDescription(entity.getDescription());
|
||||
task.setStatus(entity.getStatus());
|
||||
task.setFinishedAt(entity.getFinishedAt());
|
||||
task.setExternalUrl(entity.getExternalUrl());
|
||||
task.setProject(ProjectEntityMapper.toDomain(entity.getProject()));
|
||||
task.setCategory(CategoryEntityMapper.toDomain(entity.getCategory()));
|
||||
task.setUser(UserEntityMapper.toDomain(entity.getUser()));
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ public class UserEntityMapper {
|
|||
entity.setEmail(user.getEmail());
|
||||
entity.setPassword(user.getPassword());
|
||||
entity.setPhoto(user.getPhoto());
|
||||
entity.setPreferences(user.getPreferences());
|
||||
return entity;
|
||||
}
|
||||
|
||||
|
|
@ -25,6 +26,7 @@ public class UserEntityMapper {
|
|||
user.setEmail(entity.getEmail());
|
||||
user.setPassword(entity.getPassword());
|
||||
user.setPhoto(entity.getPhoto());
|
||||
user.setPreferences(entity.getPreferences());
|
||||
return user;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository<Tas
|
|||
} else {
|
||||
entity = getEntityManager().merge(entity);
|
||||
}
|
||||
getEntityManager().flush();
|
||||
Task savedTask = TaskEntityMapper.toDomain(entity);
|
||||
LOGGER.infof("Persisted task %d with status %s", savedTask.getId(), savedTask.getStatus());
|
||||
return savedTask;
|
||||
|
|
|
|||
|
|
@ -59,6 +59,12 @@ public class AuthController {
|
|||
@Inject
|
||||
UploadUserPhotoUseCase uploadUserPhotoUseCase;
|
||||
|
||||
@Inject
|
||||
net.beetechgroup.beetask.usecase.user.preferences.GetUserPreferencesUseCase getUserPreferencesUseCase;
|
||||
|
||||
@Inject
|
||||
net.beetechgroup.beetask.usecase.user.preferences.UpdateUserPreferencesUseCase updateUserPreferencesUseCase;
|
||||
|
||||
@Inject
|
||||
SecurityIdentity securityIdentity;
|
||||
|
||||
|
|
@ -99,6 +105,28 @@ public class AuthController {
|
|||
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")
|
||||
@POST
|
||||
@Operation(summary = "Register", description = "Creates a new user account")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
package net.beetechgroup.beetask.interfaceadapters.controllers.auth;
|
||||
|
||||
public record PreferencesRequest(String preferences) {}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package net.beetechgroup.beetask.interfaceadapters.controllers.auth;
|
||||
|
||||
public record PreferencesResponse(String preferences) {}
|
||||
|
|
@ -12,5 +12,6 @@ public record CreateTaskRequest(
|
|||
Long projectId,
|
||||
Long categoryId,
|
||||
Long assigneeUserId,
|
||||
List<TaskHistoryItemResponse> history) {
|
||||
List<TaskHistoryItemResponse> history,
|
||||
String externalUrl) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,16 +14,11 @@ public record CreateTaskResponse(
|
|||
CategoryResponse category,
|
||||
UserResponse user,
|
||||
LocalDateTime finishedAt,
|
||||
List<TaskHistoryItemResponse> history) {
|
||||
List<TaskHistoryItemResponse> history,
|
||||
String externalUrl) {
|
||||
|
||||
public record ProjectResponse(Long id, String name) {}
|
||||
public record CategoryResponse(Long id, String name, String color, String icon) {}
|
||||
public record UserResponse(Long id, String name, String email, String photo) {}
|
||||
}
|
||||
|
||||
record TaskHistoryItemResponse(
|
||||
Long id,
|
||||
LocalDateTime startAt,
|
||||
LocalDateTime endAt,
|
||||
Long elapsedSeconds) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,7 +40,8 @@ public class TaskControllerMapper {
|
|||
userEmail,
|
||||
Objects.nonNull(request.history()) ? request.history().stream()
|
||||
.map(h -> new TaskHistoryItemInput(h.id(), h.startAt(), h.endAt()))
|
||||
.toList() : null
|
||||
.toList() : null,
|
||||
request.externalUrl()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -69,7 +70,8 @@ public class TaskControllerMapper {
|
|||
: null,
|
||||
output.finishedAt(),
|
||||
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,
|
||||
Objects.nonNull(request.history()) ? request.history().stream()
|
||||
.map(h -> new TaskHistoryItemInput(h.id(), h.startAt(), h.endAt()))
|
||||
.toList() : null
|
||||
.toList() : null,
|
||||
request.externalUrl()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
}
|
||||
|
|
@ -13,5 +13,6 @@ public record CreateTaskInput(
|
|||
Long categoryId,
|
||||
Long assigneeUserId,
|
||||
String userEmail,
|
||||
List<TaskHistoryItemInput> history) {
|
||||
List<TaskHistoryItemInput> history,
|
||||
String externalUrl) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,14 +29,15 @@ public class CreateTaskMapper {
|
|||
getPhotoUrl(task.getUser().getName(), task.getUser().getPhoto()))
|
||||
: null,
|
||||
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) {
|
||||
return (Objects.nonNull(photo) && !photo.isBlank()) ? photo : null;
|
||||
}
|
||||
|
||||
private static TaskHistoryItemOutput toTaskHistoryItemOutput(TaskHistoryItem taskHistoryItem) {
|
||||
public static TaskHistoryItemOutput toTaskHistoryItemOutput(TaskHistoryItem taskHistoryItem) {
|
||||
return new TaskHistoryItemOutput(
|
||||
taskHistoryItem.getId(),
|
||||
taskHistoryItem.getStartAt(),
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ public record CreateTaskOutput(
|
|||
CategoryOutput category,
|
||||
UserOutput user,
|
||||
LocalDateTime finishedAt,
|
||||
List<TaskHistoryItemOutput> history) {
|
||||
List<TaskHistoryItemOutput> history,
|
||||
String externalUrl) {
|
||||
|
||||
public record ProjectOutput(Long id, String name) {}
|
||||
public record CategoryOutput(Long id, String name, String color, String icon) {}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ public class CreateTaskUseCase {
|
|||
task.setTitle(input.title());
|
||||
task.setDescription(input.description());
|
||||
task.setStatus(input.status());
|
||||
task.setExternalUrl(input.externalUrl());
|
||||
|
||||
if (Objects.nonNull(input.projectId())) {
|
||||
Project project = projectRepository.findProjectById(input.projectId())
|
||||
|
|
|
|||
|
|
@ -13,5 +13,6 @@ public record UpdateTaskInput(
|
|||
Long categoryId,
|
||||
Long assigneeUserId,
|
||||
String userEmail,
|
||||
List<TaskHistoryItemInput> history) {
|
||||
List<TaskHistoryItemInput> history,
|
||||
String externalUrl) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ public class UpdateTaskUseCase {
|
|||
task.setTitle(input.title());
|
||||
task.setDescription(input.description());
|
||||
task.setStatus(input.status());
|
||||
task.setExternalUrl(input.externalUrl());
|
||||
|
||||
if (Objects.nonNull(input.projectId())) {
|
||||
Project project = projectRepository.findProjectById(input.projectId())
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -48,7 +48,8 @@ public class GetUserProfileUseCase {
|
|||
user.getName(),
|
||||
user.getEmail(),
|
||||
user.getPhoto(),
|
||||
orgs
|
||||
orgs,
|
||||
user.getPreferences()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,8 @@ public record UserProfileOutput(
|
|||
String name,
|
||||
String email,
|
||||
String photo,
|
||||
List<OrganizationProfileOutput> organizations
|
||||
List<OrganizationProfileOutput> organizations,
|
||||
String preferences
|
||||
) {
|
||||
public record OrganizationProfileOutput(
|
||||
Long id,
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE users_entity ADD COLUMN preferences TEXT;
|
||||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE users_entity ALTER COLUMN preferences TYPE jsonb USING preferences::jsonb;
|
||||
|
|
@ -0,0 +1 @@
|
|||
ALTER TABLE tasks ADD COLUMN external_url VARCHAR(1000);
|
||||
|
|
@ -37,11 +37,11 @@ services:
|
|||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "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.service=minio"
|
||||
- "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.service=minio-console"
|
||||
- "traefik.http.services.minio-console.loadbalancer.server.port=9001"
|
||||
|
|
@ -76,7 +76,7 @@ services:
|
|||
DB_USER: beetask
|
||||
DB_PASSWORD: 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}
|
||||
SMALLRYE_JWT_SIGN_KEY: ${SMALLRYE_JWT_SIGN_KEY}
|
||||
MP_JWT_VERIFY_PUBLICKEY: ${MP_JWT_VERIFY_PUBLICKEY}
|
||||
|
|
@ -94,7 +94,7 @@ services:
|
|||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "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.services.backend.loadbalancer.server.port=8081"
|
||||
|
||||
|
|
@ -103,7 +103,7 @@ services:
|
|||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
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
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
|
|
@ -116,32 +116,10 @@ services:
|
|||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "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.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:
|
||||
postgres_data:
|
||||
minio_data:
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
VITE_API_URL=https://api-task2.beetech.dev
|
||||
|
|
@ -3,7 +3,7 @@ FROM node:22-alpine AS build
|
|||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
RUN npm install --legacy-peer-deps
|
||||
|
||||
COPY . .
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# 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:
|
||||
|
||||
|
|
@ -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).
|
||||
|
||||
## 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
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// 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,
|
||||
```json
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "oxc"],
|
||||
"options": {
|
||||
"typeAware": true
|
||||
},
|
||||
// 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:
|
||||
|
||||
```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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
|
|
@ -6,11 +6,11 @@
|
|||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
4968
frontend/package-lock.json
generated
|
|
@ -1,41 +1,36 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"name": "new-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"lint": "oxlint",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hello-pangea/dnd": "^18.0.1",
|
||||
"@tailwindcss/vite": "^4.2.4",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"framer-motion": "^12.38.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@heroui/react": "^3.2.6",
|
||||
"@heroui/system": "^2.4.28",
|
||||
"@heroui/theme": "^2.4.26",
|
||||
"framer-motion": "^14.1.0",
|
||||
"lucide-react": "^1.55.0",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-router-dom": "^7.18.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"eslint": "^10.2.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"postcss": "^8.5.12",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"autoprefixer": "^10.6.1",
|
||||
"oxlint": "^1.81.0",
|
||||
"postcss": "^8.5.29",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^8.0.10"
|
||||
"vite": "^8.3.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 4.9 KiB After Width: | Height: | Size: 4.9 KiB |
|
|
@ -1,15 +1,63 @@
|
|||
import { RouterProvider } from "react-router-dom";
|
||||
import { router } from "./router";
|
||||
import { AuthProvider } from "./contexts/AuthContext";
|
||||
import { SystemStatusBadge } from "./components/SystemStatusBadge";
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { ThemeProvider } from './contexts/ThemeContext'
|
||||
import { AuthProvider } from './contexts/AuthContext'
|
||||
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 (
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<AuthProvider>
|
||||
<SystemStatusBadge />
|
||||
<RouterProvider router={router} />
|
||||
<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
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 4 KiB After Width: | Height: | Size: 4 KiB |
|
Before Width: | Height: | Size: 8.5 KiB After Width: | Height: | Size: 8.5 KiB |
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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} />
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}</>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
555
frontend/src/components/dashboard/BiUniformCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
367
frontend/src/components/dashboard/CreateBiCardModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
308
frontend/src/components/dashboard/CustomizeDashboardModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -77,6 +77,7 @@ export const Sidebar = ({ isMobileOpen, onCloseMobile }: SidebarProps) => {
|
|||
group: 'Geral',
|
||||
links: [
|
||||
{ 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" /> },
|
||||
],
|
||||
},
|
||||
|
|
@ -233,14 +234,14 @@ export const Sidebar = ({ isMobileOpen, onCloseMobile }: SidebarProps) => {
|
|||
{/* Active Running Task Activity Status Banner */}
|
||||
{activeTask && (
|
||||
<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 gap-2 truncate">
|
||||
<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="relative inline-flex rounded-full h-2 w-2 bg-teal-400" />
|
||||
<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-600 dark:bg-teal-400" />
|
||||
</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
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -260,8 +261,8 @@ export const Sidebar = ({ isMobileOpen, onCloseMobile }: SidebarProps) => {
|
|||
{activeTask.title}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-1.5 font-mono text-sm font-extrabold text-teal-400">
|
||||
<Clock className="w-3.5 h-3.5 text-teal-400 animate-pulse" />
|
||||
<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-700 dark:text-teal-400 animate-pulse" />
|
||||
<span>{formatSecondsToTimer(activeSeconds)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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: [],
|
||||
}
|
||||
956
frontend/src/components/tasks/CreateTaskModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,41 +1,22 @@
|
|||
import React from 'react'
|
||||
import { Play, Square, Clock, Folder, Tag, MoreVertical, CheckCircle2, CircleDot } from 'lucide-react'
|
||||
import type { Task, TaskStatus } from '../../types/task'
|
||||
import { Play, Square, Clock, Folder, Tag, MoreVertical, CircleDot, ExternalLink } from 'lucide-react'
|
||||
import type { Task } from '../../types/task'
|
||||
import { isTaskRunning, calculateTaskTotalSeconds, formatSecondsToTimer } from '../../lib/timeUtils'
|
||||
|
||||
interface TaskCardProps {
|
||||
task: Task
|
||||
onToggleTimer: (task: Task) => void
|
||||
onStatusChange: (task: Task, newStatus: TaskStatus) => void
|
||||
onEdit?: (task: Task) => void
|
||||
onOpenHistory?: (task: Task) => void
|
||||
isTrackingThis?: boolean
|
||||
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> = ({
|
||||
task,
|
||||
onToggleTimer,
|
||||
onStatusChange,
|
||||
onEdit,
|
||||
onOpenHistory,
|
||||
isTrackingThis,
|
||||
currentSeconds,
|
||||
}) => {
|
||||
|
|
@ -92,9 +73,24 @@ export const TaskCard: React.FC<TaskCardProps> = ({
|
|||
{task.category.name}
|
||||
</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>
|
||||
|
||||
{/* More Actions Menu */}
|
||||
{(onEdit || onOpenHistory) && (
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -118,32 +114,7 @@ export const TaskCard: React.FC<TaskCardProps> = ({
|
|||
}}
|
||||
/>
|
||||
<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">
|
||||
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
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
|
|
@ -155,12 +126,27 @@ export const TaskCard: React.FC<TaskCardProps> = ({
|
|||
>
|
||||
Editar Detalhes
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{onOpenHistory && (
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
|
||||
{/* Title */}
|
||||
|
|
@ -205,13 +191,18 @@ export const TaskCard: React.FC<TaskCardProps> = ({
|
|||
{/* Timer button & display */}
|
||||
<div className="flex items-center gap-2">
|
||||
<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
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
|
@ -225,13 +216,13 @@ export const TaskCard: React.FC<TaskCardProps> = ({
|
|||
className={`p-1.5 rounded-lg transition-all duration-200 ${
|
||||
running
|
||||
? '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 ? (
|
||||
<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>
|
||||
</div>
|
||||
295
frontend/src/components/tasks/TaskKanbanView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
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 { isTaskRunning, calculateTaskTotalSeconds, formatSecondsToTimer } from '../../lib/timeUtils'
|
||||
import { CustomSelect } from '../ui/Select'
|
||||
|
|
@ -9,6 +9,7 @@ interface TaskListViewProps {
|
|||
onToggleTimer: (task: Task) => void
|
||||
onStatusChange: (task: Task, newStatus: TaskStatus) => void
|
||||
onEditTask: (task: Task) => void
|
||||
onOpenHistory?: (task: Task) => void
|
||||
activeTaskId?: number | null
|
||||
activeTaskSeconds?: number
|
||||
}
|
||||
|
|
@ -19,6 +20,7 @@ export const TaskListView: React.FC<TaskListViewProps> = ({
|
|||
onToggleTimer,
|
||||
onStatusChange,
|
||||
onEditTask,
|
||||
onOpenHistory,
|
||||
activeTaskId,
|
||||
activeTaskSeconds,
|
||||
}) => {
|
||||
|
|
@ -58,12 +60,26 @@ export const TaskListView: React.FC<TaskListViewProps> = ({
|
|||
{/* Task Title & Description */}
|
||||
<td className="py-3.5 px-4 max-w-xs">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<span
|
||||
onClick={() => onEditTask(task)}
|
||||
className="font-medium text-foreground hover:text-brand cursor-pointer transition-colors truncate"
|
||||
>
|
||||
{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 && (
|
||||
<span className="text-muted text-[11px] truncate max-w-sm">
|
||||
{task.description}
|
||||
|
|
@ -146,13 +162,15 @@ export const TaskListView: React.FC<TaskListViewProps> = ({
|
|||
{/* Time Spent */}
|
||||
<td className="py-3.5 px-4 whitespace-nowrap">
|
||||
<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
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
</td>
|
||||
|
|
@ -168,13 +186,13 @@ export const TaskListView: React.FC<TaskListViewProps> = ({
|
|||
className={`p-1.5 rounded-lg transition-colors ${
|
||||
isRunning
|
||||
? '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 ? (
|
||||
<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>
|
||||
|
||||
|
|
@ -194,7 +212,7 @@ export const TaskListView: React.FC<TaskListViewProps> = ({
|
|||
className="fixed inset-0 z-40"
|
||||
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
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
|
@ -205,6 +223,19 @@ export const TaskListView: React.FC<TaskListViewProps> = ({
|
|||
>
|
||||
Editar Detalhes
|
||||
</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
|
||||
type="button"
|
||||
onClick={() => {
|
||||
|
|
@ -1,157 +1,162 @@
|
|||
import React, {
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { authService, type LoginResponse } from "../services/authService";
|
||||
useMemo,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import {
|
||||
authService,
|
||||
type LoginResponse,
|
||||
type CreateAccountResponse,
|
||||
type OrganizationMembership,
|
||||
} from '../services/authService'
|
||||
|
||||
interface AuthContextType {
|
||||
user: LoginResponse | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
activeOrg: LoginResponse["organizations"][number] | null;
|
||||
login: (email: string, password: string) => Promise<LoginResponse>;
|
||||
logout: () => void;
|
||||
refreshUser: () => Promise<void>;
|
||||
setActiveOrg: (id: number) => void;
|
||||
user: LoginResponse | null
|
||||
isAuthenticated: boolean
|
||||
isLoading: boolean
|
||||
activeOrg: OrganizationMembership | null
|
||||
login: (email: string, password: string) => Promise<LoginResponse>
|
||||
register: (name: string, email: string, password: string) => Promise<CreateAccountResponse>
|
||||
logout: () => void
|
||||
refreshUser: () => Promise<void>
|
||||
setActiveOrg: (id: number) => void
|
||||
}
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
USER: "user",
|
||||
ACTIVE_ORG_ID: "activeOrgId",
|
||||
} as const;
|
||||
USER: 'user',
|
||||
ACTIVE_ORG_ID: 'activeOrgId',
|
||||
} as const
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
|
||||
children,
|
||||
}) => {
|
||||
const [user, setUser] = useState<LoginResponse | null>(null);
|
||||
const [activeOrgId, setActiveOrgId] = useState<number | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
export const AuthProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [user, setUser] = useState<LoginResponse | null>(null)
|
||||
const [activeOrgId, setActiveOrgId] = useState<number | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const setupActiveOrg = useCallback((organizations: LoginResponse["organizations"]) => {
|
||||
const storedOrgId = localStorage.getItem(STORAGE_KEYS.ACTIVE_ORG_ID);
|
||||
const parsedStoredId = storedOrgId ? Number(storedOrgId) : null;
|
||||
const setupActiveOrg = useCallback((organizations: OrganizationMembership[]) => {
|
||||
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) {
|
||||
setActiveOrgId(parsedStoredId);
|
||||
setActiveOrgId(parsedStoredId)
|
||||
} else if (organizations.length > 0) {
|
||||
const firstOrgId = organizations[0].id;
|
||||
setActiveOrgId(firstOrgId);
|
||||
localStorage.setItem(STORAGE_KEYS.ACTIVE_ORG_ID, firstOrgId.toString());
|
||||
const firstOrgId = organizations[0].id
|
||||
setActiveOrgId(firstOrgId)
|
||||
localStorage.setItem(STORAGE_KEYS.ACTIVE_ORG_ID, firstOrgId.toString())
|
||||
}
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(() => {
|
||||
localStorage.removeItem(STORAGE_KEYS.USER);
|
||||
setUser(null);
|
||||
window.location.href = "/login";
|
||||
}, []);
|
||||
localStorage.removeItem(STORAGE_KEYS.USER)
|
||||
localStorage.removeItem(STORAGE_KEYS.ACTIVE_ORG_ID)
|
||||
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(
|
||||
(userData: LoginResponse) => {
|
||||
const issuedAt = new Date(userData.issuedAt).getTime();
|
||||
const expiresAt = issuedAt + userData.expiresIn * 1000;
|
||||
const now = Date.now();
|
||||
const issuedAt = new Date(userData.issuedAt).getTime()
|
||||
const expiresAt = issuedAt + userData.expiresIn * 1000
|
||||
const now = Date.now()
|
||||
|
||||
// Se já expirou
|
||||
if (now >= expiresAt) {
|
||||
logout();
|
||||
return false;
|
||||
logout()
|
||||
return false
|
||||
}
|
||||
|
||||
// Se falta menos de 5 minutos para vencer, tenta renovar
|
||||
const timeToExpire = expiresAt - now;
|
||||
if (timeToExpire < 300000) {
|
||||
// 5 min
|
||||
renewToken(userData.refreshToken);
|
||||
// Se expirar em menos de 5 min, renova
|
||||
if (expiresAt - now < 300000) {
|
||||
renewToken(userData.refreshToken)
|
||||
}
|
||||
|
||||
return true;
|
||||
return true
|
||||
},
|
||||
[logout],
|
||||
);
|
||||
|
||||
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();
|
||||
}
|
||||
};
|
||||
[logout, renewToken]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const storedUser = localStorage.getItem(STORAGE_KEYS.USER);
|
||||
const storedUser = localStorage.getItem(STORAGE_KEYS.USER)
|
||||
if (storedUser) {
|
||||
try {
|
||||
const userData: LoginResponse = JSON.parse(storedUser);
|
||||
const userData: LoginResponse = JSON.parse(storedUser)
|
||||
if (checkTokenExpiration(userData)) {
|
||||
setUser(userData);
|
||||
setupActiveOrg(userData.organizations);
|
||||
setUser(userData)
|
||||
setupActiveOrg(userData.organizations)
|
||||
}
|
||||
} catch (e) {
|
||||
localStorage.removeItem(STORAGE_KEYS.USER);
|
||||
} catch {
|
||||
localStorage.removeItem(STORAGE_KEYS.USER)
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
}, [checkTokenExpiration]);
|
||||
setIsLoading(false)
|
||||
}, [checkTokenExpiration, setupActiveOrg])
|
||||
|
||||
// Timer para verificar expiração periodicamente
|
||||
// Verificação periódica a cada 60 segundos
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
if (!user) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
checkTokenExpiration(user);
|
||||
}, 60000); // Verifica a cada minuto
|
||||
checkTokenExpiration(user)
|
||||
}, 60000)
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [user, checkTokenExpiration]);
|
||||
return () => clearInterval(interval)
|
||||
}, [user, checkTokenExpiration])
|
||||
|
||||
const login = async (email: string, password: string) => {
|
||||
const response = await authService.login(email, password);
|
||||
response.issuedAt = new Date().toISOString();
|
||||
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(response));
|
||||
setUser(response);
|
||||
setupActiveOrg(response.organizations);
|
||||
return response;
|
||||
};
|
||||
const response = await authService.login(email, password)
|
||||
response.issuedAt = new Date().toISOString()
|
||||
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(response))
|
||||
setUser(response)
|
||||
setupActiveOrg(response.organizations)
|
||||
return response
|
||||
}
|
||||
|
||||
const register = async (name: string, email: string, password: string) => {
|
||||
return authService.register(name, email, password)
|
||||
}
|
||||
|
||||
const refreshUser = async () => {
|
||||
try {
|
||||
const response = await authService.me();
|
||||
const storedUser = localStorage.getItem(STORAGE_KEYS.USER);
|
||||
const response = await authService.me()
|
||||
const storedUser = localStorage.getItem(STORAGE_KEYS.USER)
|
||||
if (storedUser) {
|
||||
const userData = JSON.parse(storedUser);
|
||||
const updatedUser = { ...userData, ...response };
|
||||
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(updatedUser));
|
||||
setUser(updatedUser);
|
||||
|
||||
// Update active org if not set or if current active org is gone
|
||||
setupActiveOrg(updatedUser.organizations);
|
||||
const userData = JSON.parse(storedUser)
|
||||
const updatedUser = { ...userData, ...response }
|
||||
localStorage.setItem(STORAGE_KEYS.USER, JSON.stringify(updatedUser))
|
||||
setUser(updatedUser)
|
||||
setupActiveOrg(updatedUser.organizations)
|
||||
}
|
||||
} catch {
|
||||
// Ignora erro
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to refresh user profile", error);
|
||||
}
|
||||
};
|
||||
|
||||
const setActiveOrg = (id: number) => {
|
||||
setActiveOrgId(id);
|
||||
localStorage.setItem(STORAGE_KEYS.ACTIVE_ORG_ID, id.toString());
|
||||
};
|
||||
setActiveOrgId(id)
|
||||
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, activeOrgId],
|
||||
);
|
||||
[user, activeOrgId]
|
||||
)
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
|
|
@ -161,6 +166,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||
isLoading,
|
||||
activeOrg,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
refreshUser,
|
||||
setActiveOrg,
|
||||
|
|
@ -168,13 +174,13 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({
|
|||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useAuth must be used within an AuthProvider");
|
||||
const context = useContext(AuthContext)
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider')
|
||||
}
|
||||
return context;
|
||||
};
|
||||
return context
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
--font-sans: 'Plus Jakarta Sans', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||
|
||||
--color-app-bg: #f5f1e8;
|
||||
--color-app-bg-strong: #ebe4d7;
|
||||
--color-surface: #fffdf8;
|
||||
--color-surface-muted: #f6efe2;
|
||||
--color-border-soft: #e7dcc9;
|
||||
--color-border-strong: #cdbba0;
|
||||
--color-text-main: #1f2937;
|
||||
--color-text-muted: #6b7280;
|
||||
--color-app-bg: var(--app-bg);
|
||||
--color-app-bg-strong: var(--app-bg-strong);
|
||||
--color-surface: var(--surface);
|
||||
--color-surface-muted: var(--surface-muted);
|
||||
--color-surface-raised: var(--surface-raised);
|
||||
--color-surface-sunken: var(--surface-sunken);
|
||||
--color-border: var(--border);
|
||||
--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-hover: #115e59;
|
||||
--color-brand-strong: #115e59;
|
||||
--color-brand-soft: #ccfbf1;
|
||||
--color-brand-soft: var(--brand-soft);
|
||||
|
||||
--color-accent: #d97706;
|
||||
--color-accent-soft: #ffedd5;
|
||||
--color-accent-hover: #b45309;
|
||||
--color-accent-soft: var(--accent-soft);
|
||||
|
||||
--color-success: #10b981;
|
||||
--color-success-soft: #ecfdf5;
|
||||
--color-success-soft: rgba(16, 185, 129, 0.15);
|
||||
--color-warning: #f59e0b;
|
||||
--color-warning-soft: #fffbeb;
|
||||
--color-warning-soft: rgba(245, 158, 11, 0.15);
|
||||
--color-danger: #ef4444;
|
||||
--color-danger-soft: #fef2f2;
|
||||
--color-danger-soft: rgba(239, 68, 68, 0.15);
|
||||
|
||||
--shadow-panel:
|
||||
0 22px 60px rgba(79, 62, 35, 0.08), 0 6px 18px rgba(79, 62, 35, 0.06);
|
||||
--shadow-panel: var(--shadow-panel);
|
||||
}
|
||||
|
||||
:root {
|
||||
color: var(--color-text-main);
|
||||
color: var(--color-foreground);
|
||||
background-color: var(--color-app-bg);
|
||||
font-family: var(--font-sans);
|
||||
font-synthesis: none;
|
||||
|
|
@ -51,8 +100,64 @@ body,
|
|||
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
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%);
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
return `${API_BASE_URL}${path}`
|
||||
}
|
||||
|
||||
type RequestOptions = Omit<RequestInit, 'body'> & {
|
||||
body?: any
|
||||
body?: unknown
|
||||
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
|
||||
|
||||
let authHeader = {}
|
||||
let authHeader: Record<string, string> = {}
|
||||
if (!skipAuth) {
|
||||
const storedUser = localStorage.getItem('user')
|
||||
if (storedUser) {
|
||||
try {
|
||||
const userData = JSON.parse(storedUser)
|
||||
authHeader = { 'Authorization': `Bearer ${userData.jwt}` }
|
||||
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: {
|
||||
...defaultHeaders,
|
||||
...authHeader,
|
||||
...headers,
|
||||
...((headers as Record<string, string>) || {}),
|
||||
},
|
||||
body: isFormData ? body : (body !== undefined && body !== null ? JSON.stringify(body) : undefined),
|
||||
})
|
||||
|
||||
const text = await response.text()
|
||||
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)
|
||||
|
|
|
|||
135
frontend/src/lib/timeUtils.ts
Normal 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]
|
||||
}
|
||||
|
||||
120
frontend/src/lib/userGoals.ts
Normal 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
|
||||
}
|
||||
231
frontend/src/lib/userPreferences.ts
Normal 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
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
import App from './App'
|
||||
import { HeroUIProvider } from '@heroui/system'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<HeroUIProvider>
|
||||
<App />
|
||||
</HeroUIProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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="flex items-center justify-between mb-3">
|
||||
<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" />
|
||||
</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="flex items-center justify-between mb-3">
|
||||
<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" />
|
||||
</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}%
|
||||
</p>
|
||||
<p className="text-[11px] text-muted mt-1">
|
||||
|
|
@ -310,7 +310,7 @@ export const AnalyticsPage: React.FC = () => {
|
|||
</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
|
||||
</p>
|
||||
<p className="text-[11px] text-muted mt-1">
|
||||
|
|
@ -515,12 +515,12 @@ export const AnalyticsPage: React.FC = () => {
|
|||
{member.totalCount}
|
||||
</td>
|
||||
<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" />
|
||||
{member.completedCount}
|
||||
</span>
|
||||
</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)}
|
||||
</td>
|
||||
</tr>
|
||||
|
|
@ -1,294 +1,447 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'framer-motion'
|
||||
import { ArrowRight, Loader2, Pencil, Plus, Search, Tag, X } from 'lucide-react'
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import {
|
||||
Tag,
|
||||
Plus,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
ListTodo,
|
||||
Sparkles,
|
||||
ArrowRight,
|
||||
Edit2,
|
||||
X,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { categoryService } from '../services/categoryService'
|
||||
import type { Category, CategoryPayload } from '../types/category'
|
||||
import { CategoryIcon } from '../components/CategoryIcon'
|
||||
import { IconPicker } from '../components/IconPicker'
|
||||
import { taskService } from '../services/taskService'
|
||||
import type { Category, Task } from '../types/task'
|
||||
import { calculateTaskTotalSeconds, formatSecondsToHuman } from '../lib/timeUtils'
|
||||
|
||||
const DEFAULT_COLOR = '#7C3AED'
|
||||
const DEFAULT_ICON = 'Tag'
|
||||
const COLOR_PRESETS = [
|
||||
'#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 = () => {
|
||||
const { activeOrg } = useAuth()
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
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 navigate = useNavigate()
|
||||
|
||||
const fetchCategories = async () => {
|
||||
if (!activeOrg) return
|
||||
setIsLoading(true)
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
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 {
|
||||
const data = await categoryService.listByOrganization(activeOrg.id)
|
||||
setCategories(data)
|
||||
} catch (err) {
|
||||
console.error('Falha ao carregar categorias', err)
|
||||
setError('Não foi possível carregar as categorias.')
|
||||
setRefreshing(true)
|
||||
const [catsRes, tasksRes] = await Promise.all([
|
||||
categoryService.listCategories(activeOrgId),
|
||||
taskService.listTasks({ organizationId: activeOrgId }).catch(() => []),
|
||||
])
|
||||
setCategories(catsRes || [])
|
||||
setTasks(tasksRes || [])
|
||||
} catch (err: unknown) {
|
||||
console.error(err)
|
||||
setError('Falha ao carregar categorias.')
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [activeOrgId])
|
||||
|
||||
useEffect(() => {
|
||||
fetchCategories()
|
||||
}, [activeOrg])
|
||||
loadData()
|
||||
}, [loadData])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
setForm({ name: '', color: DEFAULT_COLOR, icon: DEFAULT_ICON })
|
||||
setError(null)
|
||||
setShowForm(true)
|
||||
// Aggregate category statistics
|
||||
const categoryStats = useMemo(() => {
|
||||
const statsMap = new Map<
|
||||
number,
|
||||
{ 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,
|
||||
}
|
||||
|
||||
const openEdit = (category: Category) => {
|
||||
setEditing(category)
|
||||
setForm({ name: category.name, color: category.color, icon: category.icon })
|
||||
setError(null)
|
||||
setShowForm(true)
|
||||
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 handleSubmit = async (e: React.FormEvent) => {
|
||||
const handleOpenEditModal = (cat: Category) => {
|
||||
setEditingCategory(cat)
|
||||
setName(cat.name)
|
||||
setColor(cat.color || COLOR_PRESETS[0])
|
||||
setIsModalOpen(true)
|
||||
}
|
||||
|
||||
const handleSaveCategory = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!activeOrg) return
|
||||
setIsSubmitting(true)
|
||||
if (!activeOrgId || !name.trim()) return
|
||||
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
if (editing) {
|
||||
const updated = await categoryService.update(activeOrg.id, editing.id, form)
|
||||
setCategories((prev) => prev.map((c) => (c.id === updated.id ? updated : c)))
|
||||
if (editingCategory) {
|
||||
const updated = await categoryService.updateCategory(activeOrgId, editingCategory.id, {
|
||||
name: name.trim(),
|
||||
color,
|
||||
icon: 'tag',
|
||||
})
|
||||
setCategories((prev) =>
|
||||
prev.map((c) => (c.id === updated.id ? updated : c))
|
||||
)
|
||||
} 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])
|
||||
}
|
||||
setShowForm(false)
|
||||
} catch (err) {
|
||||
console.error('Falha ao salvar categoria', err)
|
||||
setIsModalOpen(false)
|
||||
} catch (err: unknown) {
|
||||
console.error(err)
|
||||
setError(err instanceof Error ? err.message : 'Falha ao salvar categoria.')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = categories.filter((c) =>
|
||||
c.name.toLowerCase().includes(searchQuery.toLowerCase()),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-10 pb-20">
|
||||
<header className="flex flex-col gap-6 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-6 max-w-7xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-brand font-black uppercase tracking-widest text-[10px] mb-2">
|
||||
<Tag size={14} />
|
||||
Workspace
|
||||
</div>
|
||||
<h1 className="text-4xl font-black tracking-tight text-text-main">
|
||||
Categorias em <span className="text-brand">{activeOrg?.name ?? '...'}</span>
|
||||
<div className="flex items-center gap-2 mb-1.5 flex-wrap">
|
||||
<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">
|
||||
Categorias & Tags
|
||||
</h1>
|
||||
<p className="mt-2 text-lg text-text-muted">
|
||||
Crie categorias para classificar e medir o tempo das suas tarefas.
|
||||
<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>
|
||||
<p className="text-xs text-muted">
|
||||
Classifique suas tarefas por tipos de trabalho (Bug, Feature, Reunião, Design) para métricas precisas.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative hidden md:block">
|
||||
<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
|
||||
onClick={openCreate}
|
||||
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"
|
||||
>
|
||||
<Plus size={18} />
|
||||
Nova Categoria
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="h-32 animate-pulse rounded-[2rem] border border-border-soft bg-surface-muted" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<section className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{filtered.map((category, index) => (
|
||||
<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>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<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"
|
||||
type="button"
|
||||
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"
|
||||
>
|
||||
<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>
|
||||
<h3 className="text-xl font-bold text-text-main mb-2">Nenhuma categoria cadastrada</h3>
|
||||
<p className="text-text-muted max-w-xs mx-auto font-medium">
|
||||
Crie sua primeira categoria para começar a classificar tarefas e medir tempo.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AnimatePresence>
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-app-bg/80 backdrop-blur-sm">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
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"
|
||||
>
|
||||
<button
|
||||
onClick={() => setShowForm(false)}
|
||||
className="absolute top-6 right-6 text-text-muted hover:text-brand transition-colors"
|
||||
>
|
||||
<X size={22} />
|
||||
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin text-accent' : ''}`} />
|
||||
</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 }}
|
||||
<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"
|
||||
>
|
||||
<CategoryIcon iconName={form.icon} color={form.color} size={24} />
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>Nova Categoria</span>
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-black text-text-main">
|
||||
{editing ? 'Editar categoria' : 'Nova categoria'}
|
||||
</h2>
|
||||
<p className="text-sm text-text-muted">
|
||||
Defina nome, cor e ícone para identificar suas tarefas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<label className="block space-y-2">
|
||||
<span className="text-sm font-bold text-text-main">Nome</span>
|
||||
<input
|
||||
autoFocus
|
||||
required
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="Ex.: Bug, Feature, Reunião"
|
||||
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"
|
||||
/>
|
||||
</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 className="space-y-2">
|
||||
<span className="text-sm font-bold text-text-main">Ícone</span>
|
||||
<IconPicker
|
||||
value={form.icon}
|
||||
onChange={(icon) => setForm({ ...form, icon })}
|
||||
color={form.color}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<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">
|
||||
<X size={16} />
|
||||
{error}
|
||||
<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">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{/* 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>
|
||||
) : filteredCategories.length === 0 ? (
|
||||
<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">
|
||||
<div className="w-14 h-14 rounded-2xl bg-accent/10 border border-accent/20 flex items-center justify-center text-accent mb-4">
|
||||
<Tag className="w-7 h-7" />
|
||||
</div>
|
||||
<h3 className="text-base font-bold text-foreground mb-1">
|
||||
Nenhum categoria cadastrada
|
||||
</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>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowForm(false)}
|
||||
className="px-5 py-3 text-sm font-bold text-text-muted hover:text-text-main"
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Modal Criar/Editar Categoria */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6">
|
||||
<div
|
||||
className="fixed inset-0 bg-black/70 backdrop-blur-xs"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
/>
|
||||
<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">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-8 h-8 rounded-lg bg-accent/15 border border-accent/30 flex items-center justify-center text-accent">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-bold text-foreground">
|
||||
{editingCategory ? 'Editar Categoria' : 'Nova Categoria'}
|
||||
</h2>
|
||||
<p className="text-xs text-muted">
|
||||
{editingCategory
|
||||
? 'Atualize o nome ou cor da tag.'
|
||||
: 'Crie uma categoria para agrupar tipos de trabalho.'}
|
||||
</p>
|
||||
</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={handleSaveCategory} className="p-5 space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-foreground mb-1.5">
|
||||
Nome da Categoria <span className="text-accent">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
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 }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-border flex items-center justify-end gap-2.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
disabled={submitting}
|
||||
className="px-4 py-2 rounded-xl border border-border bg-surface text-xs font-medium text-foreground hover:bg-surface-raised"
|
||||
>
|
||||
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"
|
||||
disabled={submitting || !name.trim()}
|
||||
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"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="animate-spin" size={18} />
|
||||
) : (
|
||||
<>
|
||||
{editing ? 'Salvar' : 'Criar categoria'}
|
||||
<ArrowRight size={18} />
|
||||
</>
|
||||
)}
|
||||
{submitting && <Loader2 className="w-3.5 h-3.5 animate-spin" />}
|
||||
{editingCategory ? 'Salvar Alterações' : 'Criar Categoria'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||