first commit
This commit is contained in:
40
.env.example
Normal file
40
.env.example
Normal file
@@ -0,0 +1,40 @@
|
||||
APP_ENV=production
|
||||
APP_PUBLIC_URL=https://app.example.com.br
|
||||
APP_TIMEZONE=America/Sao_Paulo
|
||||
HTTP_ADDR=:8080
|
||||
LOG_LEVEL=info
|
||||
BACKEND_PORT=8080
|
||||
FRONTEND_PORT=5173
|
||||
|
||||
SUPER_ADMIN_EMAIL=admin@example.com.br
|
||||
SUPER_ADMIN_PASSWORD=change-me-secure-password
|
||||
|
||||
POSTGRES_DB=mira
|
||||
POSTGRES_USER=mira
|
||||
POSTGRES_PASSWORD=change-me-secure-password
|
||||
DATABASE_URL=postgres://mira:change-me-secure-password@postgres:5432/mira?sslmode=disable
|
||||
|
||||
JWT_SECRET=change-me-with-a-long-random-secret
|
||||
JWT_ACCESS_TOKEN_TTL_MINUTES=15
|
||||
JWT_REFRESH_TOKEN_TTL_DAYS=7
|
||||
|
||||
CORS_ALLOWED_ORIGINS=https://app.example.com.br
|
||||
|
||||
CALENDAR_PROVIDER=brasilapi
|
||||
CALENDAR_API_BASE_URL=https://brasilapi.com.br
|
||||
CALENDAR_API_KEY=
|
||||
CALENDAR_CACHE_TTL_HOURS=24
|
||||
|
||||
SMTP_HOST=smtp.example.com.br
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_EMAIL=no-reply@example.com.br
|
||||
SMTP_FROM_NAME=Mira
|
||||
|
||||
UPLOAD_MAX_SIZE_MB=10
|
||||
UPLOAD_ALLOWED_MIME_TYPES=image/jpeg,image/png,image/webp,application/pdf
|
||||
STORAGE_DRIVER=local
|
||||
STORAGE_LOCAL_PATH=/var/lib/mira/uploads
|
||||
|
||||
VITE_API_BASE_URL=http://localhost:8080/api/v1
|
||||
52
.gitea/workflows/ci.yml
Normal file
52
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,52 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
cache-dependency-path: backend/go.sum
|
||||
- name: Download dependencies
|
||||
working-directory: backend
|
||||
run: go mod download
|
||||
- name: Vet
|
||||
working-directory: backend
|
||||
run: go vet ./...
|
||||
- name: Test
|
||||
working-directory: backend
|
||||
run: go test ./...
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
- name: Install dependencies
|
||||
working-directory: frontend
|
||||
run: npm install
|
||||
- name: Build
|
||||
working-directory: frontend
|
||||
run: npm run build
|
||||
|
||||
docker:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [backend, frontend]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build backend image
|
||||
run: docker build ./backend
|
||||
- name: Build frontend image
|
||||
run: docker build ./frontend --build-arg VITE_API_BASE_URL=http://localhost:8080/api/v1
|
||||
17
.gitignore
vendored
Normal file
17
.gitignore
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
coverage/
|
||||
|
||||
.DS_Store
|
||||
*.log
|
||||
|
||||
/frontend/node_modules/
|
||||
/frontend/dist/
|
||||
/frontend/.vite/
|
||||
/backend/tmp/
|
||||
/new_frontend/
|
||||
653
AGENT.md
Normal file
653
AGENT.md
Normal file
@@ -0,0 +1,653 @@
|
||||
# AGENT.md
|
||||
|
||||
## Overview
|
||||
|
||||
This project is a mobile-first web application for an advertising agency to organize editorial calendars, important dates, deliveries, assets, and client-facing planning.
|
||||
|
||||
The main goal is to replace the current messy Trello workflow with a centralized, secure, responsive application where:
|
||||
|
||||
- agency staff can view and manage all clients they are allowed to access;
|
||||
- each client can access only their own area;
|
||||
- the calendar shows Brazilian national holidays, commemorative dates, and custom dates;
|
||||
- specific days can contain content such as post copy, images, notes, status, and attachments;
|
||||
- the dashboard includes a weekly overview so the team can quickly understand what is done and what is still pending.
|
||||
|
||||
The application itself must be fully localized in Brazilian Portuguese (`pt-BR`). Project documentation and developer-facing instructions may be written in English unless explicitly requested otherwise.
|
||||
|
||||
## Audience
|
||||
|
||||
- Internal users: advertising team, social media team, account managers, designers, managers, and administrators.
|
||||
- External users: agency clients, initially with read-only access.
|
||||
- Product tone: clear, professional, direct, and operational.
|
||||
- UI language: Brazilian Portuguese (`pt-BR`).
|
||||
|
||||
## Technical Stack
|
||||
|
||||
### Frontend
|
||||
|
||||
- JavaScript.
|
||||
- React 19.
|
||||
- shadcn/ui.
|
||||
- Mobile-first and fully responsive.
|
||||
- UI text in `pt-BR`.
|
||||
- Accessible components, including keyboard navigation where applicable.
|
||||
|
||||
### Backend
|
||||
|
||||
- Go 1.25.
|
||||
- Gin.
|
||||
- REST API.
|
||||
- Strong input validation.
|
||||
- Authentication and role-based authorization.
|
||||
- Clean, readable, modular structure.
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- Repository: Gitea.
|
||||
- Hosting: Portainer.
|
||||
- Configuration: environment variables only.
|
||||
- Delivery: Dockerfile, Docker Compose, and CI/CD pipelines.
|
||||
|
||||
## Product Identity
|
||||
|
||||
- Project name: `Mira`.
|
||||
- Main brand color: orange.
|
||||
- The UI must support light and dark themes.
|
||||
- Theme switching must be available in the authenticated application shell.
|
||||
- The design should feel operational and polished, not like a marketing landing page.
|
||||
|
||||
## User Roles
|
||||
|
||||
### Super Admin
|
||||
|
||||
The main administrative user for the agency.
|
||||
|
||||
- Created from Portainer environment variables.
|
||||
- Can access all clients.
|
||||
- Can create, edit, and remove users.
|
||||
- Can create email invitations.
|
||||
- Can manage clients, calendars, custom dates, and content.
|
||||
- Can grant access to internal users and clients.
|
||||
|
||||
Expected variables:
|
||||
|
||||
- `SUPER_ADMIN_EMAIL`
|
||||
- `SUPER_ADMIN_PASSWORD`
|
||||
|
||||
### Agency User
|
||||
|
||||
Internal operational user.
|
||||
|
||||
- Can view assigned clients.
|
||||
- Can create and edit calendar content according to permissions.
|
||||
- Can view the weekly dashboard.
|
||||
- Can attach assets to calendar days.
|
||||
- Can update delivery/content statuses.
|
||||
|
||||
### Client Viewer
|
||||
|
||||
External user linked to a specific client.
|
||||
|
||||
- Can log in.
|
||||
- Can view only their own client area.
|
||||
- Cannot create, edit, or delete content in the first version.
|
||||
- Cannot access other clients.
|
||||
|
||||
## Authentication and Invitations
|
||||
|
||||
- The application must have a login screen.
|
||||
- There must be no public registration screen.
|
||||
- The super admin can create users and send email invitations.
|
||||
- Invitations must use a unique token, expiration date, and single-use flow.
|
||||
- Passwords must be stored with a strong hash, never as plaintext.
|
||||
- Password reset, if implemented, must use temporary tokens.
|
||||
- Sessions must expire and be revocable.
|
||||
|
||||
## Permissions Model
|
||||
|
||||
Use role-based access control with client scoping.
|
||||
|
||||
Minimum roles:
|
||||
|
||||
- `super_admin`
|
||||
- `agency_user`
|
||||
- `client_viewer`
|
||||
|
||||
Minimum rules:
|
||||
|
||||
- `super_admin` can access everything.
|
||||
- `agency_user` can access only assigned clients unless explicitly elevated.
|
||||
- `client_viewer` can access only their linked client.
|
||||
- Every sensitive route must validate authentication, role, and client scope.
|
||||
- Never rely on frontend-only access control.
|
||||
|
||||
## Core Modules
|
||||
|
||||
### Login
|
||||
|
||||
- Simple, secure, responsive screen.
|
||||
- Fields: email and password.
|
||||
- Error messages in Portuguese.
|
||||
- Brute-force protection.
|
||||
- Do not reveal whether an email exists.
|
||||
|
||||
### Weekly Dashboard
|
||||
|
||||
General view of the current week.
|
||||
|
||||
It should show:
|
||||
|
||||
- planned content and commitments for the week;
|
||||
- pending items;
|
||||
- completed items;
|
||||
- relevant holidays and commemorative dates;
|
||||
- filters by client, status, and owner;
|
||||
- clear highlight for today.
|
||||
|
||||
### Client Calendar
|
||||
|
||||
The core product surface.
|
||||
|
||||
It should support:
|
||||
|
||||
- monthly view;
|
||||
- weekly view;
|
||||
- day detail view;
|
||||
- Brazilian national holidays;
|
||||
- Brazilian commemorative dates;
|
||||
- client-specific custom dates;
|
||||
- content/posts attached to a date;
|
||||
- attachments such as images, files, and text;
|
||||
- content status.
|
||||
|
||||
### Day Content
|
||||
|
||||
Each day can contain one or more items.
|
||||
|
||||
Suggested fields:
|
||||
|
||||
- title;
|
||||
- description;
|
||||
- content type;
|
||||
- status;
|
||||
- owner;
|
||||
- client;
|
||||
- date;
|
||||
- optional time;
|
||||
- copy/text;
|
||||
- images and attachments;
|
||||
- internal notes;
|
||||
- client-visible notes;
|
||||
- change history.
|
||||
|
||||
Suggested statuses:
|
||||
|
||||
- `rascunho`
|
||||
- `planejado`
|
||||
- `em_producao`
|
||||
- `em_revisao`
|
||||
- `aprovado`
|
||||
- `publicado`
|
||||
- `cancelado`
|
||||
|
||||
Keep status values compatible with Portuguese UI labels.
|
||||
|
||||
### Custom Dates
|
||||
|
||||
Allow custom dates globally or per client.
|
||||
|
||||
Examples:
|
||||
|
||||
- company anniversary;
|
||||
- store anniversary;
|
||||
- institutional date;
|
||||
- recurring campaign;
|
||||
- commercial milestone;
|
||||
- internal event.
|
||||
|
||||
Suggested fields:
|
||||
|
||||
- name;
|
||||
- description;
|
||||
- date;
|
||||
- annual recurrence flag;
|
||||
- optional client;
|
||||
- visibility;
|
||||
- color or category.
|
||||
|
||||
### Clients
|
||||
|
||||
Each client has their own area.
|
||||
|
||||
Suggested fields:
|
||||
|
||||
- name;
|
||||
- slug;
|
||||
- status;
|
||||
- contacts;
|
||||
- linked users;
|
||||
- calendar settings;
|
||||
- custom dates;
|
||||
- display preferences.
|
||||
|
||||
## Brazilian Calendar
|
||||
|
||||
The application must support:
|
||||
|
||||
- Brazilian national holidays;
|
||||
- Brazilian commemorative dates;
|
||||
- internal custom dates;
|
||||
- client-specific custom dates.
|
||||
|
||||
Recommended implementation:
|
||||
|
||||
- keep an internal table/cache for holidays and commemorative dates;
|
||||
- sync holidays by year from a configurable external provider;
|
||||
- allow manual fallback if the external API fails;
|
||||
- separate official holidays from non-official commemorative dates;
|
||||
- allow administrative review of imported dates.
|
||||
|
||||
Provider decision:
|
||||
|
||||
- Initial provider: BrasilAPI.
|
||||
- Regional holidays, municipal holidays, client-specific dates, and agency-specific commemorative dates will be added manually through the application.
|
||||
- The backend must still use a provider abstraction so another provider can be added later without rewriting calendar features.
|
||||
|
||||
Providers considered:
|
||||
|
||||
- BrasilAPI: Brazilian public API with national holiday endpoints.
|
||||
- Nager.Date: international public holiday API by country, with public endpoints that do not require an API key.
|
||||
- Feriados API or feriados.dev: Brazil-focused services with national, state, and city coverage.
|
||||
- Calendarific: international paid/freemium API with support for countries and regions.
|
||||
|
||||
Initial recommendation:
|
||||
|
||||
- implement a backend `CalendarProvider` abstraction;
|
||||
- configure the provider through `CALENDAR_PROVIDER`, defaulting to `brasilapi`;
|
||||
- persist provider results in the database;
|
||||
- allow manual registration of commemorative dates;
|
||||
- never depend on real-time external API availability to render the calendar.
|
||||
|
||||
Expected variables:
|
||||
|
||||
- `CALENDAR_PROVIDER`
|
||||
- `CALENDAR_API_BASE_URL`
|
||||
- `CALENDAR_API_KEY`
|
||||
- `CALENDAR_CACHE_TTL_HOURS`
|
||||
|
||||
## Security
|
||||
|
||||
The project must strictly follow OWASP best practices.
|
||||
|
||||
Minimum requirements:
|
||||
|
||||
- validate and sanitize all inputs;
|
||||
- protect against XSS;
|
||||
- protect against CSRF where applicable;
|
||||
- protect against SQL injection using parameterized queries or safe ORM patterns;
|
||||
- enforce backend access control on all protected routes;
|
||||
- use strong password hashing;
|
||||
- use secure cookies if cookie-based sessions are used;
|
||||
- use restrictive CORS;
|
||||
- rate-limit login and sensitive routes;
|
||||
- set HTTP security headers;
|
||||
- avoid sensitive data in logs;
|
||||
- handle uploads securely;
|
||||
- limit file size;
|
||||
- validate MIME type and extension;
|
||||
- store attachments safely;
|
||||
- audit administrative actions;
|
||||
- use environment variables for secrets;
|
||||
- never commit credentials.
|
||||
|
||||
OWASP references to consider during implementation:
|
||||
|
||||
- OWASP Top 10.
|
||||
- OWASP ASVS.
|
||||
- OWASP Cheat Sheet Series.
|
||||
|
||||
## Suggested Repository Structure
|
||||
|
||||
```text
|
||||
.
|
||||
|-- AGENT.md
|
||||
|-- README.md
|
||||
|-- docker-compose.yml
|
||||
|-- .env.example
|
||||
|-- .gitea
|
||||
| `-- workflows
|
||||
| `-- ci.yml
|
||||
|-- frontend
|
||||
| |-- Dockerfile
|
||||
| |-- package.json
|
||||
| |-- src
|
||||
| | |-- app
|
||||
| | |-- components
|
||||
| | |-- features
|
||||
| | |-- lib
|
||||
| | |-- routes
|
||||
| | `-- styles
|
||||
| `-- public
|
||||
|-- backend
|
||||
| |-- Dockerfile
|
||||
| |-- go.mod
|
||||
| |-- cmd
|
||||
| | `-- api
|
||||
| | `-- main.go
|
||||
| |-- internal
|
||||
| | |-- auth
|
||||
| | |-- calendar
|
||||
| | |-- clients
|
||||
| | |-- config
|
||||
| | |-- content
|
||||
| | |-- database
|
||||
| | |-- mail
|
||||
| | |-- users
|
||||
| | `-- security
|
||||
| `-- migrations
|
||||
`-- docs
|
||||
|-- architecture.md
|
||||
|-- security.md
|
||||
`-- api.md
|
||||
```
|
||||
|
||||
## Database
|
||||
|
||||
Database decision: PostgreSQL.
|
||||
|
||||
PostgreSQL is the correct fit because the application is relational by nature: users, clients, permissions, invitations, calendar items, attachments, statuses, and audit logs all need strong consistency and queryable relationships.
|
||||
|
||||
Initial entities:
|
||||
|
||||
- users;
|
||||
- clients;
|
||||
- client_users;
|
||||
- invitations;
|
||||
- calendar_dates;
|
||||
- holidays;
|
||||
- commemorative_dates;
|
||||
- calendar_items;
|
||||
- calendar_item_attachments;
|
||||
- audit_logs;
|
||||
- sessions or refresh_tokens, depending on authentication strategy.
|
||||
|
||||
Use consistent timestamps:
|
||||
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
- `deleted_at` when soft delete is needed.
|
||||
|
||||
Timezone rules:
|
||||
|
||||
- handle dates and times carefully;
|
||||
- use `America/Sao_Paulo` as the default operational timezone;
|
||||
- store instants in UTC when time is involved;
|
||||
- store calendar-only dates as `date`, not timestamp.
|
||||
|
||||
## Uploads and Attachments
|
||||
|
||||
Attachments are a critical security surface.
|
||||
|
||||
Requirements:
|
||||
|
||||
- limit file size;
|
||||
- restrict allowed file types;
|
||||
- validate actual file content;
|
||||
- rename files in storage;
|
||||
- avoid serving uploads directly from the same domain without protection;
|
||||
- prevent execution of uploaded files;
|
||||
- store metadata in the database;
|
||||
- support logical deletion.
|
||||
|
||||
Expected variables:
|
||||
|
||||
- `UPLOAD_MAX_SIZE_MB`
|
||||
- `UPLOAD_ALLOWED_MIME_TYPES`
|
||||
- `STORAGE_DRIVER`
|
||||
- `STORAGE_LOCAL_PATH`
|
||||
- `STORAGE_S3_ENDPOINT`
|
||||
- `STORAGE_S3_BUCKET`
|
||||
- `STORAGE_S3_ACCESS_KEY`
|
||||
- `STORAGE_S3_SECRET_KEY`
|
||||
|
||||
## Email
|
||||
|
||||
Used for invitations and possibly password reset.
|
||||
|
||||
Expected variables:
|
||||
|
||||
- `SMTP_HOST`
|
||||
- `SMTP_PORT`
|
||||
- `SMTP_USER`
|
||||
- `SMTP_PASSWORD`
|
||||
- `SMTP_FROM_EMAIL`
|
||||
- `SMTP_FROM_NAME`
|
||||
- `APP_PUBLIC_URL`
|
||||
|
||||
## Minimum Environment Variables
|
||||
|
||||
```env
|
||||
APP_ENV=production
|
||||
APP_PUBLIC_URL=https://app.example.com.br
|
||||
APP_TIMEZONE=America/Sao_Paulo
|
||||
|
||||
SUPER_ADMIN_EMAIL=admin@example.com.br
|
||||
SUPER_ADMIN_PASSWORD=change-me-secure-password
|
||||
|
||||
DATABASE_URL=postgres://user:change-me-secure-password@postgres:5432/mira?sslmode=disable
|
||||
|
||||
JWT_SECRET=change-me-with-a-long-random-secret
|
||||
JWT_ACCESS_TOKEN_TTL_MINUTES=15
|
||||
JWT_REFRESH_TOKEN_TTL_DAYS=7
|
||||
|
||||
CORS_ALLOWED_ORIGINS=https://app.example.com.br
|
||||
|
||||
CALENDAR_PROVIDER=brasilapi
|
||||
CALENDAR_API_BASE_URL=https://brasilapi.com.br
|
||||
CALENDAR_API_KEY=
|
||||
CALENDAR_CACHE_TTL_HOURS=24
|
||||
|
||||
SMTP_HOST=smtp.example.com.br
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_FROM_EMAIL=no-reply@example.com.br
|
||||
SMTP_FROM_NAME=Mira
|
||||
|
||||
UPLOAD_MAX_SIZE_MB=10
|
||||
UPLOAD_ALLOWED_MIME_TYPES=image/jpeg,image/png,image/webp,application/pdf
|
||||
STORAGE_DRIVER=local
|
||||
STORAGE_LOCAL_PATH=/var/lib/mira/uploads
|
||||
```
|
||||
|
||||
## Backend API
|
||||
|
||||
Standards:
|
||||
|
||||
- versioned routes under `/api/v1`;
|
||||
- consistent JSON responses;
|
||||
- errors in `pt-BR`;
|
||||
- payload validation before business logic;
|
||||
- authentication middleware;
|
||||
- authorization middleware;
|
||||
- rate limit middleware;
|
||||
- security headers middleware;
|
||||
- structured logs.
|
||||
|
||||
Suggested initial routes:
|
||||
|
||||
```text
|
||||
POST /api/v1/auth/login
|
||||
POST /api/v1/auth/logout
|
||||
POST /api/v1/auth/refresh
|
||||
|
||||
GET /api/v1/me
|
||||
|
||||
GET /api/v1/clients
|
||||
POST /api/v1/clients
|
||||
GET /api/v1/clients/:clientId
|
||||
PATCH /api/v1/clients/:clientId
|
||||
|
||||
GET /api/v1/calendar/week
|
||||
GET /api/v1/clients/:clientId/calendar
|
||||
GET /api/v1/clients/:clientId/calendar/days/:date
|
||||
|
||||
POST /api/v1/clients/:clientId/calendar-items
|
||||
GET /api/v1/calendar-items/:itemId
|
||||
PATCH /api/v1/calendar-items/:itemId
|
||||
DELETE /api/v1/calendar-items/:itemId
|
||||
|
||||
POST /api/v1/calendar-items/:itemId/attachments
|
||||
DELETE /api/v1/attachments/:attachmentId
|
||||
|
||||
GET /api/v1/calendar/holidays
|
||||
POST /api/v1/commemorative-dates
|
||||
PATCH /api/v1/commemorative-dates/:dateId
|
||||
DELETE /api/v1/commemorative-dates/:dateId
|
||||
|
||||
GET /api/v1/users
|
||||
POST /api/v1/users/invitations
|
||||
POST /api/v1/invitations/:token/accept
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
Principles:
|
||||
|
||||
- mobile-first;
|
||||
- dense, clear, operational screens;
|
||||
- no landing page;
|
||||
- first screen after login should be the weekly dashboard;
|
||||
- calendar must be easy to navigate on mobile;
|
||||
- use shadcn/ui consistently;
|
||||
- keep visible UI text in `pt-BR`;
|
||||
- do not rely on color alone to communicate status;
|
||||
- support loading, error, empty, and success states.
|
||||
|
||||
Initial screens:
|
||||
|
||||
- login;
|
||||
- weekly dashboard;
|
||||
- client list;
|
||||
- client calendar;
|
||||
- day detail;
|
||||
- calendar item detail/create/edit;
|
||||
- users and invitations;
|
||||
- custom date settings.
|
||||
|
||||
## Gitea CI/CD
|
||||
|
||||
Minimum pipeline:
|
||||
|
||||
- frontend lint;
|
||||
- frontend tests;
|
||||
- frontend build;
|
||||
- backend lint/vet;
|
||||
- backend tests;
|
||||
- backend build;
|
||||
- Docker image builds;
|
||||
- vulnerability checks where possible.
|
||||
|
||||
Expected file:
|
||||
|
||||
- `.gitea/workflows/ci.yml`
|
||||
|
||||
## Docker and Portainer
|
||||
|
||||
Expected deliverables:
|
||||
|
||||
- `frontend/Dockerfile`
|
||||
- `backend/Dockerfile`
|
||||
- `docker-compose.yml`
|
||||
- `.env.example`
|
||||
|
||||
Docker Compose should include:
|
||||
|
||||
- frontend;
|
||||
- backend;
|
||||
- PostgreSQL;
|
||||
- database volume;
|
||||
- local upload volume when `STORAGE_DRIVER=local`.
|
||||
|
||||
All configuration must come from environment variables to support Portainer-based operation.
|
||||
|
||||
## Testing
|
||||
|
||||
Backend:
|
||||
|
||||
- business rule unit tests;
|
||||
- authorization tests;
|
||||
- calendar tests;
|
||||
- invitation tests;
|
||||
- validation tests;
|
||||
- upload tests.
|
||||
|
||||
Frontend:
|
||||
|
||||
- tests for critical components;
|
||||
- calendar rendering tests;
|
||||
- login flow tests;
|
||||
- role-based visibility tests.
|
||||
|
||||
E2E, when possible:
|
||||
|
||||
- super admin login;
|
||||
- client creation;
|
||||
- user invitation;
|
||||
- client read-only access;
|
||||
- calendar item creation;
|
||||
- weekly view.
|
||||
|
||||
## MVP Acceptance Criteria
|
||||
|
||||
The MVP must allow:
|
||||
|
||||
- login with the super admin loaded from environment variables;
|
||||
- client creation;
|
||||
- internal and client users created through invitations;
|
||||
- role-based access control;
|
||||
- clients to view only their own page;
|
||||
- agency users to view all permitted clients;
|
||||
- monthly calendar per client;
|
||||
- general weekly dashboard;
|
||||
- Brazilian national holiday display;
|
||||
- custom date registration;
|
||||
- content creation per day;
|
||||
- attachment of allowed images or files;
|
||||
- responsive mobile UI;
|
||||
- Docker Compose deployment through Portainer;
|
||||
- basic Gitea CI/CD pipeline.
|
||||
|
||||
## Out of Scope for the Initial MVP
|
||||
|
||||
These can be planned after the MVP:
|
||||
|
||||
- public registration;
|
||||
- client editing permissions;
|
||||
- formal approval workflows;
|
||||
- direct Instagram, Facebook, TikTok, or LinkedIn integration;
|
||||
- automated publishing;
|
||||
- real-time comments;
|
||||
- push notifications;
|
||||
- full kanban board;
|
||||
- complex subscription-based multi-tenancy.
|
||||
|
||||
## Implementation Principles
|
||||
|
||||
- Prioritize clarity and security.
|
||||
- Keep frontend, backend, and infrastructure separated.
|
||||
- Avoid hard coupling to a single calendar provider.
|
||||
- Protect all backend routes.
|
||||
- Log important administrative actions.
|
||||
- Create reusable components only when there is a real need.
|
||||
- Prefer clear names over premature abstractions.
|
||||
- Document relevant technical decisions in `docs/architecture.md`.
|
||||
- Never commit secrets.
|
||||
|
||||
## Calendar References
|
||||
|
||||
- [BrasilAPI](https://brasilapi.com.br/)
|
||||
- [Nager.Date](https://date.nager.at/)
|
||||
- [Feriados API](https://feriadosapi.com/docs)
|
||||
- [feriados.dev](https://feriados.dev/documentacao)
|
||||
- [Calendarific](https://calendarific.com/)
|
||||
44
README.md
Normal file
44
README.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Mira
|
||||
|
||||
Mobile-first editorial calendar application for an advertising agency and its clients.
|
||||
|
||||
The application UI must be localized in Brazilian Portuguese (`pt-BR`), while developer documentation can stay in English.
|
||||
|
||||
## Stack
|
||||
|
||||
- Frontend: React 19, JavaScript, shadcn/ui direction, CSS variables for light/dark themes.
|
||||
- Backend: Go 1.25, Gin.
|
||||
- Database: PostgreSQL.
|
||||
- Calendar provider: BrasilAPI for Brazilian national holidays.
|
||||
- Deployment: Docker Compose/Portainer.
|
||||
- Repository and CI/CD: Gitea.
|
||||
|
||||
## Local Start
|
||||
|
||||
1. Copy `.env.example` to `.env`.
|
||||
2. Replace secrets and passwords.
|
||||
3. Start the stack:
|
||||
|
||||
```sh
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Default local URLs:
|
||||
|
||||
- Frontend: `http://localhost:5173`
|
||||
- Backend health: `http://localhost:8080/healthz`
|
||||
- Backend API: `http://localhost:8080/api/v1`
|
||||
|
||||
## Current Status
|
||||
|
||||
This is the initial scaffold. The structure is ready for MVP implementation, but authentication, persistence, invitations, and calendar item CRUD are still placeholders.
|
||||
|
||||
## Key Decisions
|
||||
|
||||
- Use PostgreSQL, not NoSQL.
|
||||
- Use BrasilAPI as the initial holiday provider.
|
||||
- Add regional holidays and agency/client-specific commemorative dates manually through the app.
|
||||
- Keep a backend calendar provider abstraction for future provider changes.
|
||||
- Keep uploaded files protected.
|
||||
- Include audit logs from the beginning of the real MVP implementation.
|
||||
- Use orange as the main color and support light/dark themes.
|
||||
3
backend/.dockerignore
Normal file
3
backend/.dockerignore
Normal file
@@ -0,0 +1,3 @@
|
||||
.git
|
||||
tmp
|
||||
*.log
|
||||
25
backend/Dockerfile
Normal file
25
backend/Dockerfile
Normal file
@@ -0,0 +1,25 @@
|
||||
FROM golang:1.25-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY go.mod ./
|
||||
RUN apk add --no-cache build-base
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=1 GOOS=linux go build -o /bin/mira-api ./cmd/api
|
||||
|
||||
FROM alpine:3.21
|
||||
|
||||
RUN addgroup -S mira && adduser -S mira -G mira
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=build /bin/mira-api /usr/local/bin/mira-api
|
||||
COPY --from=build /app/migrations /app/migrations
|
||||
RUN mkdir -p /var/lib/mira/uploads && chown -R mira:mira /var/lib/mira
|
||||
|
||||
USER mira
|
||||
ENV GODEBUG=netdns=cgo
|
||||
EXPOSE 8080
|
||||
|
||||
CMD ["mira-api"]
|
||||
55
backend/cmd/api/main.go
Normal file
55
backend/cmd/api/main.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"mira/backend/internal/auth"
|
||||
"mira/backend/internal/calendar"
|
||||
"mira/backend/internal/clients"
|
||||
"mira/backend/internal/config"
|
||||
"mira/backend/internal/database"
|
||||
"mira/backend/internal/httpserver"
|
||||
"mira/backend/internal/users"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg := config.Load()
|
||||
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: cfg.LogLevel,
|
||||
}))
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
db, err := database.Connect(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
logger.Error("database connection failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := db.Migrate(ctx, logger, "migrations"); err != nil {
|
||||
logger.Error("database migration failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
userStore := users.NewStore(db.Pool)
|
||||
clientStore := clients.NewStore(db.Pool)
|
||||
calendarStore := calendar.NewStore(db.Pool)
|
||||
if err := auth.BootstrapSuperAdmin(ctx, cfg, userStore, logger); err != nil {
|
||||
logger.Error("super admin bootstrap failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
server := httpserver.New(cfg, logger, db, userStore, clientStore, calendarStore)
|
||||
|
||||
logger.Info("starting api", "addr", cfg.HTTPAddr, "env", cfg.AppEnv)
|
||||
if err := server.Run(cfg.HTTPAddr); err != nil {
|
||||
logger.Error("api stopped", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
44
backend/go.mod
Normal file
44
backend/go.mod
Normal file
@@ -0,0 +1,44 @@
|
||||
module mira/backend
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
golang.org/x/crypto v0.23.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.11.6 // indirect
|
||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
||||
github.com/goccy/go-json v0.10.2 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/net v0.25.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.26.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
google.golang.org/protobuf v1.34.1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
110
backend/go.sum
Normal file
110
backend/go.sum
Normal file
@@ -0,0 +1,110 @@
|
||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw=
|
||||
github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rogpeppe/go-internal v1.15.0 h1:D0RCU5rMAp+SpgkiNdrjfJ+LX4J1M32V2NeCY7EJ6hc=
|
||||
github.com/rogpeppe/go-internal v1.15.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
|
||||
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
|
||||
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
42
backend/internal/auth/bootstrap.go
Normal file
42
backend/internal/auth/bootstrap.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"mira/backend/internal/config"
|
||||
"mira/backend/internal/users"
|
||||
)
|
||||
|
||||
func BootstrapSuperAdmin(ctx context.Context, cfg config.Config, store users.Store, logger *slog.Logger) error {
|
||||
email := strings.TrimSpace(cfg.SuperAdminEmail)
|
||||
password := cfg.SuperAdminPassword
|
||||
|
||||
if email == "" || password == "" {
|
||||
if cfg.AppEnv == "production" {
|
||||
return errors.New("SUPER_ADMIN_EMAIL and SUPER_ADMIN_PASSWORD are required in production")
|
||||
}
|
||||
logger.Warn("super admin bootstrap skipped because credentials are not configured")
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(password) < 12 {
|
||||
return fmt.Errorf("SUPER_ADMIN_PASSWORD must have at least 12 characters")
|
||||
}
|
||||
|
||||
hash, err := HashPassword(password)
|
||||
if err != nil {
|
||||
return fmt.Errorf("hash super admin password: %w", err)
|
||||
}
|
||||
|
||||
user, err := store.UpsertSuperAdmin(ctx, email, hash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert super admin: %w", err)
|
||||
}
|
||||
|
||||
logger.Info("super admin bootstrapped", "user_id", user.ID, "email", user.Email)
|
||||
return nil
|
||||
}
|
||||
43
backend/internal/auth/middleware.go
Normal file
43
backend/internal/auth/middleware.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const ContextClaimsKey = "auth_claims"
|
||||
const ContextUserIDKey = "auth_user_id"
|
||||
const ContextRoleKey = "auth_role"
|
||||
|
||||
func RequireAuth(tokens TokenService) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
header := c.GetHeader("Authorization")
|
||||
if header == "" || !strings.HasPrefix(header, "Bearer ") {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."})
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := tokens.ParseAccessToken(strings.TrimPrefix(header, "Bearer "))
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"message": "Sessao invalida ou expirada."})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set(ContextClaimsKey, claims)
|
||||
c.Set(ContextUserIDKey, claims.UserID)
|
||||
c.Set(ContextRoleKey, claims.Role)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func ClaimsFromContext(c *gin.Context) (Claims, bool) {
|
||||
value, ok := c.Get(ContextClaimsKey)
|
||||
if !ok {
|
||||
return Claims{}, false
|
||||
}
|
||||
|
||||
claims, ok := value.(Claims)
|
||||
return claims, ok
|
||||
}
|
||||
12
backend/internal/auth/password.go
Normal file
12
backend/internal/auth/password.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package auth
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
func HashPassword(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
return string(hash), err
|
||||
}
|
||||
|
||||
func CheckPassword(password string, hash string) bool {
|
||||
return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil
|
||||
}
|
||||
134
backend/internal/auth/routes.go
Normal file
134
backend/internal/auth/routes.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"mira/backend/internal/users"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Routes struct {
|
||||
users users.Store
|
||||
tokens TokenService
|
||||
}
|
||||
|
||||
func NewRoutes(users users.Store, tokens TokenService) Routes {
|
||||
return Routes{
|
||||
users: users,
|
||||
tokens: tokens,
|
||||
}
|
||||
}
|
||||
|
||||
func (r Routes) Register(router *gin.RouterGroup) {
|
||||
router.POST("/login", r.login)
|
||||
router.POST("/invitations/accept", r.acceptInvitation)
|
||||
|
||||
router.POST("/logout", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Sessao encerrada."})
|
||||
})
|
||||
|
||||
router.POST("/refresh", func(c *gin.Context) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"message": "Renovacao de sessao ainda nao implementada."})
|
||||
})
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
func (r Routes) login(c *gin.Context) {
|
||||
var input loginRequest
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Informe e-mail e senha validos."})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := r.users.FindByEmail(c.Request.Context(), strings.TrimSpace(input.Email))
|
||||
if errors.Is(err, users.ErrNotFound) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "E-mail ou senha invalidos."})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel fazer login."})
|
||||
return
|
||||
}
|
||||
if !CheckPassword(input.Password, user.PasswordHash) || user.Status != "active" {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "E-mail ou senha invalidos."})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := r.tokens.IssueAccessToken(user.ID, user.Email, user.Role)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel criar a sessao."})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"access_token": token,
|
||||
"token_type": "Bearer",
|
||||
"user": gin.H{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
"role": user.Role,
|
||||
"status": user.Status,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type acceptInvitationRequest struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
Name string `json:"name" binding:"required,min=2"`
|
||||
Password string `json:"password" binding:"required,min=12"`
|
||||
}
|
||||
|
||||
func (r Routes) acceptInvitation(c *gin.Context) {
|
||||
var input acceptInvitationRequest
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Informe nome, senha e token validos."})
|
||||
return
|
||||
}
|
||||
|
||||
passwordHash, err := HashPassword(input.Password)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel proteger a senha."})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := r.users.AcceptInvitation(
|
||||
c.Request.Context(),
|
||||
users.HashInvitationToken(strings.TrimSpace(input.Token)),
|
||||
strings.TrimSpace(input.Name),
|
||||
passwordHash,
|
||||
)
|
||||
if errors.Is(err, users.ErrInvitationNotFound) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Convite invalido ou expirado."})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel aceitar o convite."})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := r.tokens.IssueAccessToken(user.ID, user.Email, user.Role)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel criar a sessao."})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"access_token": token,
|
||||
"token_type": "Bearer",
|
||||
"user": gin.H{
|
||||
"id": user.ID,
|
||||
"email": user.Email,
|
||||
"name": user.Name,
|
||||
"role": user.Role,
|
||||
"status": user.Status,
|
||||
},
|
||||
})
|
||||
}
|
||||
70
backend/internal/auth/tokens.go
Normal file
70
backend/internal/auth/tokens.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
type TokenService struct {
|
||||
secret []byte
|
||||
accessTTL time.Duration
|
||||
}
|
||||
|
||||
func NewTokenService(secret string, accessTTL time.Duration) TokenService {
|
||||
return TokenService{
|
||||
secret: []byte(secret),
|
||||
accessTTL: accessTTL,
|
||||
}
|
||||
}
|
||||
|
||||
func (s TokenService) IssueAccessToken(userID string, email string, role string) (string, error) {
|
||||
if len(s.secret) == 0 {
|
||||
return "", errors.New("jwt secret is not configured")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Subject: userID,
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(s.accessTTL)),
|
||||
},
|
||||
}
|
||||
|
||||
return jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(s.secret)
|
||||
}
|
||||
|
||||
func (s TokenService) ParseAccessToken(tokenValue string) (Claims, error) {
|
||||
if len(s.secret) == 0 {
|
||||
return Claims{}, errors.New("jwt secret is not configured")
|
||||
}
|
||||
|
||||
token, err := jwt.ParseWithClaims(tokenValue, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
if token.Method != jwt.SigningMethodHS256 {
|
||||
return nil, errors.New("unexpected signing method")
|
||||
}
|
||||
return s.secret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return Claims{}, err
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(*Claims)
|
||||
if !ok || !token.Valid {
|
||||
return Claims{}, errors.New("invalid token")
|
||||
}
|
||||
|
||||
return *claims, nil
|
||||
}
|
||||
165
backend/internal/calendar/attachments.go
Normal file
165
backend/internal/calendar/attachments.go
Normal file
@@ -0,0 +1,165 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
var ErrAttachmentNotFound = errors.New("attachment not found")
|
||||
|
||||
type Attachment struct {
|
||||
ID string `json:"id"`
|
||||
CalendarItemID string `json:"calendar_item_id"`
|
||||
OriginalFilename string `json:"original_filename"`
|
||||
StoredFilename string `json:"stored_filename"`
|
||||
MimeType string `json:"mime_type"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
StorageDriver string `json:"storage_driver"`
|
||||
StoragePath string `json:"-"`
|
||||
CreatedBy *string `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type CreateAttachmentInput struct {
|
||||
CalendarItemID string
|
||||
OriginalFilename string
|
||||
StoredFilename string
|
||||
MimeType string
|
||||
SizeBytes int64
|
||||
StorageDriver string
|
||||
StoragePath string
|
||||
CreatedBy string
|
||||
}
|
||||
|
||||
func (s Store) CreateAttachment(ctx context.Context, input CreateAttachmentInput) (Attachment, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
INSERT INTO calendar_item_attachments (
|
||||
calendar_item_id,
|
||||
original_filename,
|
||||
stored_filename,
|
||||
mime_type,
|
||||
size_bytes,
|
||||
storage_driver,
|
||||
storage_path,
|
||||
created_by
|
||||
)
|
||||
VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, NULLIF($8, '')::uuid)
|
||||
RETURNING
|
||||
id::text,
|
||||
calendar_item_id::text,
|
||||
original_filename,
|
||||
stored_filename,
|
||||
mime_type,
|
||||
size_bytes,
|
||||
storage_driver,
|
||||
storage_path,
|
||||
created_by::text,
|
||||
created_at
|
||||
`, input.CalendarItemID, input.OriginalFilename, input.StoredFilename, input.MimeType, input.SizeBytes, input.StorageDriver, input.StoragePath, input.CreatedBy)
|
||||
|
||||
return scanAttachment(row)
|
||||
}
|
||||
|
||||
func (s Store) ListAttachments(ctx context.Context, calendarItemID string, viewerUserID string) ([]Attachment, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT
|
||||
calendar_item_attachments.id::text,
|
||||
calendar_item_attachments.calendar_item_id::text,
|
||||
calendar_item_attachments.original_filename,
|
||||
calendar_item_attachments.stored_filename,
|
||||
calendar_item_attachments.mime_type,
|
||||
calendar_item_attachments.size_bytes,
|
||||
calendar_item_attachments.storage_driver,
|
||||
calendar_item_attachments.storage_path,
|
||||
calendar_item_attachments.created_by::text,
|
||||
calendar_item_attachments.created_at
|
||||
FROM calendar_item_attachments
|
||||
INNER JOIN calendar_items ON calendar_items.id = calendar_item_attachments.calendar_item_id
|
||||
WHERE calendar_item_attachments.calendar_item_id = $1::uuid
|
||||
AND (
|
||||
$2 = ''
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM client_users
|
||||
WHERE client_users.client_id = calendar_items.client_id
|
||||
AND client_users.user_id = $2::uuid
|
||||
)
|
||||
)
|
||||
ORDER BY calendar_item_attachments.created_at DESC
|
||||
`, calendarItemID, viewerUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
attachments := []Attachment{}
|
||||
for rows.Next() {
|
||||
attachment, err := scanAttachment(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
attachments = append(attachments, attachment)
|
||||
}
|
||||
|
||||
return attachments, rows.Err()
|
||||
}
|
||||
|
||||
func (s Store) FindAttachment(ctx context.Context, id string, viewerUserID string) (Attachment, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
SELECT
|
||||
calendar_item_attachments.id::text,
|
||||
calendar_item_attachments.calendar_item_id::text,
|
||||
calendar_item_attachments.original_filename,
|
||||
calendar_item_attachments.stored_filename,
|
||||
calendar_item_attachments.mime_type,
|
||||
calendar_item_attachments.size_bytes,
|
||||
calendar_item_attachments.storage_driver,
|
||||
calendar_item_attachments.storage_path,
|
||||
calendar_item_attachments.created_by::text,
|
||||
calendar_item_attachments.created_at
|
||||
FROM calendar_item_attachments
|
||||
INNER JOIN calendar_items ON calendar_items.id = calendar_item_attachments.calendar_item_id
|
||||
WHERE calendar_item_attachments.id = $1::uuid
|
||||
AND (
|
||||
$2 = ''
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM client_users
|
||||
WHERE client_users.client_id = calendar_items.client_id
|
||||
AND client_users.user_id = $2::uuid
|
||||
)
|
||||
)
|
||||
`, id, viewerUserID)
|
||||
|
||||
return scanAttachment(row)
|
||||
}
|
||||
|
||||
type attachmentScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanAttachment(row attachmentScanner) (Attachment, error) {
|
||||
var attachment Attachment
|
||||
err := row.Scan(
|
||||
&attachment.ID,
|
||||
&attachment.CalendarItemID,
|
||||
&attachment.OriginalFilename,
|
||||
&attachment.StoredFilename,
|
||||
&attachment.MimeType,
|
||||
&attachment.SizeBytes,
|
||||
&attachment.StorageDriver,
|
||||
&attachment.StoragePath,
|
||||
&attachment.CreatedBy,
|
||||
&attachment.CreatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Attachment{}, ErrAttachmentNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Attachment{}, err
|
||||
}
|
||||
return attachment, nil
|
||||
}
|
||||
23
backend/internal/calendar/audit.go
Normal file
23
backend/internal/calendar/audit.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
func (s Store) RecordAudit(ctx context.Context, actorUserID string, action string, entityType string, entityID string, metadata map[string]any) error {
|
||||
payload := "{}"
|
||||
if metadata != nil {
|
||||
bytes, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload = string(bytes)
|
||||
}
|
||||
|
||||
_, err := s.db.Exec(ctx, `
|
||||
INSERT INTO audit_logs (actor_user_id, action, entity_type, entity_id, metadata)
|
||||
VALUES (NULLIF($1, '')::uuid, $2, $3, NULLIF($4, '')::uuid, $5::jsonb)
|
||||
`, actorUserID, action, entityType, entityID, payload)
|
||||
return err
|
||||
}
|
||||
248
backend/internal/calendar/custom_dates.go
Normal file
248
backend/internal/calendar/custom_dates.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
var ErrCustomDateNotFound = errors.New("custom date not found")
|
||||
|
||||
type CustomDate struct {
|
||||
ID string `json:"id"`
|
||||
ClientID *string `json:"client_id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Date string `json:"date"`
|
||||
RecursAnnually bool `json:"recurs_annually"`
|
||||
Visibility string `json:"visibility"`
|
||||
Category string `json:"category"`
|
||||
CreatedBy *string `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func (s Store) ListCustomDatesByYear(ctx context.Context, year int, clientID string, viewerUserID string) ([]CustomDate, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT
|
||||
id::text,
|
||||
client_id::text,
|
||||
name,
|
||||
description,
|
||||
CASE
|
||||
WHEN recurs_annually THEN make_date(
|
||||
$1,
|
||||
EXTRACT(MONTH FROM date)::int,
|
||||
LEAST(
|
||||
EXTRACT(DAY FROM date)::int,
|
||||
EXTRACT(DAY FROM (date_trunc('month', make_date($1, EXTRACT(MONTH FROM date)::int, 1)) + interval '1 month - 1 day'))::int
|
||||
)
|
||||
)::text
|
||||
ELSE date::text
|
||||
END,
|
||||
recurs_annually,
|
||||
visibility,
|
||||
category,
|
||||
created_by::text,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM commemorative_dates
|
||||
WHERE (
|
||||
(date >= make_date($1, 1, 1) AND date < make_date($1 + 1, 1, 1))
|
||||
OR (recurs_annually AND date < make_date($1 + 1, 1, 1))
|
||||
)
|
||||
AND ($2 = '' OR client_id IS NULL OR client_id::text = $2)
|
||||
AND (
|
||||
$3 = ''
|
||||
OR (
|
||||
visibility = 'client'
|
||||
AND (
|
||||
client_id IS NULL
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM client_users
|
||||
WHERE client_users.client_id = commemorative_dates.client_id
|
||||
AND client_users.user_id = $3::uuid
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
ORDER BY date ASC, name ASC
|
||||
`, year, clientID, viewerUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
dates := []CustomDate{}
|
||||
for rows.Next() {
|
||||
date, err := scanCustomDate(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dates = append(dates, date)
|
||||
}
|
||||
|
||||
return dates, rows.Err()
|
||||
}
|
||||
|
||||
func (s Store) CreateCustomDate(ctx context.Context, input CreateCustomDateInput) (CustomDate, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
INSERT INTO commemorative_dates (
|
||||
client_id,
|
||||
name,
|
||||
description,
|
||||
date,
|
||||
recurs_annually,
|
||||
visibility,
|
||||
category,
|
||||
created_by
|
||||
)
|
||||
VALUES (NULLIF($1, '')::uuid, $2, $3, $4, $5, $6, $7, NULLIF($8, '')::uuid)
|
||||
RETURNING
|
||||
id::text,
|
||||
client_id::text,
|
||||
name,
|
||||
description,
|
||||
date::text,
|
||||
recurs_annually,
|
||||
visibility,
|
||||
category,
|
||||
created_by::text,
|
||||
created_at,
|
||||
updated_at
|
||||
`,
|
||||
input.ClientID,
|
||||
input.Name,
|
||||
input.Description,
|
||||
input.Date,
|
||||
input.RecursAnnually,
|
||||
input.Visibility,
|
||||
input.Category,
|
||||
input.CreatedBy,
|
||||
)
|
||||
|
||||
return scanCustomDate(row)
|
||||
}
|
||||
|
||||
type UpdateCustomDateInput struct {
|
||||
ID string
|
||||
ClientID string
|
||||
Name string
|
||||
Description string
|
||||
Date string
|
||||
RecursAnnually bool
|
||||
Visibility string
|
||||
}
|
||||
|
||||
func (s Store) UpdateCustomDate(ctx context.Context, input UpdateCustomDateInput) (CustomDate, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
UPDATE commemorative_dates
|
||||
SET
|
||||
client_id = NULLIF($2, '')::uuid,
|
||||
name = $3,
|
||||
description = $4,
|
||||
date = $5::date,
|
||||
recurs_annually = $6,
|
||||
visibility = $7,
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid
|
||||
AND category != 'feriados-brasil'
|
||||
RETURNING
|
||||
id::text,
|
||||
client_id::text,
|
||||
name,
|
||||
description,
|
||||
date::text,
|
||||
recurs_annually,
|
||||
visibility,
|
||||
category,
|
||||
created_by::text,
|
||||
created_at,
|
||||
updated_at
|
||||
`, input.ID, input.ClientID, input.Name, input.Description, input.Date, input.RecursAnnually, input.Visibility)
|
||||
|
||||
return scanCustomDate(row)
|
||||
}
|
||||
|
||||
func (s Store) DeleteCustomDate(ctx context.Context, id string) error {
|
||||
command, err := s.db.Exec(ctx, `
|
||||
DELETE FROM commemorative_dates
|
||||
WHERE id = $1::uuid
|
||||
AND category != 'feriados-brasil'
|
||||
`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if command.RowsAffected() == 0 {
|
||||
return ErrCustomDateNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s Store) UpsertExternalCommemorativeDates(ctx context.Context, dates []ExternalCommemorativeDate) error {
|
||||
for _, date := range dates {
|
||||
if date.Name == "" || date.Date == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
INSERT INTO commemorative_dates (
|
||||
client_id,
|
||||
name,
|
||||
description,
|
||||
date,
|
||||
recurs_annually,
|
||||
visibility,
|
||||
category
|
||||
)
|
||||
VALUES (NULL, $1, $2, $3::date, false, 'client', 'feriados-brasil')
|
||||
ON CONFLICT DO NOTHING
|
||||
`, date.Name, date.Description, date.Date); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type CreateCustomDateInput struct {
|
||||
ClientID string
|
||||
Name string
|
||||
Description string
|
||||
Date string
|
||||
RecursAnnually bool
|
||||
Visibility string
|
||||
Category string
|
||||
CreatedBy string
|
||||
}
|
||||
|
||||
type customDateScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanCustomDate(row customDateScanner) (CustomDate, error) {
|
||||
var date CustomDate
|
||||
err := row.Scan(
|
||||
&date.ID,
|
||||
&date.ClientID,
|
||||
&date.Name,
|
||||
&date.Description,
|
||||
&date.Date,
|
||||
&date.RecursAnnually,
|
||||
&date.Visibility,
|
||||
&date.Category,
|
||||
&date.CreatedBy,
|
||||
&date.CreatedAt,
|
||||
&date.UpdatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return CustomDate{}, ErrCustomDateNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return CustomDate{}, err
|
||||
}
|
||||
return date, nil
|
||||
}
|
||||
329
backend/internal/calendar/items.go
Normal file
329
backend/internal/calendar/items.go
Normal file
@@ -0,0 +1,329 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
var ErrCalendarItemNotFound = errors.New("calendar item not found")
|
||||
|
||||
type CalendarItem struct {
|
||||
ID string `json:"id"`
|
||||
ClientID string `json:"client_id"`
|
||||
ClientName string `json:"client_name"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
ContentType string `json:"content_type"`
|
||||
Status string `json:"status"`
|
||||
OwnerID *string `json:"owner_id"`
|
||||
ScheduledDate string `json:"scheduled_date"`
|
||||
ScheduledAt *time.Time `json:"scheduled_at"`
|
||||
CopyText string `json:"copy_text"`
|
||||
InternalNotes string `json:"internal_notes"`
|
||||
ClientNotes string `json:"client_notes"`
|
||||
CreatedBy *string `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type ListCalendarItemsFilter struct {
|
||||
ClientID string
|
||||
ViewerUserID string
|
||||
From string
|
||||
To string
|
||||
}
|
||||
|
||||
func (s Store) ListCalendarItems(ctx context.Context, filter ListCalendarItemsFilter) ([]CalendarItem, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT
|
||||
calendar_items.id::text,
|
||||
calendar_items.client_id::text,
|
||||
clients.name,
|
||||
calendar_items.title,
|
||||
calendar_items.description,
|
||||
calendar_items.content_type,
|
||||
calendar_items.status,
|
||||
calendar_items.owner_id::text,
|
||||
calendar_items.scheduled_date::text,
|
||||
calendar_items.scheduled_at,
|
||||
calendar_items.copy_text,
|
||||
calendar_items.internal_notes,
|
||||
calendar_items.client_notes,
|
||||
calendar_items.created_by::text,
|
||||
calendar_items.created_at,
|
||||
calendar_items.updated_at
|
||||
FROM calendar_items
|
||||
INNER JOIN clients ON clients.id = calendar_items.client_id
|
||||
WHERE calendar_items.scheduled_date >= $1::date
|
||||
AND calendar_items.scheduled_date <= $2::date
|
||||
AND ($3 = '' OR calendar_items.client_id::text = $3)
|
||||
AND (
|
||||
$4 = ''
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM client_users
|
||||
WHERE client_users.client_id = calendar_items.client_id
|
||||
AND client_users.user_id = $4::uuid
|
||||
)
|
||||
)
|
||||
ORDER BY calendar_items.scheduled_date ASC, calendar_items.created_at ASC
|
||||
`, filter.From, filter.To, filter.ClientID, filter.ViewerUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := []CalendarItem{}
|
||||
for rows.Next() {
|
||||
item, err := scanCalendarItem(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
|
||||
return items, rows.Err()
|
||||
}
|
||||
|
||||
func (s Store) FindCalendarItem(ctx context.Context, id string, viewerUserID string) (CalendarItem, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
SELECT
|
||||
calendar_items.id::text,
|
||||
calendar_items.client_id::text,
|
||||
clients.name,
|
||||
calendar_items.title,
|
||||
calendar_items.description,
|
||||
calendar_items.content_type,
|
||||
calendar_items.status,
|
||||
calendar_items.owner_id::text,
|
||||
calendar_items.scheduled_date::text,
|
||||
calendar_items.scheduled_at,
|
||||
calendar_items.copy_text,
|
||||
calendar_items.internal_notes,
|
||||
calendar_items.client_notes,
|
||||
calendar_items.created_by::text,
|
||||
calendar_items.created_at,
|
||||
calendar_items.updated_at
|
||||
FROM calendar_items
|
||||
INNER JOIN clients ON clients.id = calendar_items.client_id
|
||||
WHERE calendar_items.id = $1::uuid
|
||||
AND (
|
||||
$2 = ''
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM client_users
|
||||
WHERE client_users.client_id = calendar_items.client_id
|
||||
AND client_users.user_id = $2::uuid
|
||||
)
|
||||
)
|
||||
`, id, viewerUserID)
|
||||
|
||||
return scanCalendarItem(row)
|
||||
}
|
||||
|
||||
type CreateCalendarItemInput struct {
|
||||
ClientID string
|
||||
Title string
|
||||
Description string
|
||||
ContentType string
|
||||
Status string
|
||||
ScheduledDate string
|
||||
CopyText string
|
||||
InternalNotes string
|
||||
ClientNotes string
|
||||
CreatedBy string
|
||||
}
|
||||
|
||||
func (s Store) CreateCalendarItem(ctx context.Context, input CreateCalendarItemInput) (CalendarItem, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
INSERT INTO calendar_items (
|
||||
client_id,
|
||||
title,
|
||||
description,
|
||||
content_type,
|
||||
status,
|
||||
scheduled_date,
|
||||
copy_text,
|
||||
internal_notes,
|
||||
client_notes,
|
||||
created_by
|
||||
)
|
||||
VALUES ($1::uuid, $2, $3, $4, $5, $6::date, $7, $8, $9, NULLIF($10, '')::uuid)
|
||||
RETURNING
|
||||
calendar_items.id::text,
|
||||
calendar_items.client_id::text,
|
||||
(SELECT name FROM clients WHERE clients.id = calendar_items.client_id),
|
||||
calendar_items.title,
|
||||
calendar_items.description,
|
||||
calendar_items.content_type,
|
||||
calendar_items.status,
|
||||
calendar_items.owner_id::text,
|
||||
calendar_items.scheduled_date::text,
|
||||
calendar_items.scheduled_at,
|
||||
calendar_items.copy_text,
|
||||
calendar_items.internal_notes,
|
||||
calendar_items.client_notes,
|
||||
calendar_items.created_by::text,
|
||||
calendar_items.created_at,
|
||||
calendar_items.updated_at
|
||||
`,
|
||||
input.ClientID,
|
||||
input.Title,
|
||||
input.Description,
|
||||
input.ContentType,
|
||||
input.Status,
|
||||
input.ScheduledDate,
|
||||
input.CopyText,
|
||||
input.InternalNotes,
|
||||
input.ClientNotes,
|
||||
input.CreatedBy,
|
||||
)
|
||||
|
||||
return scanCalendarItem(row)
|
||||
}
|
||||
|
||||
type UpdateCalendarItemInput struct {
|
||||
ID string
|
||||
Title string
|
||||
UpdateTitle bool
|
||||
Description string
|
||||
UpdateDescription bool
|
||||
Status string
|
||||
UpdateStatus bool
|
||||
ScheduledDate string
|
||||
UpdateScheduledDate bool
|
||||
CopyText string
|
||||
UpdateCopyText bool
|
||||
InternalNotes string
|
||||
UpdateInternalNotes bool
|
||||
ClientNotes string
|
||||
UpdateClientNotes bool
|
||||
}
|
||||
|
||||
func (s Store) UpdateCalendarItem(ctx context.Context, input UpdateCalendarItemInput) (CalendarItem, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
UPDATE calendar_items
|
||||
SET
|
||||
title = CASE WHEN $2 THEN $3 ELSE title END,
|
||||
description = CASE WHEN $4 THEN $5 ELSE description END,
|
||||
status = CASE WHEN $6 THEN $7 ELSE status END,
|
||||
scheduled_date = CASE WHEN $8 THEN NULLIF($9, '')::date ELSE scheduled_date END,
|
||||
copy_text = CASE WHEN $10 THEN $11 ELSE copy_text END,
|
||||
internal_notes = CASE WHEN $12 THEN $13 ELSE internal_notes END,
|
||||
client_notes = CASE WHEN $14 THEN $15 ELSE client_notes END,
|
||||
updated_at = now()
|
||||
WHERE id = $1::uuid
|
||||
RETURNING
|
||||
calendar_items.id::text,
|
||||
calendar_items.client_id::text,
|
||||
(SELECT name FROM clients WHERE clients.id = calendar_items.client_id),
|
||||
calendar_items.title,
|
||||
calendar_items.description,
|
||||
calendar_items.content_type,
|
||||
calendar_items.status,
|
||||
calendar_items.owner_id::text,
|
||||
calendar_items.scheduled_date::text,
|
||||
calendar_items.scheduled_at,
|
||||
calendar_items.copy_text,
|
||||
calendar_items.internal_notes,
|
||||
calendar_items.client_notes,
|
||||
calendar_items.created_by::text,
|
||||
calendar_items.created_at,
|
||||
calendar_items.updated_at
|
||||
`,
|
||||
input.ID,
|
||||
input.UpdateTitle,
|
||||
input.Title,
|
||||
input.UpdateDescription,
|
||||
input.Description,
|
||||
input.UpdateStatus,
|
||||
input.Status,
|
||||
input.UpdateScheduledDate,
|
||||
input.ScheduledDate,
|
||||
input.UpdateCopyText,
|
||||
input.CopyText,
|
||||
input.UpdateInternalNotes,
|
||||
input.InternalNotes,
|
||||
input.UpdateClientNotes,
|
||||
input.ClientNotes,
|
||||
)
|
||||
|
||||
return scanCalendarItem(row)
|
||||
}
|
||||
|
||||
func (s Store) CancelCalendarItem(ctx context.Context, id string) (CalendarItem, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
UPDATE calendar_items
|
||||
SET status = 'cancelado', updated_at = now()
|
||||
WHERE id = $1::uuid
|
||||
RETURNING
|
||||
calendar_items.id::text,
|
||||
calendar_items.client_id::text,
|
||||
(SELECT name FROM clients WHERE clients.id = calendar_items.client_id),
|
||||
calendar_items.title,
|
||||
calendar_items.description,
|
||||
calendar_items.content_type,
|
||||
calendar_items.status,
|
||||
calendar_items.owner_id::text,
|
||||
calendar_items.scheduled_date::text,
|
||||
calendar_items.scheduled_at,
|
||||
calendar_items.copy_text,
|
||||
calendar_items.internal_notes,
|
||||
calendar_items.client_notes,
|
||||
calendar_items.created_by::text,
|
||||
calendar_items.created_at,
|
||||
calendar_items.updated_at
|
||||
`, id)
|
||||
|
||||
return scanCalendarItem(row)
|
||||
}
|
||||
|
||||
func (s Store) DeleteCalendarItem(ctx context.Context, id string) error {
|
||||
command, err := s.db.Exec(ctx, `
|
||||
DELETE FROM calendar_items
|
||||
WHERE id = $1::uuid
|
||||
`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if command.RowsAffected() == 0 {
|
||||
return ErrCalendarItemNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type calendarItemScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanCalendarItem(row calendarItemScanner) (CalendarItem, error) {
|
||||
var item CalendarItem
|
||||
err := row.Scan(
|
||||
&item.ID,
|
||||
&item.ClientID,
|
||||
&item.ClientName,
|
||||
&item.Title,
|
||||
&item.Description,
|
||||
&item.ContentType,
|
||||
&item.Status,
|
||||
&item.OwnerID,
|
||||
&item.ScheduledDate,
|
||||
&item.ScheduledAt,
|
||||
&item.CopyText,
|
||||
&item.InternalNotes,
|
||||
&item.ClientNotes,
|
||||
&item.CreatedBy,
|
||||
&item.CreatedAt,
|
||||
&item.UpdatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return CalendarItem{}, ErrCalendarItemNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return CalendarItem{}, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
136
backend/internal/calendar/provider.go
Normal file
136
backend/internal/calendar/provider.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Holiday struct {
|
||||
Date string `json:"date"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type Provider interface {
|
||||
NationalHolidays(ctx context.Context, year int) ([]Holiday, error)
|
||||
}
|
||||
|
||||
type CommemorativeProvider interface {
|
||||
CommemorativeDates(ctx context.Context, year int) ([]ExternalCommemorativeDate, error)
|
||||
}
|
||||
|
||||
type ExternalCommemorativeDate struct {
|
||||
Date string
|
||||
Name string
|
||||
Description string
|
||||
SourceID string
|
||||
}
|
||||
|
||||
type BrasilAPIProvider struct {
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
type FeriadosBrasilProvider struct {
|
||||
baseURL string
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
func NewFeriadosBrasilProvider() FeriadosBrasilProvider {
|
||||
return FeriadosBrasilProvider{
|
||||
baseURL: "https://raw.githubusercontent.com/joaopbini/feriados-brasil/master/dados/comemorativas/json",
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type feriadosBrasilCommemorativeDate struct {
|
||||
ID string `json:"id"`
|
||||
Data string `json:"data"`
|
||||
Nome string `json:"nome"`
|
||||
Tipo string `json:"tipo"`
|
||||
Descricao string `json:"descricao"`
|
||||
UF string `json:"uf"`
|
||||
CodigoIBGE *int `json:"codigo_ibge"`
|
||||
}
|
||||
|
||||
func (p FeriadosBrasilProvider) CommemorativeDates(ctx context.Context, year int) ([]ExternalCommemorativeDate, error) {
|
||||
url := fmt.Sprintf("%s/%d.json", p.baseURL, year)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("feriados-brasil returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var records []feriadosBrasilCommemorativeDate
|
||||
if err := json.NewDecoder(resp.Body).Decode(&records); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dates := make([]ExternalCommemorativeDate, 0, len(records))
|
||||
for _, record := range records {
|
||||
if strings.ToUpper(strings.TrimSpace(record.Tipo)) != "COMEMORATIVA" {
|
||||
continue
|
||||
}
|
||||
parsed, err := time.Parse("02/01/2006", strings.TrimSpace(record.Data))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dates = append(dates, ExternalCommemorativeDate{
|
||||
Date: parsed.Format("2006-01-02"),
|
||||
Name: strings.TrimSpace(record.Nome),
|
||||
Description: strings.TrimSpace(record.Descricao),
|
||||
SourceID: strings.TrimSpace(record.ID),
|
||||
})
|
||||
}
|
||||
|
||||
return dates, nil
|
||||
}
|
||||
|
||||
func NewBrasilAPIProvider(baseURL string) BrasilAPIProvider {
|
||||
return BrasilAPIProvider{
|
||||
baseURL: baseURL,
|
||||
client: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (p BrasilAPIProvider) NationalHolidays(ctx context.Context, year int) ([]Holiday, error) {
|
||||
url := fmt.Sprintf("%s/api/feriados/v1/%d", p.baseURL, year)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("brasilapi returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var holidays []Holiday
|
||||
if err := json.NewDecoder(resp.Body).Decode(&holidays); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return holidays, nil
|
||||
}
|
||||
740
backend/internal/calendar/routes.go
Normal file
740
backend/internal/calendar/routes.go
Normal file
@@ -0,0 +1,740 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mira/backend/internal/auth"
|
||||
"mira/backend/internal/users"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Routes struct {
|
||||
store Store
|
||||
provider Provider
|
||||
commemorative CommemorativeProvider
|
||||
uploadMaxSizeBytes int64
|
||||
uploadAllowedMIMEs map[string]bool
|
||||
storageDriver string
|
||||
storageLocalPath string
|
||||
}
|
||||
|
||||
type RouteOptions struct {
|
||||
UploadMaxSizeMB int
|
||||
UploadAllowedMIMEs []string
|
||||
StorageDriver string
|
||||
StorageLocalPath string
|
||||
}
|
||||
|
||||
func NewRoutes(store Store, provider Provider, commemorative CommemorativeProvider, options RouteOptions) Routes {
|
||||
allowedMIMEs := make(map[string]bool, len(options.UploadAllowedMIMEs))
|
||||
for _, mime := range options.UploadAllowedMIMEs {
|
||||
allowedMIMEs[strings.TrimSpace(mime)] = true
|
||||
}
|
||||
if options.UploadMaxSizeMB <= 0 {
|
||||
options.UploadMaxSizeMB = 10
|
||||
}
|
||||
if options.StorageDriver == "" {
|
||||
options.StorageDriver = "local"
|
||||
}
|
||||
if options.StorageLocalPath == "" {
|
||||
options.StorageLocalPath = "/var/lib/mira/uploads"
|
||||
}
|
||||
|
||||
return Routes{
|
||||
store: store,
|
||||
provider: provider,
|
||||
commemorative: commemorative,
|
||||
uploadMaxSizeBytes: int64(options.UploadMaxSizeMB) * 1024 * 1024,
|
||||
uploadAllowedMIMEs: allowedMIMEs,
|
||||
storageDriver: options.StorageDriver,
|
||||
storageLocalPath: options.StorageLocalPath,
|
||||
}
|
||||
}
|
||||
|
||||
func (r Routes) Register(router *gin.RouterGroup, requireAuth gin.HandlerFunc) {
|
||||
router.Use(requireAuth)
|
||||
|
||||
router.GET("/week", func(c *gin.Context) {
|
||||
c.JSON(http.StatusNotImplemented, gin.H{"message": "Dashboard semanal ainda nao implementado."})
|
||||
})
|
||||
|
||||
router.GET("/holidays", r.holidays)
|
||||
router.GET("/custom-dates", r.customDates)
|
||||
router.POST("/custom-dates", r.createCustomDate)
|
||||
router.PATCH("/custom-dates/:customDateId", r.updateCustomDate)
|
||||
router.DELETE("/custom-dates/:customDateId", r.deleteCustomDate)
|
||||
router.GET("/items", r.calendarItems)
|
||||
router.POST("/items", r.createCalendarItem)
|
||||
router.GET("/items/:itemId", r.getCalendarItem)
|
||||
router.PATCH("/items/:itemId", r.updateCalendarItem)
|
||||
router.DELETE("/items/:itemId", r.cancelCalendarItem)
|
||||
router.GET("/items/:itemId/attachments", r.listAttachments)
|
||||
router.POST("/items/:itemId/attachments", r.uploadAttachment)
|
||||
router.GET("/attachments/:attachmentId/download", r.downloadAttachment)
|
||||
}
|
||||
|
||||
func (r Routes) holidays(c *gin.Context) {
|
||||
year := time.Now().Year()
|
||||
if value := c.Query("year"); value != "" {
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil || parsed < 1900 || parsed > 2199 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Ano invalido."})
|
||||
return
|
||||
}
|
||||
year = parsed
|
||||
}
|
||||
|
||||
holidays, err := r.store.ListHolidaysByYear(c.Request.Context(), year)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar feriados."})
|
||||
return
|
||||
}
|
||||
|
||||
if len(holidays) == 0 {
|
||||
imported, err := r.provider.NationalHolidays(c.Request.Context(), year)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"message": "Nao foi possivel consultar os feriados nacionais."})
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.store.UpsertHolidays(c.Request.Context(), "brasilapi", imported); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel salvar feriados."})
|
||||
return
|
||||
}
|
||||
|
||||
holidays, err = r.store.ListHolidaysByYear(c.Request.Context(), year)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar feriados."})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"holidays": holidays})
|
||||
}
|
||||
|
||||
func (r Routes) customDates(c *gin.Context) {
|
||||
claims, ok := auth.ClaimsFromContext(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."})
|
||||
return
|
||||
}
|
||||
|
||||
year := time.Now().Year()
|
||||
if value := c.Query("year"); value != "" {
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil || parsed < 1900 || parsed > 2199 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Ano invalido."})
|
||||
return
|
||||
}
|
||||
year = parsed
|
||||
}
|
||||
|
||||
viewerUserID := ""
|
||||
if claims.Role == users.RoleClientViewer {
|
||||
viewerUserID = claims.UserID
|
||||
}
|
||||
|
||||
if r.commemorative != nil {
|
||||
initialDates, err := r.store.ListCustomDatesByYear(c.Request.Context(), year, "", viewerUserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar datas personalizadas."})
|
||||
return
|
||||
}
|
||||
hasImported := false
|
||||
for _, date := range initialDates {
|
||||
if date.Category == "feriados-brasil" {
|
||||
hasImported = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasImported {
|
||||
imported, err := r.commemorative.CommemorativeDates(c.Request.Context(), year)
|
||||
if err == nil && len(imported) > 0 {
|
||||
_ = r.store.UpsertExternalCommemorativeDates(c.Request.Context(), imported)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dates, err := r.store.ListCustomDatesByYear(c.Request.Context(), year, c.Query("client_id"), viewerUserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar datas personalizadas."})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"custom_dates": dates})
|
||||
}
|
||||
|
||||
type createCustomDateRequest struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Name string `json:"name" binding:"required,min=2"`
|
||||
Description string `json:"description"`
|
||||
Date string `json:"date" binding:"required"`
|
||||
RecursAnnually bool `json:"recurs_annually"`
|
||||
Visibility string `json:"visibility"`
|
||||
Category string `json:"category"`
|
||||
}
|
||||
|
||||
func (r Routes) createCustomDate(c *gin.Context) {
|
||||
claims, ok := auth.ClaimsFromContext(c)
|
||||
if !ok || (claims.Role != users.RoleSuperAdmin && claims.Role != users.RoleAgencyUser) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
|
||||
return
|
||||
}
|
||||
|
||||
var input createCustomDateRequest
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Informe os dados da data personalizada."})
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := time.Parse("2006-01-02", input.Date); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Data invalida. Use o formato YYYY-MM-DD."})
|
||||
return
|
||||
}
|
||||
|
||||
visibility := strings.TrimSpace(input.Visibility)
|
||||
if visibility == "" {
|
||||
visibility = "agency"
|
||||
}
|
||||
if visibility != "agency" && visibility != "client" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Visibilidade invalida."})
|
||||
return
|
||||
}
|
||||
|
||||
category := strings.TrimSpace(input.Category)
|
||||
if category == "" {
|
||||
category = "custom"
|
||||
}
|
||||
|
||||
date, err := r.store.CreateCustomDate(c.Request.Context(), CreateCustomDateInput{
|
||||
ClientID: strings.TrimSpace(input.ClientID),
|
||||
Name: strings.TrimSpace(input.Name),
|
||||
Description: strings.TrimSpace(input.Description),
|
||||
Date: input.Date,
|
||||
RecursAnnually: input.RecursAnnually,
|
||||
Visibility: visibility,
|
||||
Category: category,
|
||||
CreatedBy: claims.UserID,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel criar a data personalizada."})
|
||||
return
|
||||
}
|
||||
|
||||
_ = r.store.RecordAudit(c.Request.Context(), claims.UserID, "custom_date.created", "commemorative_date", date.ID, map[string]any{
|
||||
"name": date.Name,
|
||||
"client_id": date.ClientID,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"custom_date": date})
|
||||
}
|
||||
|
||||
type updateCustomDateRequest struct {
|
||||
ClientID string `json:"client_id"`
|
||||
Name string `json:"name" binding:"required,min=2"`
|
||||
Description string `json:"description"`
|
||||
Date string `json:"date" binding:"required"`
|
||||
RecursAnnually bool `json:"recurs_annually"`
|
||||
Visibility string `json:"visibility"`
|
||||
}
|
||||
|
||||
func (r Routes) updateCustomDate(c *gin.Context) {
|
||||
claims, ok := auth.ClaimsFromContext(c)
|
||||
if !ok || (claims.Role != users.RoleSuperAdmin && claims.Role != users.RoleAgencyUser) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
|
||||
return
|
||||
}
|
||||
|
||||
var input updateCustomDateRequest
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Informe os dados da data personalizada."})
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := time.Parse("2006-01-02", input.Date); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Data invalida. Use o formato YYYY-MM-DD."})
|
||||
return
|
||||
}
|
||||
|
||||
visibility := strings.TrimSpace(input.Visibility)
|
||||
if visibility == "" {
|
||||
visibility = "agency"
|
||||
}
|
||||
if visibility != "agency" && visibility != "client" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Visibilidade invalida."})
|
||||
return
|
||||
}
|
||||
|
||||
date, err := r.store.UpdateCustomDate(c.Request.Context(), UpdateCustomDateInput{
|
||||
ID: c.Param("customDateId"),
|
||||
ClientID: strings.TrimSpace(input.ClientID),
|
||||
Name: strings.TrimSpace(input.Name),
|
||||
Description: strings.TrimSpace(input.Description),
|
||||
Date: input.Date,
|
||||
RecursAnnually: input.RecursAnnually,
|
||||
Visibility: visibility,
|
||||
})
|
||||
if errors.Is(err, ErrCustomDateNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"message": "Data personalizada nao encontrada."})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel atualizar a data personalizada."})
|
||||
return
|
||||
}
|
||||
|
||||
_ = r.store.RecordAudit(c.Request.Context(), claims.UserID, "custom_date.updated", "commemorative_date", date.ID, map[string]any{
|
||||
"name": date.Name,
|
||||
"client_id": date.ClientID,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"custom_date": date})
|
||||
}
|
||||
|
||||
func (r Routes) deleteCustomDate(c *gin.Context) {
|
||||
claims, ok := auth.ClaimsFromContext(c)
|
||||
if !ok || (claims.Role != users.RoleSuperAdmin && claims.Role != users.RoleAgencyUser) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
|
||||
return
|
||||
}
|
||||
|
||||
err := r.store.DeleteCustomDate(c.Request.Context(), c.Param("customDateId"))
|
||||
if errors.Is(err, ErrCustomDateNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"message": "Data personalizada nao encontrada."})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel excluir a data personalizada."})
|
||||
return
|
||||
}
|
||||
|
||||
_ = r.store.RecordAudit(c.Request.Context(), claims.UserID, "custom_date.deleted", "commemorative_date", c.Param("customDateId"), nil)
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Data personalizada excluida."})
|
||||
}
|
||||
|
||||
func (r Routes) calendarItems(c *gin.Context) {
|
||||
claims, ok := auth.ClaimsFromContext(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."})
|
||||
return
|
||||
}
|
||||
|
||||
from := strings.TrimSpace(c.Query("from"))
|
||||
to := strings.TrimSpace(c.Query("to"))
|
||||
|
||||
if from == "" || to == "" {
|
||||
year := time.Now().Year()
|
||||
if value := c.Query("year"); value != "" {
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil || parsed < 1900 || parsed > 2199 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Ano invalido."})
|
||||
return
|
||||
}
|
||||
year = parsed
|
||||
}
|
||||
|
||||
from = time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC).Format("2006-01-02")
|
||||
to = time.Date(year, 12, 31, 0, 0, 0, 0, time.UTC).Format("2006-01-02")
|
||||
}
|
||||
|
||||
if _, err := time.Parse("2006-01-02", from); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Data inicial invalida. Use o formato YYYY-MM-DD."})
|
||||
return
|
||||
}
|
||||
if _, err := time.Parse("2006-01-02", to); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Data final invalida. Use o formato YYYY-MM-DD."})
|
||||
return
|
||||
}
|
||||
|
||||
viewerUserID := ""
|
||||
if claims.Role == users.RoleClientViewer {
|
||||
viewerUserID = claims.UserID
|
||||
}
|
||||
|
||||
items, err := r.store.ListCalendarItems(c.Request.Context(), ListCalendarItemsFilter{
|
||||
ClientID: strings.TrimSpace(c.Query("client_id")),
|
||||
ViewerUserID: viewerUserID,
|
||||
From: from,
|
||||
To: to,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar postagens."})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
type createCalendarItemRequest struct {
|
||||
ClientID string `json:"client_id" binding:"required"`
|
||||
Title string `json:"title" binding:"required,min=2"`
|
||||
Description string `json:"description"`
|
||||
ContentType string `json:"content_type"`
|
||||
Status string `json:"status"`
|
||||
ScheduledDate string `json:"scheduled_date" binding:"required"`
|
||||
CopyText string `json:"copy_text"`
|
||||
InternalNotes string `json:"internal_notes"`
|
||||
ClientNotes string `json:"client_notes"`
|
||||
}
|
||||
|
||||
func (r Routes) createCalendarItem(c *gin.Context) {
|
||||
claims, ok := auth.ClaimsFromContext(c)
|
||||
if !ok || (claims.Role != users.RoleSuperAdmin && claims.Role != users.RoleAgencyUser) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
|
||||
return
|
||||
}
|
||||
|
||||
var input createCalendarItemRequest
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Informe os dados da postagem."})
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := time.Parse("2006-01-02", input.ScheduledDate); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Data invalida. Use o formato YYYY-MM-DD."})
|
||||
return
|
||||
}
|
||||
|
||||
contentType := strings.TrimSpace(input.ContentType)
|
||||
if contentType == "" {
|
||||
contentType = "post"
|
||||
}
|
||||
|
||||
status := strings.TrimSpace(input.Status)
|
||||
if status == "" {
|
||||
status = "rascunho"
|
||||
}
|
||||
if !isValidCalendarItemStatus(status) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Status invalido."})
|
||||
return
|
||||
}
|
||||
|
||||
item, err := r.store.CreateCalendarItem(c.Request.Context(), CreateCalendarItemInput{
|
||||
ClientID: strings.TrimSpace(input.ClientID),
|
||||
Title: strings.TrimSpace(input.Title),
|
||||
Description: strings.TrimSpace(input.Description),
|
||||
ContentType: contentType,
|
||||
Status: status,
|
||||
ScheduledDate: input.ScheduledDate,
|
||||
CopyText: strings.TrimSpace(input.CopyText),
|
||||
InternalNotes: strings.TrimSpace(input.InternalNotes),
|
||||
ClientNotes: strings.TrimSpace(input.ClientNotes),
|
||||
CreatedBy: claims.UserID,
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel criar a postagem."})
|
||||
return
|
||||
}
|
||||
|
||||
_ = r.store.RecordAudit(c.Request.Context(), claims.UserID, "calendar_item.created", "calendar_item", item.ID, map[string]any{
|
||||
"title": item.Title,
|
||||
"client_id": item.ClientID,
|
||||
"status": item.Status,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"item": item})
|
||||
}
|
||||
|
||||
func (r Routes) getCalendarItem(c *gin.Context) {
|
||||
claims, ok := auth.ClaimsFromContext(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."})
|
||||
return
|
||||
}
|
||||
|
||||
viewerUserID := ""
|
||||
if claims.Role == users.RoleClientViewer {
|
||||
viewerUserID = claims.UserID
|
||||
}
|
||||
|
||||
item, err := r.store.FindCalendarItem(c.Request.Context(), c.Param("itemId"), viewerUserID)
|
||||
if errors.Is(err, ErrCalendarItemNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"message": "Postagem nao encontrada."})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar a postagem."})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"item": item})
|
||||
}
|
||||
|
||||
type updateCalendarItemRequest struct {
|
||||
Title *string `json:"title"`
|
||||
Description *string `json:"description"`
|
||||
Status *string `json:"status"`
|
||||
ScheduledDate *string `json:"scheduled_date"`
|
||||
CopyText *string `json:"copy_text"`
|
||||
InternalNotes *string `json:"internal_notes"`
|
||||
ClientNotes *string `json:"client_notes"`
|
||||
}
|
||||
|
||||
func (r Routes) updateCalendarItem(c *gin.Context) {
|
||||
claims, ok := auth.ClaimsFromContext(c)
|
||||
if !ok || (claims.Role != users.RoleSuperAdmin && claims.Role != users.RoleAgencyUser) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
|
||||
return
|
||||
}
|
||||
|
||||
var input updateCalendarItemRequest
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Dados invalidos."})
|
||||
return
|
||||
}
|
||||
|
||||
update := UpdateCalendarItemInput{ID: c.Param("itemId")}
|
||||
if input.Title != nil {
|
||||
update.UpdateTitle = true
|
||||
update.Title = strings.TrimSpace(*input.Title)
|
||||
if update.Title == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Titulo invalido."})
|
||||
return
|
||||
}
|
||||
}
|
||||
if input.Description != nil {
|
||||
update.UpdateDescription = true
|
||||
update.Description = strings.TrimSpace(*input.Description)
|
||||
}
|
||||
if input.Status != nil {
|
||||
update.UpdateStatus = true
|
||||
update.Status = strings.TrimSpace(*input.Status)
|
||||
if !isValidCalendarItemStatus(update.Status) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Status invalido."})
|
||||
return
|
||||
}
|
||||
}
|
||||
if input.ScheduledDate != nil {
|
||||
update.UpdateScheduledDate = true
|
||||
update.ScheduledDate = strings.TrimSpace(*input.ScheduledDate)
|
||||
if _, err := time.Parse("2006-01-02", update.ScheduledDate); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Data invalida. Use o formato YYYY-MM-DD."})
|
||||
return
|
||||
}
|
||||
}
|
||||
if input.CopyText != nil {
|
||||
update.UpdateCopyText = true
|
||||
update.CopyText = strings.TrimSpace(*input.CopyText)
|
||||
}
|
||||
if input.InternalNotes != nil {
|
||||
update.UpdateInternalNotes = true
|
||||
update.InternalNotes = strings.TrimSpace(*input.InternalNotes)
|
||||
}
|
||||
if input.ClientNotes != nil {
|
||||
update.UpdateClientNotes = true
|
||||
update.ClientNotes = strings.TrimSpace(*input.ClientNotes)
|
||||
}
|
||||
|
||||
item, err := r.store.UpdateCalendarItem(c.Request.Context(), update)
|
||||
if errors.Is(err, ErrCalendarItemNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"message": "Postagem nao encontrada."})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel atualizar a postagem."})
|
||||
return
|
||||
}
|
||||
|
||||
_ = r.store.RecordAudit(c.Request.Context(), claims.UserID, "calendar_item.updated", "calendar_item", item.ID, map[string]any{
|
||||
"title": item.Title,
|
||||
"status": item.Status,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"item": item})
|
||||
}
|
||||
|
||||
func (r Routes) cancelCalendarItem(c *gin.Context) {
|
||||
claims, ok := auth.ClaimsFromContext(c)
|
||||
if !ok || (claims.Role != users.RoleSuperAdmin && claims.Role != users.RoleAgencyUser) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
|
||||
return
|
||||
}
|
||||
|
||||
item, err := r.store.FindCalendarItem(c.Request.Context(), c.Param("itemId"), "")
|
||||
if errors.Is(err, ErrCalendarItemNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"message": "Postagem nao encontrada."})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar a postagem."})
|
||||
return
|
||||
}
|
||||
|
||||
if err := r.store.DeleteCalendarItem(c.Request.Context(), item.ID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel excluir a postagem."})
|
||||
return
|
||||
}
|
||||
|
||||
_ = r.store.RecordAudit(c.Request.Context(), claims.UserID, "calendar_item.deleted", "calendar_item", item.ID, map[string]any{
|
||||
"title": item.Title,
|
||||
"status": item.Status,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Postagem excluida."})
|
||||
}
|
||||
|
||||
func (r Routes) listAttachments(c *gin.Context) {
|
||||
claims, ok := auth.ClaimsFromContext(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."})
|
||||
return
|
||||
}
|
||||
|
||||
viewerUserID := ""
|
||||
if claims.Role == users.RoleClientViewer {
|
||||
viewerUserID = claims.UserID
|
||||
}
|
||||
|
||||
attachments, err := r.store.ListAttachments(c.Request.Context(), c.Param("itemId"), viewerUserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar anexos."})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"attachments": attachments})
|
||||
}
|
||||
|
||||
func (r Routes) uploadAttachment(c *gin.Context) {
|
||||
claims, ok := auth.ClaimsFromContext(c)
|
||||
if !ok || (claims.Role != users.RoleSuperAdmin && claims.Role != users.RoleAgencyUser) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
|
||||
return
|
||||
}
|
||||
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, r.uploadMaxSizeBytes)
|
||||
file, header, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Envie um arquivo valido."})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if header.Size > r.uploadMaxSizeBytes {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Arquivo acima do limite permitido."})
|
||||
return
|
||||
}
|
||||
|
||||
head := make([]byte, 512)
|
||||
n, err := file.Read(head)
|
||||
if err != nil && err != io.EOF {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Nao foi possivel ler o arquivo."})
|
||||
return
|
||||
}
|
||||
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel processar o arquivo."})
|
||||
return
|
||||
}
|
||||
|
||||
mimeType := http.DetectContentType(head[:n])
|
||||
if len(r.uploadAllowedMIMEs) > 0 && !r.uploadAllowedMIMEs[mimeType] {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Tipo de arquivo nao permitido."})
|
||||
return
|
||||
}
|
||||
|
||||
storedFilename, err := randomStoredFilename(header.Filename)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel preparar o arquivo."})
|
||||
return
|
||||
}
|
||||
|
||||
itemDir := filepath.Join(r.storageLocalPath, c.Param("itemId"))
|
||||
if err := os.MkdirAll(itemDir, 0o750); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel preparar o armazenamento."})
|
||||
return
|
||||
}
|
||||
|
||||
storagePath := filepath.Join(itemDir, storedFilename)
|
||||
destination, err := os.OpenFile(storagePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o640)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel salvar o arquivo."})
|
||||
return
|
||||
}
|
||||
defer destination.Close()
|
||||
|
||||
sizeBytes, err := io.Copy(destination, file)
|
||||
if err != nil {
|
||||
_ = os.Remove(storagePath)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel salvar o arquivo."})
|
||||
return
|
||||
}
|
||||
|
||||
attachment, err := r.store.CreateAttachment(c.Request.Context(), CreateAttachmentInput{
|
||||
CalendarItemID: c.Param("itemId"),
|
||||
OriginalFilename: filepath.Base(header.Filename),
|
||||
StoredFilename: storedFilename,
|
||||
MimeType: mimeType,
|
||||
SizeBytes: sizeBytes,
|
||||
StorageDriver: r.storageDriver,
|
||||
StoragePath: storagePath,
|
||||
CreatedBy: claims.UserID,
|
||||
})
|
||||
if err != nil {
|
||||
_ = os.Remove(storagePath)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel registrar o anexo."})
|
||||
return
|
||||
}
|
||||
|
||||
_ = r.store.RecordAudit(c.Request.Context(), claims.UserID, "attachment.created", "calendar_item_attachment", attachment.ID, map[string]any{
|
||||
"calendar_item_id": attachment.CalendarItemID,
|
||||
"filename": attachment.OriginalFilename,
|
||||
"mime_type": attachment.MimeType,
|
||||
"size_bytes": attachment.SizeBytes,
|
||||
})
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"attachment": attachment})
|
||||
}
|
||||
|
||||
func (r Routes) downloadAttachment(c *gin.Context) {
|
||||
claims, ok := auth.ClaimsFromContext(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."})
|
||||
return
|
||||
}
|
||||
|
||||
viewerUserID := ""
|
||||
if claims.Role == users.RoleClientViewer {
|
||||
viewerUserID = claims.UserID
|
||||
}
|
||||
|
||||
attachment, err := r.store.FindAttachment(c.Request.Context(), c.Param("attachmentId"), viewerUserID)
|
||||
if errors.Is(err, ErrAttachmentNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"message": "Anexo nao encontrado."})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar o anexo."})
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", attachment.MimeType)
|
||||
c.Header("Content-Disposition", `attachment; filename="`+strings.ReplaceAll(attachment.OriginalFilename, `"`, "")+`"`)
|
||||
c.File(attachment.StoragePath)
|
||||
}
|
||||
|
||||
func randomStoredFilename(original string) (string, error) {
|
||||
bytes := make([]byte, 16)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
extension := strings.ToLower(filepath.Ext(original))
|
||||
return hex.EncodeToString(bytes) + extension, nil
|
||||
}
|
||||
|
||||
func isValidCalendarItemStatus(status string) bool {
|
||||
switch status {
|
||||
case "rascunho", "planejado", "em_producao", "em_revisao", "aprovado", "publicado", "cancelado":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
83
backend/internal/calendar/store.go
Normal file
83
backend/internal/calendar/store.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type HolidayRecord struct {
|
||||
ID string `json:"id"`
|
||||
Date string `json:"date"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Source string `json:"source"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(db *pgxpool.Pool) Store {
|
||||
return Store{db: db}
|
||||
}
|
||||
|
||||
func (s Store) ListHolidaysByYear(ctx context.Context, year int) ([]HolidayRecord, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id::text, date::text, name, type, source, created_at, updated_at
|
||||
FROM holidays
|
||||
WHERE date >= make_date($1, 1, 1)
|
||||
AND date < make_date($1 + 1, 1, 1)
|
||||
ORDER BY date ASC, name ASC
|
||||
`, year)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
holidays := []HolidayRecord{}
|
||||
for rows.Next() {
|
||||
var holiday HolidayRecord
|
||||
if err := rows.Scan(
|
||||
&holiday.ID,
|
||||
&holiday.Date,
|
||||
&holiday.Name,
|
||||
&holiday.Type,
|
||||
&holiday.Source,
|
||||
&holiday.CreatedAt,
|
||||
&holiday.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
holidays = append(holidays, holiday)
|
||||
}
|
||||
|
||||
return holidays, rows.Err()
|
||||
}
|
||||
|
||||
func (s Store) UpsertHolidays(ctx context.Context, source string, holidays []Holiday) error {
|
||||
for _, holiday := range holidays {
|
||||
payload, err := json.Marshal(holiday)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := s.db.Exec(ctx, `
|
||||
INSERT INTO holidays (date, name, type, source, raw_payload)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb)
|
||||
ON CONFLICT (date, source, name)
|
||||
DO UPDATE SET
|
||||
type = EXCLUDED.type,
|
||||
raw_payload = EXCLUDED.raw_payload,
|
||||
updated_at = now()
|
||||
`, holiday.Date, holiday.Name, holiday.Type, source, string(payload)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
18
backend/internal/clients/model.go
Normal file
18
backend/internal/clients/model.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package clients
|
||||
|
||||
import "time"
|
||||
|
||||
type Client struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Status string `json:"status"`
|
||||
WebsiteURL string `json:"website_url"`
|
||||
InstagramURL string `json:"instagram_url"`
|
||||
FacebookURL string `json:"facebook_url"`
|
||||
LinkedinURL string `json:"linkedin_url"`
|
||||
Color string `json:"color"`
|
||||
Notes string `json:"notes"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
196
backend/internal/clients/routes.go
Normal file
196
backend/internal/clients/routes.go
Normal file
@@ -0,0 +1,196 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"mira/backend/internal/auth"
|
||||
"mira/backend/internal/users"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Routes struct {
|
||||
store Store
|
||||
}
|
||||
|
||||
func NewRoutes(store Store) Routes {
|
||||
return Routes{store: store}
|
||||
}
|
||||
|
||||
func (r Routes) Register(router *gin.RouterGroup, requireAuth gin.HandlerFunc) {
|
||||
router.Use(requireAuth)
|
||||
|
||||
router.GET("", r.list)
|
||||
router.POST("", r.create)
|
||||
router.GET("/:clientId", r.get)
|
||||
router.PATCH("/:clientId", r.update)
|
||||
router.DELETE("/:clientId", r.delete)
|
||||
}
|
||||
|
||||
func (r Routes) list(c *gin.Context) {
|
||||
claims, ok := auth.ClaimsFromContext(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."})
|
||||
return
|
||||
}
|
||||
|
||||
var clients []Client
|
||||
var err error
|
||||
if claims.Role == users.RoleSuperAdmin || claims.Role == users.RoleAgencyUser {
|
||||
clients, err = r.store.List(c.Request.Context())
|
||||
} else {
|
||||
clients, err = r.store.ListForUser(c.Request.Context(), claims.UserID)
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel listar clientes."})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"clients": clients})
|
||||
}
|
||||
|
||||
type createClientRequest struct {
|
||||
Name string `json:"name" binding:"required,min=2"`
|
||||
}
|
||||
|
||||
func (r Routes) create(c *gin.Context) {
|
||||
claims, ok := auth.ClaimsFromContext(c)
|
||||
if !ok || claims.Role != users.RoleSuperAdmin {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
|
||||
return
|
||||
}
|
||||
|
||||
var input createClientRequest
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Informe um nome de cliente valido."})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := r.store.Create(c.Request.Context(), input.Name)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel criar o cliente."})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"client": client})
|
||||
}
|
||||
|
||||
func (r Routes) get(c *gin.Context) {
|
||||
if !r.canReadClient(c, c.Param("clientId")) {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := r.store.FindByID(c.Request.Context(), c.Param("clientId"))
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"message": "Cliente nao encontrado."})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel carregar o cliente."})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"client": client})
|
||||
}
|
||||
|
||||
type updateClientRequest struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
WebsiteURL string `json:"website_url"`
|
||||
InstagramURL string `json:"instagram_url"`
|
||||
FacebookURL string `json:"facebook_url"`
|
||||
LinkedinURL string `json:"linkedin_url"`
|
||||
Color string `json:"color"`
|
||||
Notes string `json:"notes"`
|
||||
}
|
||||
|
||||
func (r Routes) update(c *gin.Context) {
|
||||
claims, ok := auth.ClaimsFromContext(c)
|
||||
if !ok || claims.Role != users.RoleSuperAdmin {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
|
||||
return
|
||||
}
|
||||
|
||||
var input updateClientRequest
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Dados invalidos."})
|
||||
return
|
||||
}
|
||||
|
||||
color := strings.TrimSpace(input.Color)
|
||||
if color != "" && !hexColorPattern.MatchString(color) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Cor invalida."})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := r.store.Update(c.Request.Context(), c.Param("clientId"), input.Name, input.Status, input.WebsiteURL, input.InstagramURL, input.FacebookURL, input.LinkedinURL, color, input.Notes)
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"message": "Cliente nao encontrado."})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel atualizar o cliente."})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"client": client})
|
||||
}
|
||||
|
||||
var hexColorPattern = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
|
||||
|
||||
func (r Routes) archive(c *gin.Context) {
|
||||
claims, ok := auth.ClaimsFromContext(c)
|
||||
if !ok || claims.Role != users.RoleSuperAdmin {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
|
||||
return
|
||||
}
|
||||
|
||||
client, err := r.store.Archive(c.Request.Context(), c.Param("clientId"))
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"message": "Cliente nao encontrado."})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel arquivar o cliente."})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"client": client})
|
||||
}
|
||||
|
||||
func (r Routes) delete(c *gin.Context) {
|
||||
claims, ok := auth.ClaimsFromContext(c)
|
||||
if !ok || claims.Role != users.RoleSuperAdmin {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
|
||||
return
|
||||
}
|
||||
|
||||
err := r.store.Delete(c.Request.Context(), c.Param("clientId"))
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
c.JSON(http.StatusNotFound, gin.H{"message": "Cliente nao encontrado."})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel excluir o cliente."})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": "Cliente excluido."})
|
||||
}
|
||||
|
||||
func (r Routes) canReadClient(c *gin.Context, clientID string) bool {
|
||||
claims, ok := auth.ClaimsFromContext(c)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if claims.Role == users.RoleSuperAdmin || claims.Role == users.RoleAgencyUser {
|
||||
return true
|
||||
}
|
||||
|
||||
allowed, err := r.store.UserCanAccess(c.Request.Context(), claims.UserID, clientID)
|
||||
return err == nil && allowed
|
||||
}
|
||||
201
backend/internal/clients/store.go
Normal file
201
backend/internal/clients/store.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package clients
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("client not found")
|
||||
|
||||
type Store struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(db *pgxpool.Pool) Store {
|
||||
return Store{db: db}
|
||||
}
|
||||
|
||||
func (s Store) List(ctx context.Context) ([]Client, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id::text, name, slug, status, website_url, instagram_url, facebook_url, linkedin_url, color, notes, created_at, updated_at
|
||||
FROM clients
|
||||
ORDER BY name ASC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
clients := []Client{}
|
||||
for rows.Next() {
|
||||
client, err := scanClient(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clients = append(clients, client)
|
||||
}
|
||||
|
||||
return clients, rows.Err()
|
||||
}
|
||||
|
||||
func (s Store) ListForUser(ctx context.Context, userID string) ([]Client, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT clients.id::text, clients.name, clients.slug, clients.status, clients.website_url, clients.instagram_url, clients.facebook_url, clients.linkedin_url, clients.color, clients.notes, clients.created_at, clients.updated_at
|
||||
FROM clients
|
||||
INNER JOIN client_users ON client_users.client_id = clients.id
|
||||
WHERE client_users.user_id = $1::uuid
|
||||
ORDER BY clients.name ASC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
clients := []Client{}
|
||||
for rows.Next() {
|
||||
client, err := scanClient(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
clients = append(clients, client)
|
||||
}
|
||||
|
||||
return clients, rows.Err()
|
||||
}
|
||||
|
||||
func (s Store) UserCanAccess(ctx context.Context, userID string, clientID string) (bool, error) {
|
||||
var exists bool
|
||||
err := s.db.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM client_users
|
||||
WHERE user_id = $1::uuid
|
||||
AND client_id = $2::uuid
|
||||
)
|
||||
`, userID, clientID).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
|
||||
func (s Store) FindByID(ctx context.Context, id string) (Client, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
SELECT id::text, name, slug, status, website_url, instagram_url, facebook_url, linkedin_url, color, notes, created_at, updated_at
|
||||
FROM clients
|
||||
WHERE id = $1
|
||||
`, id)
|
||||
|
||||
return scanClient(row)
|
||||
}
|
||||
|
||||
func (s Store) Create(ctx context.Context, name string) (Client, error) {
|
||||
slug := slugify(name)
|
||||
|
||||
row := s.db.QueryRow(ctx, `
|
||||
INSERT INTO clients (name, slug, status)
|
||||
VALUES ($1, $2, 'active')
|
||||
RETURNING id::text, name, slug, status, website_url, instagram_url, facebook_url, linkedin_url, color, notes, created_at, updated_at
|
||||
`, strings.TrimSpace(name), slug)
|
||||
|
||||
return scanClient(row)
|
||||
}
|
||||
|
||||
func (s Store) Update(ctx context.Context, id string, name string, status string, websiteURL string, instagramURL string, facebookURL string, linkedinURL string, color string, notes string) (Client, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
UPDATE clients
|
||||
SET
|
||||
name = COALESCE(NULLIF($2, ''), name),
|
||||
slug = CASE WHEN NULLIF($2, '') IS NULL THEN slug ELSE $3 END,
|
||||
status = COALESCE(NULLIF($4, ''), status),
|
||||
website_url = $5,
|
||||
instagram_url = $6,
|
||||
facebook_url = $7,
|
||||
linkedin_url = $8,
|
||||
color = COALESCE(NULLIF($9, ''), color),
|
||||
notes = $10,
|
||||
updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id::text, name, slug, status, website_url, instagram_url, facebook_url, linkedin_url, color, notes, created_at, updated_at
|
||||
`, id, strings.TrimSpace(name), slugify(name), strings.TrimSpace(status), strings.TrimSpace(websiteURL), strings.TrimSpace(instagramURL), strings.TrimSpace(facebookURL), strings.TrimSpace(linkedinURL), strings.TrimSpace(color), strings.TrimSpace(notes))
|
||||
|
||||
return scanClient(row)
|
||||
}
|
||||
|
||||
func (s Store) Archive(ctx context.Context, id string) (Client, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
UPDATE clients
|
||||
SET status = 'archived', updated_at = now()
|
||||
WHERE id = $1
|
||||
RETURNING id::text, name, slug, status, website_url, instagram_url, facebook_url, linkedin_url, color, notes, created_at, updated_at
|
||||
`, id)
|
||||
|
||||
return scanClient(row)
|
||||
}
|
||||
|
||||
func (s Store) Delete(ctx context.Context, id string) error {
|
||||
result, err := s.db.Exec(ctx, `
|
||||
DELETE FROM clients
|
||||
WHERE id = $1
|
||||
`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanClient(row scanner) (Client, error) {
|
||||
var client Client
|
||||
err := row.Scan(
|
||||
&client.ID,
|
||||
&client.Name,
|
||||
&client.Slug,
|
||||
&client.Status,
|
||||
&client.WebsiteURL,
|
||||
&client.InstagramURL,
|
||||
&client.FacebookURL,
|
||||
&client.LinkedinURL,
|
||||
&client.Color,
|
||||
&client.Notes,
|
||||
&client.CreatedAt,
|
||||
&client.UpdatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Client{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Client{}, err
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func slugify(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
replacements := map[string]string{
|
||||
"á": "a", "à": "a", "â": "a", "ã": "a", "ä": "a",
|
||||
"é": "e", "è": "e", "ê": "e", "ë": "e",
|
||||
"í": "i", "ì": "i", "î": "i", "ï": "i",
|
||||
"ó": "o", "ò": "o", "ô": "o", "õ": "o", "ö": "o",
|
||||
"ú": "u", "ù": "u", "û": "u", "ü": "u",
|
||||
"ç": "c",
|
||||
}
|
||||
for from, to := range replacements {
|
||||
value = strings.ReplaceAll(value, from, to)
|
||||
}
|
||||
|
||||
value = regexp.MustCompile(`[^a-z0-9]+`).ReplaceAllString(value, "-")
|
||||
value = strings.Trim(value, "-")
|
||||
if value == "" {
|
||||
return "cliente"
|
||||
}
|
||||
return value
|
||||
}
|
||||
119
backend/internal/config/config.go
Normal file
119
backend/internal/config/config.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AppEnv string
|
||||
AppPublicURL string
|
||||
AppTimezone string
|
||||
HTTPAddr string
|
||||
LogLevel slog.Level
|
||||
DatabaseURL string
|
||||
SuperAdminEmail string
|
||||
SuperAdminPassword string
|
||||
JWTSecret string
|
||||
AccessTokenTTL time.Duration
|
||||
RefreshTokenTTL time.Duration
|
||||
CORSAllowedOrigins []string
|
||||
CalendarProvider string
|
||||
CalendarAPIBaseURL string
|
||||
CalendarAPIKey string
|
||||
CalendarCacheTTL time.Duration
|
||||
UploadMaxSizeMB int
|
||||
UploadAllowedMIMEs []string
|
||||
StorageDriver string
|
||||
StorageLocalPath string
|
||||
SMTPHost string
|
||||
SMTPPort string
|
||||
SMTPUser string
|
||||
SMTPPassword string
|
||||
SMTPFromEmail string
|
||||
SMTPFromName string
|
||||
}
|
||||
|
||||
func Load() Config {
|
||||
return Config{
|
||||
AppEnv: env("APP_ENV", "development"),
|
||||
AppPublicURL: env("APP_PUBLIC_URL", "http://localhost:5173"),
|
||||
AppTimezone: env("APP_TIMEZONE", "America/Sao_Paulo"),
|
||||
HTTPAddr: env("HTTP_ADDR", ":8080"),
|
||||
LogLevel: logLevel(env("LOG_LEVEL", "info")),
|
||||
DatabaseURL: env("DATABASE_URL", "postgres://mira:mira@localhost:5432/mira?sslmode=disable"),
|
||||
SuperAdminEmail: env("SUPER_ADMIN_EMAIL", ""),
|
||||
SuperAdminPassword: env("SUPER_ADMIN_PASSWORD", ""),
|
||||
JWTSecret: env("JWT_SECRET", ""),
|
||||
AccessTokenTTL: minutes("JWT_ACCESS_TOKEN_TTL_MINUTES", 15),
|
||||
RefreshTokenTTL: hours("JWT_REFRESH_TOKEN_TTL_DAYS", 7*24),
|
||||
CORSAllowedOrigins: list("CORS_ALLOWED_ORIGINS", "http://localhost:5173"),
|
||||
CalendarProvider: env("CALENDAR_PROVIDER", "brasilapi"),
|
||||
CalendarAPIBaseURL: env("CALENDAR_API_BASE_URL", "https://brasilapi.com.br"),
|
||||
CalendarAPIKey: env("CALENDAR_API_KEY", ""),
|
||||
CalendarCacheTTL: hours("CALENDAR_CACHE_TTL_HOURS", 24),
|
||||
UploadMaxSizeMB: integer("UPLOAD_MAX_SIZE_MB", 10),
|
||||
UploadAllowedMIMEs: list("UPLOAD_ALLOWED_MIME_TYPES", "image/jpeg,image/png,image/webp,application/pdf"),
|
||||
StorageDriver: env("STORAGE_DRIVER", "local"),
|
||||
StorageLocalPath: env("STORAGE_LOCAL_PATH", "/var/lib/mira/uploads"),
|
||||
SMTPHost: env("SMTP_HOST", ""),
|
||||
SMTPPort: env("SMTP_PORT", "587"),
|
||||
SMTPUser: env("SMTP_USER", ""),
|
||||
SMTPPassword: env("SMTP_PASSWORD", ""),
|
||||
SMTPFromEmail: env("SMTP_FROM_EMAIL", ""),
|
||||
SMTPFromName: env("SMTP_FROM_NAME", "Mira"),
|
||||
}
|
||||
}
|
||||
|
||||
func env(key, fallback string) string {
|
||||
value := strings.TrimSpace(os.Getenv(key))
|
||||
if value == "" {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func list(key, fallback string) []string {
|
||||
raw := env(key, fallback)
|
||||
parts := strings.Split(raw, ",")
|
||||
values := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
value := strings.TrimSpace(part)
|
||||
if value != "" {
|
||||
values = append(values, value)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func integer(key string, fallback int) int {
|
||||
value, err := strconv.Atoi(env(key, ""))
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func minutes(key string, fallback int) time.Duration {
|
||||
return time.Duration(integer(key, fallback)) * time.Minute
|
||||
}
|
||||
|
||||
func hours(key string, fallback int) time.Duration {
|
||||
return time.Duration(integer(key, fallback)) * time.Hour
|
||||
}
|
||||
|
||||
func logLevel(value string) slog.Level {
|
||||
switch strings.ToLower(value) {
|
||||
case "debug":
|
||||
return slog.LevelDebug
|
||||
case "warn":
|
||||
return slog.LevelWarn
|
||||
case "error":
|
||||
return slog.LevelError
|
||||
default:
|
||||
return slog.LevelInfo
|
||||
}
|
||||
}
|
||||
77
backend/internal/database/database.go
Normal file
77
backend/internal/database/database.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func Connect(ctx context.Context, databaseURL string) (*DB, error) {
|
||||
cfg, err := pgxpool.ParseConfig(databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse database url: %w", err)
|
||||
}
|
||||
|
||||
cfg.MaxConns = 10
|
||||
cfg.MinConns = 1
|
||||
cfg.MaxConnLifetime = time.Hour
|
||||
cfg.MaxConnIdleTime = 30 * time.Minute
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create database pool: %w", err)
|
||||
}
|
||||
|
||||
if err := pool.Ping(ctx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("ping database: %w", err)
|
||||
}
|
||||
|
||||
return &DB{Pool: pool}, nil
|
||||
}
|
||||
|
||||
func (db *DB) Close() {
|
||||
if db != nil && db.Pool != nil {
|
||||
db.Pool.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (db *DB) Migrate(ctx context.Context, logger *slog.Logger, migrationsDir string) error {
|
||||
files, err := filepath.Glob(filepath.Join(migrationsDir, "*.sql"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("list migrations: %w", err)
|
||||
}
|
||||
|
||||
sort.Strings(files)
|
||||
|
||||
for _, file := range files {
|
||||
sql, err := os.ReadFile(file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", file, err)
|
||||
}
|
||||
|
||||
statement := strings.TrimSpace(string(sql))
|
||||
if statement == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, err := db.Pool.Exec(ctx, statement); err != nil {
|
||||
return fmt.Errorf("run migration %s: %w", file, err)
|
||||
}
|
||||
|
||||
logger.Info("migration applied", "file", filepath.Base(file))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
75
backend/internal/httpserver/server.go
Normal file
75
backend/internal/httpserver/server.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"mira/backend/internal/auth"
|
||||
"mira/backend/internal/calendar"
|
||||
"mira/backend/internal/clients"
|
||||
"mira/backend/internal/config"
|
||||
"mira/backend/internal/database"
|
||||
"mira/backend/internal/security"
|
||||
"mira/backend/internal/users"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func New(cfg config.Config, logger *slog.Logger, db *database.DB, userStore users.Store, clientStore clients.Store, calendarStore calendar.Store) *gin.Engine {
|
||||
if cfg.AppEnv == "production" {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
router := gin.New()
|
||||
router.Use(gin.Recovery())
|
||||
router.Use(security.Headers())
|
||||
router.Use(security.CORS(cfg.CORSAllowedOrigins))
|
||||
|
||||
router.GET("/healthz", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
|
||||
router.GET("/readyz", func(c *gin.Context) {
|
||||
if err := db.Pool.Ping(c.Request.Context()); err != nil {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"status": "not_ready"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ready"})
|
||||
})
|
||||
|
||||
tokenService := auth.NewTokenService(cfg.JWTSecret, cfg.AccessTokenTTL)
|
||||
requireAuth := auth.RequireAuth(tokenService)
|
||||
authRoutes := auth.NewRoutes(userStore, tokenService)
|
||||
userRoutes := users.NewRoutes(userStore, cfg.AppPublicURL)
|
||||
clientRoutes := clients.NewRoutes(clientStore)
|
||||
calendarRoutes := calendar.NewRoutes(calendarStore, calendar.NewBrasilAPIProvider(cfg.CalendarAPIBaseURL), calendar.NewFeriadosBrasilProvider(), calendar.RouteOptions{
|
||||
UploadMaxSizeMB: cfg.UploadMaxSizeMB,
|
||||
UploadAllowedMIMEs: cfg.UploadAllowedMIMEs,
|
||||
StorageDriver: cfg.StorageDriver,
|
||||
StorageLocalPath: cfg.StorageLocalPath,
|
||||
})
|
||||
|
||||
api := router.Group("/api/v1")
|
||||
authRoutes.Register(api.Group("/auth"))
|
||||
api.GET("/me", requireAuth, func(c *gin.Context) {
|
||||
claims, ok := auth.ClaimsFromContext(c)
|
||||
if !ok {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "Autenticacao obrigatoria."})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := userStore.FindByID(c.Request.Context(), claims.UserID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"message": "Usuario nao encontrado."})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"user": user})
|
||||
})
|
||||
userRoutes.Register(api.Group("/users"), requireAuth)
|
||||
clientRoutes.Register(api.Group("/clients"), requireAuth)
|
||||
calendarRoutes.Register(api.Group("/calendar"), requireAuth)
|
||||
|
||||
logger.Info("routes registered")
|
||||
return router
|
||||
}
|
||||
43
backend/internal/security/headers.go
Normal file
43
backend/internal/security/headers.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package security
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func Headers() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Header("Content-Security-Policy", "default-src 'self'; frame-ancestors 'none'")
|
||||
c.Header("X-Content-Type-Options", "nosniff")
|
||||
c.Header("X-Frame-Options", "DENY")
|
||||
c.Header("Referrer-Policy", "strict-origin-when-cross-origin")
|
||||
c.Header("Permissions-Policy", "camera=(), microphone=(), geolocation=()")
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func CORS(allowedOrigins []string) gin.HandlerFunc {
|
||||
allowed := map[string]bool{}
|
||||
for _, origin := range allowedOrigins {
|
||||
allowed[strings.TrimSpace(origin)] = true
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
origin := c.GetHeader("Origin")
|
||||
if allowed[origin] {
|
||||
c.Header("Access-Control-Allow-Origin", origin)
|
||||
c.Header("Access-Control-Allow-Credentials", "true")
|
||||
c.Header("Access-Control-Allow-Headers", "Authorization, Content-Type")
|
||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
|
||||
}
|
||||
|
||||
if c.Request.Method == http.MethodOptions {
|
||||
c.AbortWithStatus(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
209
backend/internal/users/invitations.go
Normal file
209
backend/internal/users/invitations.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
var ErrInvitationNotFound = errors.New("invitation not found")
|
||||
|
||||
type Invitation struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
ClientID *string `json:"client_id"`
|
||||
ClientName *string `json:"client_name"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
AcceptedAt *time.Time `json:"accepted_at"`
|
||||
CreatedBy *string `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
InvitationURL string `json:"invitation_url,omitempty"`
|
||||
}
|
||||
|
||||
type CreateInvitationInput struct {
|
||||
Email string
|
||||
Role string
|
||||
ClientID string
|
||||
TokenHash string
|
||||
ExpiresAt time.Time
|
||||
CreatedBy string
|
||||
}
|
||||
|
||||
func NewInvitationToken() (string, string, error) {
|
||||
bytes := make([]byte, 32)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
token := base64.RawURLEncoding.EncodeToString(bytes)
|
||||
return token, HashInvitationToken(token), nil
|
||||
}
|
||||
|
||||
func HashInvitationToken(token string) string {
|
||||
sum := sha256.Sum256([]byte(token))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func (s Store) CreateInvitation(ctx context.Context, input CreateInvitationInput) (Invitation, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
INSERT INTO invitations (email, role, client_id, token_hash, expires_at, created_by)
|
||||
VALUES (lower($1), $2, NULLIF($3, '')::uuid, $4, $5, NULLIF($6, '')::uuid)
|
||||
RETURNING
|
||||
invitations.id::text,
|
||||
invitations.email,
|
||||
invitations.role,
|
||||
invitations.client_id::text,
|
||||
(SELECT name FROM clients WHERE clients.id = invitations.client_id),
|
||||
invitations.expires_at,
|
||||
invitations.accepted_at,
|
||||
invitations.created_by::text,
|
||||
invitations.created_at
|
||||
`, input.Email, input.Role, input.ClientID, input.TokenHash, input.ExpiresAt, input.CreatedBy)
|
||||
|
||||
return scanInvitation(row)
|
||||
}
|
||||
|
||||
func (s Store) ListInvitations(ctx context.Context) ([]Invitation, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT
|
||||
invitations.id::text,
|
||||
invitations.email,
|
||||
invitations.role,
|
||||
invitations.client_id::text,
|
||||
clients.name,
|
||||
invitations.expires_at,
|
||||
invitations.accepted_at,
|
||||
invitations.created_by::text,
|
||||
invitations.created_at
|
||||
FROM invitations
|
||||
LEFT JOIN clients ON clients.id = invitations.client_id
|
||||
ORDER BY invitations.created_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
invitations := []Invitation{}
|
||||
for rows.Next() {
|
||||
invitation, err := scanInvitation(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
invitations = append(invitations, invitation)
|
||||
}
|
||||
|
||||
return invitations, rows.Err()
|
||||
}
|
||||
|
||||
func (s Store) AcceptInvitation(ctx context.Context, tokenHash string, name string, passwordHash string) (User, error) {
|
||||
tx, err := s.db.Begin(ctx)
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var invitation Invitation
|
||||
var rawTokenHash string
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT
|
||||
invitations.id::text,
|
||||
invitations.email,
|
||||
invitations.role,
|
||||
invitations.client_id::text,
|
||||
(SELECT name FROM clients WHERE clients.id = invitations.client_id),
|
||||
invitations.expires_at,
|
||||
invitations.accepted_at,
|
||||
invitations.created_by::text,
|
||||
invitations.created_at,
|
||||
invitations.token_hash
|
||||
FROM invitations
|
||||
WHERE invitations.token_hash = $1
|
||||
AND invitations.accepted_at IS NULL
|
||||
AND invitations.expires_at > now()
|
||||
FOR UPDATE
|
||||
`, tokenHash).Scan(
|
||||
&invitation.ID,
|
||||
&invitation.Email,
|
||||
&invitation.Role,
|
||||
&invitation.ClientID,
|
||||
&invitation.ClientName,
|
||||
&invitation.ExpiresAt,
|
||||
&invitation.AcceptedAt,
|
||||
&invitation.CreatedBy,
|
||||
&invitation.CreatedAt,
|
||||
&rawTokenHash,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, ErrInvitationNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
|
||||
user, err := scanUser(tx.QueryRow(ctx, `
|
||||
INSERT INTO users (email, name, password_hash, role, status)
|
||||
VALUES ($1, $2, $3, $4, 'active')
|
||||
RETURNING id::text, email, name, password_hash, role, status, created_at, updated_at
|
||||
`, invitation.Email, name, passwordHash, invitation.Role))
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
|
||||
if invitation.ClientID != nil {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO client_users (client_id, user_id)
|
||||
VALUES ($1::uuid, $2::uuid)
|
||||
ON CONFLICT DO NOTHING
|
||||
`, *invitation.ClientID, user.ID); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
UPDATE invitations
|
||||
SET accepted_at = now()
|
||||
WHERE token_hash = $1
|
||||
`, rawTokenHash); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
type invitationScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanInvitation(row invitationScanner) (Invitation, error) {
|
||||
var invitation Invitation
|
||||
err := row.Scan(
|
||||
&invitation.ID,
|
||||
&invitation.Email,
|
||||
&invitation.Role,
|
||||
&invitation.ClientID,
|
||||
&invitation.ClientName,
|
||||
&invitation.ExpiresAt,
|
||||
&invitation.AcceptedAt,
|
||||
&invitation.CreatedBy,
|
||||
&invitation.CreatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Invitation{}, ErrInvitationNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Invitation{}, err
|
||||
}
|
||||
return invitation, nil
|
||||
}
|
||||
20
backend/internal/users/model.go
Normal file
20
backend/internal/users/model.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package users
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
RoleSuperAdmin = "super_admin"
|
||||
RoleAgencyUser = "agency_user"
|
||||
RoleClientViewer = "client_viewer"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
Name string `json:"name"`
|
||||
PasswordHash string `json:"-"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
118
backend/internal/users/routes.go
Normal file
118
backend/internal/users/routes.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type Routes struct {
|
||||
store Store
|
||||
appPublicURL string
|
||||
}
|
||||
|
||||
func NewRoutes(store Store, appPublicURL string) Routes {
|
||||
return Routes{
|
||||
store: store,
|
||||
appPublicURL: strings.TrimRight(appPublicURL, "/"),
|
||||
}
|
||||
}
|
||||
|
||||
func (r Routes) Register(router *gin.RouterGroup, requireAuth gin.HandlerFunc) {
|
||||
router.Use(requireAuth)
|
||||
|
||||
router.GET("", r.list)
|
||||
router.GET("/invitations", r.listInvitations)
|
||||
router.POST("/invitations", r.createInvitation)
|
||||
}
|
||||
|
||||
func (r Routes) list(c *gin.Context) {
|
||||
if c.GetString("auth_role") != RoleSuperAdmin {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
|
||||
return
|
||||
}
|
||||
|
||||
users, err := r.store.List(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel listar usuarios."})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"users": users})
|
||||
}
|
||||
|
||||
func (r Routes) listInvitations(c *gin.Context) {
|
||||
if c.GetString("auth_role") != RoleSuperAdmin {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
|
||||
return
|
||||
}
|
||||
|
||||
invitations, err := r.store.ListInvitations(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel listar convites."})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"invitations": invitations})
|
||||
}
|
||||
|
||||
type createInvitationRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Role string `json:"role" binding:"required"`
|
||||
ClientID string `json:"client_id"`
|
||||
}
|
||||
|
||||
func (r Routes) createInvitation(c *gin.Context) {
|
||||
if c.GetString("auth_role") != RoleSuperAdmin {
|
||||
c.JSON(http.StatusForbidden, gin.H{"message": "Permissao insuficiente."})
|
||||
return
|
||||
}
|
||||
|
||||
var input createInvitationRequest
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Informe os dados do convite."})
|
||||
return
|
||||
}
|
||||
|
||||
role := strings.TrimSpace(input.Role)
|
||||
if role != RoleAgencyUser && role != RoleClientViewer {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Perfil invalido para convite."})
|
||||
return
|
||||
}
|
||||
if role == RoleClientViewer && strings.TrimSpace(input.ClientID) == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"message": "Convites de cliente precisam de um cliente vinculado."})
|
||||
return
|
||||
}
|
||||
|
||||
token, tokenHash, err := NewInvitationToken()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel gerar o convite."})
|
||||
return
|
||||
}
|
||||
|
||||
invitation, err := r.store.CreateInvitation(c.Request.Context(), CreateInvitationInput{
|
||||
Email: strings.TrimSpace(input.Email),
|
||||
Role: role,
|
||||
ClientID: strings.TrimSpace(input.ClientID),
|
||||
TokenHash: tokenHash,
|
||||
ExpiresAt: time.Now().Add(7 * 24 * time.Hour),
|
||||
CreatedBy: c.GetString("auth_user_id"),
|
||||
})
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"message": "Nao foi possivel criar o convite."})
|
||||
return
|
||||
}
|
||||
|
||||
invitation.InvitationURL = r.invitationURL(token)
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"invitation": invitation})
|
||||
}
|
||||
|
||||
func (r Routes) invitationURL(token string) string {
|
||||
if r.appPublicURL == "" {
|
||||
return "/accept-invitation?token=" + token
|
||||
}
|
||||
return r.appPublicURL + "/accept-invitation?token=" + token
|
||||
}
|
||||
100
backend/internal/users/store.go
Normal file
100
backend/internal/users/store.go
Normal file
@@ -0,0 +1,100 @@
|
||||
package users
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("user not found")
|
||||
|
||||
type Store struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewStore(db *pgxpool.Pool) Store {
|
||||
return Store{db: db}
|
||||
}
|
||||
|
||||
func (s Store) FindByEmail(ctx context.Context, email string) (User, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
SELECT id::text, email, name, password_hash, role, status, created_at, updated_at
|
||||
FROM users
|
||||
WHERE lower(email) = lower($1)
|
||||
`, strings.TrimSpace(email))
|
||||
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func (s Store) FindByID(ctx context.Context, id string) (User, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
SELECT id::text, email, name, password_hash, role, status, created_at, updated_at
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`, id)
|
||||
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func (s Store) List(ctx context.Context) ([]User, error) {
|
||||
rows, err := s.db.Query(ctx, `
|
||||
SELECT id::text, email, name, password_hash, role, status, created_at, updated_at
|
||||
FROM users
|
||||
ORDER BY created_at DESC
|
||||
`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
users := []User{}
|
||||
for rows.Next() {
|
||||
user, err := scanUser(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
users = append(users, user)
|
||||
}
|
||||
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
func (s Store) UpsertSuperAdmin(ctx context.Context, email string, passwordHash string) (User, error) {
|
||||
row := s.db.QueryRow(ctx, `
|
||||
INSERT INTO users (email, name, password_hash, role, status)
|
||||
VALUES ($1, 'Super Admin', $2, 'super_admin', 'active')
|
||||
ON CONFLICT (email)
|
||||
DO UPDATE SET
|
||||
password_hash = EXCLUDED.password_hash,
|
||||
role = 'super_admin',
|
||||
status = 'active',
|
||||
updated_at = now()
|
||||
RETURNING id::text, email, name, password_hash, role, status, created_at, updated_at
|
||||
`, strings.TrimSpace(email), passwordHash)
|
||||
|
||||
return scanUser(row)
|
||||
}
|
||||
|
||||
func scanUser(row pgx.Row) (User, error) {
|
||||
var user User
|
||||
err := row.Scan(
|
||||
&user.ID,
|
||||
&user.Email,
|
||||
&user.Name,
|
||||
&user.PasswordHash,
|
||||
&user.Role,
|
||||
&user.Status,
|
||||
&user.CreatedAt,
|
||||
&user.UpdatedAt,
|
||||
)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return User{}, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
114
backend/migrations/001_initial.sql
Normal file
114
backend/migrations/001_initial.sql
Normal file
@@ -0,0 +1,114 @@
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('super_admin', 'agency_user', 'client_viewer')),
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'invited', 'disabled')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS clients (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS client_users (
|
||||
client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (client_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS invitations (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email TEXT NOT NULL,
|
||||
role TEXT NOT NULL CHECK (role IN ('agency_user', 'client_viewer')),
|
||||
client_id UUID REFERENCES clients(id) ON DELETE CASCADE,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
accepted_at TIMESTAMPTZ,
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS holidays (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
date DATE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'official_holiday',
|
||||
source TEXT NOT NULL DEFAULT 'brasilapi',
|
||||
raw_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (date, source, name)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS commemorative_dates (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_id UUID REFERENCES clients(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
date DATE NOT NULL,
|
||||
recurs_annually BOOLEAN NOT NULL DEFAULT false,
|
||||
visibility TEXT NOT NULL DEFAULT 'agency' CHECK (visibility IN ('agency', 'client')),
|
||||
category TEXT NOT NULL DEFAULT 'custom',
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS calendar_items (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
client_id UUID NOT NULL REFERENCES clients(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
content_type TEXT NOT NULL DEFAULT 'post',
|
||||
status TEXT NOT NULL DEFAULT 'rascunho' CHECK (
|
||||
status IN ('rascunho', 'planejado', 'em_producao', 'em_revisao', 'aprovado', 'publicado', 'cancelado')
|
||||
),
|
||||
owner_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
scheduled_date DATE NOT NULL,
|
||||
scheduled_at TIMESTAMPTZ,
|
||||
copy_text TEXT NOT NULL DEFAULT '',
|
||||
internal_notes TEXT NOT NULL DEFAULT '',
|
||||
client_notes TEXT NOT NULL DEFAULT '',
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS calendar_item_attachments (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
calendar_item_id UUID NOT NULL REFERENCES calendar_items(id) ON DELETE CASCADE,
|
||||
original_filename TEXT NOT NULL,
|
||||
stored_filename TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
size_bytes BIGINT NOT NULL CHECK (size_bytes >= 0),
|
||||
storage_driver TEXT NOT NULL,
|
||||
storage_path TEXT NOT NULL,
|
||||
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
|
||||
action TEXT NOT NULL,
|
||||
entity_type TEXT NOT NULL,
|
||||
entity_id UUID,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
|
||||
CREATE INDEX IF NOT EXISTS idx_calendar_items_client_date ON calendar_items (client_id, scheduled_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_holidays_date ON holidays (date);
|
||||
CREATE INDEX IF NOT EXISTS idx_commemorative_dates_client_date ON commemorative_dates (client_id, date);
|
||||
6
backend/migrations/002_client_details.sql
Normal file
6
backend/migrations/002_client_details.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE clients
|
||||
ADD COLUMN IF NOT EXISTS website_url TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS instagram_url TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS facebook_url TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS linkedin_url TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS notes TEXT NOT NULL DEFAULT '';
|
||||
3
backend/migrations/003_commemorative_unique.sql
Normal file
3
backend/migrations/003_commemorative_unique.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_commemorative_dates_global_unique
|
||||
ON commemorative_dates (date, name, category)
|
||||
WHERE client_id IS NULL;
|
||||
2
backend/migrations/004_client_color.sql
Normal file
2
backend/migrations/004_client_color.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE clients
|
||||
ADD COLUMN IF NOT EXISTS color TEXT NOT NULL DEFAULT '#f97316';
|
||||
69
docker-compose.yml
Normal file
69
docker-compose.yml
Normal file
@@ -0,0 +1,69 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:17-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
backend:
|
||||
build:
|
||||
context: ./backend
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
APP_ENV: ${APP_ENV}
|
||||
APP_PUBLIC_URL: ${APP_PUBLIC_URL}
|
||||
APP_TIMEZONE: ${APP_TIMEZONE}
|
||||
HTTP_ADDR: ${HTTP_ADDR}
|
||||
LOG_LEVEL: ${LOG_LEVEL}
|
||||
SUPER_ADMIN_EMAIL: ${SUPER_ADMIN_EMAIL}
|
||||
SUPER_ADMIN_PASSWORD: ${SUPER_ADMIN_PASSWORD}
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
JWT_ACCESS_TOKEN_TTL_MINUTES: ${JWT_ACCESS_TOKEN_TTL_MINUTES}
|
||||
JWT_REFRESH_TOKEN_TTL_DAYS: ${JWT_REFRESH_TOKEN_TTL_DAYS}
|
||||
CORS_ALLOWED_ORIGINS: ${CORS_ALLOWED_ORIGINS}
|
||||
CALENDAR_PROVIDER: ${CALENDAR_PROVIDER}
|
||||
CALENDAR_API_BASE_URL: ${CALENDAR_API_BASE_URL}
|
||||
CALENDAR_API_KEY: ${CALENDAR_API_KEY}
|
||||
CALENDAR_CACHE_TTL_HOURS: ${CALENDAR_CACHE_TTL_HOURS}
|
||||
SMTP_HOST: ${SMTP_HOST}
|
||||
SMTP_PORT: ${SMTP_PORT}
|
||||
SMTP_USER: ${SMTP_USER}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD}
|
||||
SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL}
|
||||
SMTP_FROM_NAME: ${SMTP_FROM_NAME}
|
||||
UPLOAD_MAX_SIZE_MB: ${UPLOAD_MAX_SIZE_MB}
|
||||
UPLOAD_ALLOWED_MIME_TYPES: ${UPLOAD_ALLOWED_MIME_TYPES}
|
||||
STORAGE_DRIVER: ${STORAGE_DRIVER}
|
||||
STORAGE_LOCAL_PATH: ${STORAGE_LOCAL_PATH}
|
||||
volumes:
|
||||
- uploads:/var/lib/mira/uploads
|
||||
ports:
|
||||
- "${BACKEND_PORT:-8080}:8080"
|
||||
|
||||
frontend:
|
||||
build:
|
||||
context: ./frontend
|
||||
args:
|
||||
VITE_API_BASE_URL: ${VITE_API_BASE_URL}
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- backend
|
||||
ports:
|
||||
- "${FRONTEND_PORT:-5173}:80"
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
uploads:
|
||||
19
docs/api.md
Normal file
19
docs/api.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# API Notes
|
||||
|
||||
Base path: `/api/v1`.
|
||||
|
||||
Current scaffold routes:
|
||||
|
||||
- `GET /healthz`
|
||||
- `GET /readyz`
|
||||
- `POST /api/v1/auth/login`
|
||||
- `POST /api/v1/auth/logout`
|
||||
- `POST /api/v1/auth/refresh`
|
||||
- `GET /api/v1/users`
|
||||
- `POST /api/v1/users/invitations`
|
||||
- `GET /api/v1/clients`
|
||||
- `POST /api/v1/clients`
|
||||
- `GET /api/v1/clients/:clientId`
|
||||
- `PATCH /api/v1/clients/:clientId`
|
||||
- `GET /api/v1/calendar/week`
|
||||
- `GET /api/v1/calendar/holidays?year=2026`
|
||||
36
docs/architecture.md
Normal file
36
docs/architecture.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Architecture Notes
|
||||
|
||||
## Initial Decisions
|
||||
|
||||
- PostgreSQL is the system of record.
|
||||
- BrasilAPI is the initial provider for Brazilian national holidays.
|
||||
- Calendar data from external providers must be cached or persisted before production use.
|
||||
- Regional holidays and custom commemorative dates are first-party data managed inside the app.
|
||||
- Client users are read-only in the MVP.
|
||||
- Agency users can only access assigned clients unless they are super admins.
|
||||
|
||||
## Backend Boundaries
|
||||
|
||||
- `auth`: login, session/token handling, password hashing, invitation acceptance.
|
||||
- `users`: user management and invitations.
|
||||
- `clients`: client records and user-client access.
|
||||
- `calendar`: provider integrations, holiday sync, weekly dashboard, calendar views.
|
||||
- `security`: headers, CORS, rate limiting, auth middleware.
|
||||
- `database`: migrations, connection handling, transactions.
|
||||
|
||||
## Frontend Boundaries
|
||||
|
||||
- Authenticated app shell.
|
||||
- Weekly dashboard.
|
||||
- Client list.
|
||||
- Client calendar.
|
||||
- Day detail.
|
||||
- User and invitation management.
|
||||
- Custom dates.
|
||||
|
||||
## Data Rules
|
||||
|
||||
- Calendar-only dates should be stored as `date`.
|
||||
- Instants with time should be stored in UTC.
|
||||
- The operational timezone is `America/Sao_Paulo`.
|
||||
- Audit logs should record administrative and content changes.
|
||||
26
docs/security.md
Normal file
26
docs/security.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Security Notes
|
||||
|
||||
The application must follow OWASP guidance throughout implementation.
|
||||
|
||||
## Required Controls
|
||||
|
||||
- Strong password hashing.
|
||||
- Login rate limiting.
|
||||
- Backend role and client-scope authorization.
|
||||
- Restrictive CORS.
|
||||
- HTTP security headers.
|
||||
- Parameterized database queries.
|
||||
- Safe upload handling.
|
||||
- Protected attachment access.
|
||||
- Invitation tokens with expiration and single use.
|
||||
- Audit logs for sensitive changes.
|
||||
- No secrets in the repository.
|
||||
|
||||
## Upload Rules
|
||||
|
||||
- Limit file size.
|
||||
- Restrict MIME types.
|
||||
- Validate actual file content.
|
||||
- Rename files before storage.
|
||||
- Do not execute or directly expose uploaded files.
|
||||
- Check client access before serving files.
|
||||
4
frontend/.dockerignore
Normal file
4
frontend/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
.git
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
19
frontend/Dockerfile
Normal file
19
frontend/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG VITE_API_BASE_URL
|
||||
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
25
frontend/components.json
Normal file
25
frontend/components.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#f97316" />
|
||||
<title>Mira</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
6
frontend/metadata.json
Normal file
6
frontend/metadata.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "",
|
||||
"description": "",
|
||||
"requestFramePermissions": [],
|
||||
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
|
||||
}
|
||||
22
frontend/nginx.conf
Normal file
22
frontend/nginx.conf
Normal file
@@ -0,0 +1,22 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|svg|webp|ico)$ {
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
}
|
||||
7720
frontend/package-lock.json
generated
Normal file
7720
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
47
frontend/package.json
Normal file
47
frontend/package.json
Normal file
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "mira-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port=5173 --host=0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"clean": "rm -rf dist server.js",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.5.0",
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
"@google/genai": "^2.4.0",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.21.2",
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.0.1",
|
||||
"react-day-picker": "^10.0.1",
|
||||
"react-dom": "^19.0.1",
|
||||
"react-router-dom": "^7.16.0",
|
||||
"shadcn": "^4.10.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vite": "^6.2.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^22.14.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"esbuild": "^0.25.0",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.3"
|
||||
}
|
||||
}
|
||||
1
frontend/public/.gitkeep
Normal file
1
frontend/public/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
200
frontend/src/App.tsx
Normal file
200
frontend/src/App.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ThemeProvider } from "./components/theme-provider";
|
||||
import { LoginView } from "./views/LoginView";
|
||||
import { AcceptInvitationView } from "./views/AcceptInvitationView";
|
||||
import { DashboardView } from "./views/DashboardView";
|
||||
import { CalendarView } from "./views/CalendarView";
|
||||
import { UsersView } from "./views/UsersView";
|
||||
import { ClientsView } from "./views/ClientsView";
|
||||
import { ClientDashboardView } from "./views/ClientDashboardView";
|
||||
import { PostDetailView } from "./views/PostDetailView";
|
||||
import { Topbar } from "./components/layout/Topbar";
|
||||
import { Sidebar } from "./components/layout/Sidebar";
|
||||
import { clearStoredToken, getStoredToken, loadCurrentUser, type AuthUser } from "@/lib/auth";
|
||||
import { listClients, type Client } from "@/lib/clients";
|
||||
|
||||
export default function App() {
|
||||
const [user, setUser] = useState<AuthUser | null>(null);
|
||||
const [clients, setClients] = useState<Client[]>([]);
|
||||
const [selectedClientId, setSelectedClientId] = useState<string | null>(null);
|
||||
const [isCheckingSession, setIsCheckingSession] = useState(true);
|
||||
const [clientsError, setClientsError] = useState("");
|
||||
const [currentView, setCurrentView] = useState("dashboard"); // 'dashboard' | 'calendar' | 'users'
|
||||
const [selectedPostId, setSelectedPostId] = useState<string | null>(null);
|
||||
const invitationToken =
|
||||
window.location.pathname === "/accept-invitation"
|
||||
? new URLSearchParams(window.location.search).get("token")
|
||||
: null;
|
||||
|
||||
async function refreshClients(token = getStoredToken()) {
|
||||
if (!token) return;
|
||||
try {
|
||||
setClientsError("");
|
||||
const loadedClients = await listClients(token);
|
||||
setClients(loadedClients);
|
||||
setSelectedClientId((current) => {
|
||||
if (!current) return null;
|
||||
return loadedClients.some((client) => client.id === current) ? current : null;
|
||||
});
|
||||
} catch (err) {
|
||||
setClientsError(err instanceof Error ? err.message : "Nao foi possivel carregar clientes.");
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const token = getStoredToken();
|
||||
|
||||
if (!token) {
|
||||
setIsCheckingSession(false);
|
||||
return;
|
||||
}
|
||||
|
||||
loadCurrentUser(token)
|
||||
.then((loadedUser) => {
|
||||
setUser(loadedUser);
|
||||
return refreshClients(token);
|
||||
})
|
||||
.catch(() => {
|
||||
clearStoredToken();
|
||||
setUser(null);
|
||||
})
|
||||
.finally(() => setIsCheckingSession(false));
|
||||
}, []);
|
||||
|
||||
function handleLogout() {
|
||||
clearStoredToken();
|
||||
setUser(null);
|
||||
setClients([]);
|
||||
setSelectedClientId(null);
|
||||
setCurrentView("dashboard");
|
||||
setSelectedPostId(null);
|
||||
}
|
||||
|
||||
function handleLogin(loadedUser: AuthUser) {
|
||||
setUser(loadedUser);
|
||||
void refreshClients();
|
||||
}
|
||||
|
||||
function handleInvitationAccepted(loadedUser: AuthUser) {
|
||||
setUser(loadedUser);
|
||||
void refreshClients();
|
||||
}
|
||||
|
||||
function handleClientCreated(client: Client) {
|
||||
setClients((current) => [...current, client].sort((a, b) => a.name.localeCompare(b.name)));
|
||||
setSelectedClientId(client.id);
|
||||
}
|
||||
|
||||
function handleClientUpdated(client: Client) {
|
||||
setClients((current) => current.map((item) => (item.id === client.id ? client : item)).sort((a, b) => a.name.localeCompare(b.name)));
|
||||
}
|
||||
|
||||
function handleClientDeleted(clientId: string) {
|
||||
setClients((current) => current.filter((client) => client.id !== clientId));
|
||||
setSelectedClientId((current) => (current === clientId ? null : current));
|
||||
setCurrentView((current) => (current === "client-dashboard" && selectedClientId === clientId ? "clients" : current));
|
||||
}
|
||||
|
||||
function handleClientDashboardSelect(clientId: string) {
|
||||
setSelectedClientId(clientId);
|
||||
setCurrentView("client-dashboard");
|
||||
}
|
||||
|
||||
function handlePostSelect(postId: string) {
|
||||
setSelectedPostId(postId);
|
||||
setCurrentView("post-detail");
|
||||
}
|
||||
|
||||
if (isCheckingSession) {
|
||||
return (
|
||||
<ThemeProvider defaultTheme="system" storageKey="mira-theme">
|
||||
<div className="min-h-screen bg-background text-foreground flex items-center justify-center">
|
||||
<div className="text-sm text-muted-foreground">Carregando sessao...</div>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
if (invitationToken) {
|
||||
return (
|
||||
<ThemeProvider defaultTheme="system" storageKey="mira-theme">
|
||||
<AcceptInvitationView token={invitationToken} onAccepted={handleInvitationAccepted} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider defaultTheme="system" storageKey="mira-theme">
|
||||
<LoginView onLogin={handleLogin} />
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider defaultTheme="system" storageKey="mira-theme">
|
||||
<div className="min-h-screen bg-background text-foreground flex flex-col md:flex-row">
|
||||
{/* Mobile Topbar for responsive */}
|
||||
<div className="md:hidden">
|
||||
<Topbar
|
||||
onViewChange={setCurrentView}
|
||||
currentView={currentView}
|
||||
user={user}
|
||||
onLogout={handleLogout}
|
||||
clients={clients}
|
||||
selectedClientId={selectedClientId}
|
||||
onClientSelect={handleClientDashboardSelect}
|
||||
clientsError={clientsError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Desktop Sidebar */}
|
||||
<div className="hidden md:flex sticky top-0 h-screen">
|
||||
<Sidebar
|
||||
onViewChange={setCurrentView}
|
||||
currentView={currentView}
|
||||
user={user}
|
||||
onLogout={handleLogout}
|
||||
clients={clients}
|
||||
selectedClientId={selectedClientId}
|
||||
onClientSelect={handleClientDashboardSelect}
|
||||
clientsError={clientsError}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="flex-1 overflow-x-hidden">
|
||||
<main className="p-4 md:p-8 max-w-7xl mx-auto w-full">
|
||||
{currentView === "dashboard" && (
|
||||
<DashboardView onViewChange={setCurrentView} onPostSelect={handlePostSelect} />
|
||||
)}
|
||||
{currentView === "clients" && (
|
||||
<ClientsView
|
||||
clients={clients}
|
||||
onClientCreated={handleClientCreated}
|
||||
onClientUpdated={handleClientUpdated}
|
||||
onClientDeleted={handleClientDeleted}
|
||||
onClientSelect={setSelectedClientId}
|
||||
/>
|
||||
)}
|
||||
{currentView === "client-dashboard" && selectedClientId && (
|
||||
<ClientDashboardView
|
||||
client={clients.find((client) => client.id === selectedClientId) ?? null}
|
||||
selectedClientId={selectedClientId}
|
||||
onViewChange={setCurrentView}
|
||||
onPostSelect={handlePostSelect}
|
||||
/>
|
||||
)}
|
||||
{currentView === "post-detail" && selectedPostId && (
|
||||
<PostDetailView itemId={selectedPostId} onBack={() => setCurrentView("calendar")} />
|
||||
)}
|
||||
{currentView === "calendar" && (
|
||||
<CalendarView clients={clients} selectedClientId={selectedClientId} user={user} onPostSelect={handlePostSelect} />
|
||||
)}
|
||||
{currentView === "users" && <UsersView clients={clients} />}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
153
frontend/src/components/layout/Sidebar.tsx
Normal file
153
frontend/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { LayoutDashboard, CalendarDays, Users, Settings, LogOut, Search, Building2 } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ModeToggle } from "@/components/mode-toggle";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { AuthUser } from "@/lib/auth";
|
||||
import type { Client } from "@/lib/clients";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
type NavProps = {
|
||||
currentView: string;
|
||||
onViewChange: (target: string) => void;
|
||||
user: AuthUser;
|
||||
onLogout: () => void;
|
||||
clients: Client[];
|
||||
selectedClientId: string | null;
|
||||
onClientSelect: (clientId: string) => void;
|
||||
clientsError: string;
|
||||
};
|
||||
|
||||
export function Sidebar({ currentView, onViewChange, user, onLogout, clients, selectedClientId, onClientSelect, clientsError }: NavProps) {
|
||||
const [clientSearch, setClientSearch] = useState("");
|
||||
const navItems = [
|
||||
{ id: "dashboard", label: "Dashboard Semanal", icon: LayoutDashboard },
|
||||
{ id: "clients", label: "Clientes", icon: Building2 },
|
||||
{ id: "calendar", label: "Calendário", icon: CalendarDays },
|
||||
{ id: "users", label: "Pessoas", icon: Users },
|
||||
];
|
||||
|
||||
const filteredClients = useMemo(() => {
|
||||
const term = clientSearch.trim().toLowerCase();
|
||||
if (!term) return clients;
|
||||
return clients.filter((client) => client.name.toLowerCase().includes(term));
|
||||
}, [clients, clientSearch]);
|
||||
|
||||
return (
|
||||
<aside className="w-64 border-r border-border/40 bg-card h-screen flex flex-col sticky top-0">
|
||||
<div className="h-16 flex items-center px-6 border-b border-border/40">
|
||||
<div className="h-8 w-8 bg-primary rounded-md flex items-center justify-center mr-3">
|
||||
<span className="text-primary-foreground font-bold leading-none">M</span>
|
||||
</div>
|
||||
<span className="font-semibold text-lg tracking-tight">Mira</span>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-b border-border/40">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Buscar clientes..."
|
||||
className="pl-9 h-9 bg-muted/50 border-none shadow-none focus-visible:ring-1"
|
||||
value={clientSearch}
|
||||
onChange={(event) => setClientSearch(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 p-4 space-y-1 overflow-y-auto">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 ml-2">Agência</div>
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = currentView === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onViewChange(item.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center space-x-3 px-3 py-2 rounded-md text-sm transition-colors",
|
||||
isActive
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="pt-5">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 ml-2">Dashboards</div>
|
||||
{clientsError && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{clientsError}
|
||||
</div>
|
||||
)}
|
||||
{!clientsError && filteredClients.length === 0 && (
|
||||
<div className="rounded-md border border-dashed border-border/60 px-3 py-3 text-xs text-muted-foreground">
|
||||
{clients.length === 0 ? "Nenhum cliente cadastrado." : "Nenhum cliente encontrado."}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{filteredClients.map((client) => {
|
||||
const isSelected = currentView === "client-dashboard" && selectedClientId === client.id;
|
||||
return (
|
||||
<button
|
||||
key={client.id}
|
||||
onClick={() => onClientSelect(client.id)}
|
||||
className={cn(
|
||||
"w-full flex items-center space-x-3 px-3 py-2 rounded-md text-sm transition-colors",
|
||||
isSelected
|
||||
? "bg-primary/10 text-primary font-medium"
|
||||
: "text-muted-foreground hover:bg-muted hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<Building2 className="h-4 w-4" />
|
||||
<span className="truncate">{client.name}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-border/40 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<ModeToggle />
|
||||
<Button variant="ghost" size="icon" className="text-muted-foreground h-9 w-9">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-9 w-9 border border-border/50">
|
||||
<AvatarImage src="" />
|
||||
<AvatarFallback className="bg-primary/5 text-primary text-xs">{initials(user.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium leading-none truncate">{user.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate mt-1">{roleLabel(user.role)}</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-destructive" onClick={onLogout}>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
function roleLabel(role: AuthUser["role"]) {
|
||||
if (role === "super_admin") return "Super admin";
|
||||
if (role === "agency_user") return "Agencia";
|
||||
return "Cliente";
|
||||
}
|
||||
|
||||
function initials(name: string) {
|
||||
return name
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.join("") || "M";
|
||||
}
|
||||
74
frontend/src/components/layout/Topbar.tsx
Normal file
74
frontend/src/components/layout/Topbar.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { LogOut, Menu } from "lucide-react";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Sheet, SheetContent, SheetTrigger, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Sidebar } from "./Sidebar";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { AuthUser } from "@/lib/auth";
|
||||
import type { Client } from "@/lib/clients";
|
||||
|
||||
type NavProps = {
|
||||
currentView: string;
|
||||
onViewChange: (target: string) => void;
|
||||
user: AuthUser;
|
||||
onLogout: () => void;
|
||||
clients: Client[];
|
||||
selectedClientId: string | null;
|
||||
onClientSelect: (clientId: string) => void;
|
||||
clientsError: string;
|
||||
};
|
||||
|
||||
export function Topbar({ currentView, onViewChange, user, onLogout, clients, selectedClientId, onClientSelect, clientsError }: NavProps) {
|
||||
return (
|
||||
<header className="h-14 border-b border-border/40 bg-card flex items-center justify-between px-4 sticky top-0 z-30">
|
||||
<div className="flex items-center gap-3">
|
||||
<Sheet>
|
||||
<SheetTrigger className={buttonVariants({ variant: "ghost", size: "icon", className: "-ml-2 h-9 w-9" })}>
|
||||
<Menu className="h-5 w-5" />
|
||||
<span className="sr-only">Toggle Menu</span>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="left" className="p-0 w-72">
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Navigation Menu</SheetTitle>
|
||||
</SheetHeader>
|
||||
<Sidebar
|
||||
currentView={currentView}
|
||||
onViewChange={onViewChange}
|
||||
user={user}
|
||||
onLogout={onLogout}
|
||||
clients={clients}
|
||||
selectedClientId={selectedClientId}
|
||||
onClientSelect={onClientSelect}
|
||||
clientsError={clientsError}
|
||||
/>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-6 w-6 rounded bg-primary flex items-center justify-center">
|
||||
<span className="text-primary-foreground font-bold text-[10px] leading-none">M</span>
|
||||
</div>
|
||||
<span className="font-semibold text-base tracking-tight">Mira</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-8 w-8 border border-border/50">
|
||||
<AvatarFallback className="bg-primary/5 text-primary text-xs">{initials(user.name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground" onClick={onLogout}>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
function initials(name: string) {
|
||||
return name
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase())
|
||||
.join("") || "M";
|
||||
}
|
||||
35
frontend/src/components/mode-toggle.tsx
Normal file
35
frontend/src/components/mode-toggle.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Moon, Sun } from "lucide-react"
|
||||
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { useTheme } from "@/components/theme-provider"
|
||||
|
||||
export function ModeToggle() {
|
||||
const { setTheme } = useTheme()
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className={buttonVariants({ variant: "ghost", size: "icon" })}>
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
Claro
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
Escuro
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
Sistema
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
73
frontend/src/components/theme-provider.tsx
Normal file
73
frontend/src/components/theme-provider.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { createContext, useContext, useEffect, useState, type ReactNode } from "react"
|
||||
|
||||
type Theme = "dark" | "light" | "system"
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: ReactNode
|
||||
defaultTheme?: Theme
|
||||
storageKey?: string
|
||||
}
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
}
|
||||
|
||||
const ThemeProviderContext = createContext<ThemeProviderState>(initialState)
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "vite-ui-theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement
|
||||
|
||||
root.classList.remove("light", "dark")
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light"
|
||||
|
||||
root.classList.add(systemTheme)
|
||||
return
|
||||
}
|
||||
|
||||
root.classList.add(theme)
|
||||
}, [theme])
|
||||
|
||||
const value = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme)
|
||||
setTheme(theme)
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeProviderContext)
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider")
|
||||
|
||||
return context
|
||||
}
|
||||
109
frontend/src/components/ui/avatar.tsx
Normal file
109
frontend/src/components/ui/avatar.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AvatarPrimitive.Root.Props & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({ className, ...props }: AvatarPrimitive.Image.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"aspect-square size-full rounded-full object-cover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: AvatarPrimitive.Fallback.Props) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
52
frontend/src/components/ui/badge.tsx
Normal file
52
frontend/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant = "default",
|
||||
render,
|
||||
...props
|
||||
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
props: mergeProps<"span">(
|
||||
{
|
||||
className: cn(badgeVariants({ variant }), className),
|
||||
},
|
||||
props
|
||||
),
|
||||
render,
|
||||
state: {
|
||||
slot: "badge",
|
||||
variant,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
58
frontend/src/components/ui/button.tsx
Normal file
58
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
219
frontend/src/components/ui/calendar.tsx
Normal file
219
frontend/src/components/ui/calendar.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import * as React from "react"
|
||||
import {
|
||||
DayPicker,
|
||||
getDefaultClassNames,
|
||||
type DayButton,
|
||||
type Locale,
|
||||
} from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button, buttonVariants } from "@/components/ui/button"
|
||||
import { ChevronLeftIcon, ChevronRightIcon, ChevronDownIcon } from "lucide-react"
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
captionLayout = "label",
|
||||
buttonVariant = "ghost",
|
||||
locale,
|
||||
formatters,
|
||||
components,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayPicker> & {
|
||||
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
|
||||
}) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn(
|
||||
"group/calendar bg-background p-2 [--cell-radius:var(--radius-md)] [--cell-size:--spacing(7)] in-data-[slot=card-content]:bg-transparent in-data-[slot=popover-content]:bg-transparent",
|
||||
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
|
||||
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
|
||||
className
|
||||
)}
|
||||
captionLayout={captionLayout}
|
||||
locale={locale}
|
||||
formatters={{
|
||||
formatMonthDropdown: (date) =>
|
||||
date.toLocaleString(locale?.code, { month: "short" }),
|
||||
...formatters,
|
||||
}}
|
||||
classNames={{
|
||||
root: cn("w-fit", defaultClassNames.root),
|
||||
months: cn(
|
||||
"relative flex flex-col gap-4 md:flex-row",
|
||||
defaultClassNames.months
|
||||
),
|
||||
month: cn("flex w-full flex-col gap-4", defaultClassNames.month),
|
||||
nav: cn(
|
||||
"absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1",
|
||||
defaultClassNames.nav
|
||||
),
|
||||
button_previous: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||
defaultClassNames.button_previous
|
||||
),
|
||||
button_next: cn(
|
||||
buttonVariants({ variant: buttonVariant }),
|
||||
"size-(--cell-size) p-0 select-none aria-disabled:opacity-50",
|
||||
defaultClassNames.button_next
|
||||
),
|
||||
month_caption: cn(
|
||||
"flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)",
|
||||
defaultClassNames.month_caption
|
||||
),
|
||||
dropdowns: cn(
|
||||
"flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium",
|
||||
defaultClassNames.dropdowns
|
||||
),
|
||||
dropdown_root: cn(
|
||||
"relative rounded-(--cell-radius)",
|
||||
defaultClassNames.dropdown_root
|
||||
),
|
||||
dropdown: cn(
|
||||
"absolute inset-0 bg-popover opacity-0",
|
||||
defaultClassNames.dropdown
|
||||
),
|
||||
caption_label: cn(
|
||||
"font-medium select-none",
|
||||
captionLayout === "label"
|
||||
? "text-sm"
|
||||
: "flex items-center gap-1 rounded-(--cell-radius) text-sm [&>svg]:size-3.5 [&>svg]:text-muted-foreground",
|
||||
defaultClassNames.caption_label
|
||||
),
|
||||
table: "w-full border-collapse",
|
||||
weekdays: cn("flex", defaultClassNames.weekdays),
|
||||
weekday: cn(
|
||||
"flex-1 rounded-(--cell-radius) text-[0.8rem] font-normal text-muted-foreground select-none",
|
||||
defaultClassNames.weekday
|
||||
),
|
||||
week: cn("mt-2 flex w-full", defaultClassNames.week),
|
||||
week_number_header: cn(
|
||||
"w-(--cell-size) select-none",
|
||||
defaultClassNames.week_number_header
|
||||
),
|
||||
week_number: cn(
|
||||
"text-[0.8rem] text-muted-foreground select-none",
|
||||
defaultClassNames.week_number
|
||||
),
|
||||
day: cn(
|
||||
"group/day relative aspect-square h-full w-full rounded-(--cell-radius) p-0 text-center select-none [&:last-child[data-selected=true]_button]:rounded-r-(--cell-radius)",
|
||||
props.showWeekNumber
|
||||
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-(--cell-radius)"
|
||||
: "[&:first-child[data-selected=true]_button]:rounded-l-(--cell-radius)",
|
||||
defaultClassNames.day
|
||||
),
|
||||
range_start: cn(
|
||||
"relative isolate z-0 rounded-l-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:right-0 after:w-4 after:bg-muted",
|
||||
defaultClassNames.range_start
|
||||
),
|
||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||
range_end: cn(
|
||||
"relative isolate z-0 rounded-r-(--cell-radius) bg-muted after:absolute after:inset-y-0 after:left-0 after:w-4 after:bg-muted",
|
||||
defaultClassNames.range_end
|
||||
),
|
||||
today: cn(
|
||||
"rounded-(--cell-radius) bg-muted text-foreground data-[selected=true]:rounded-none",
|
||||
defaultClassNames.today
|
||||
),
|
||||
outside: cn(
|
||||
"text-muted-foreground aria-selected:text-muted-foreground",
|
||||
defaultClassNames.outside
|
||||
),
|
||||
disabled: cn(
|
||||
"text-muted-foreground opacity-50",
|
||||
defaultClassNames.disabled
|
||||
),
|
||||
hidden: cn("invisible", defaultClassNames.hidden),
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
Root: ({ className, rootRef, ...props }) => {
|
||||
return (
|
||||
<div
|
||||
data-slot="calendar"
|
||||
ref={rootRef}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
Chevron: ({ className, orientation, ...props }) => {
|
||||
if (orientation === "left") {
|
||||
return (
|
||||
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
if (orientation === "right") {
|
||||
return (
|
||||
<ChevronRightIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ChevronDownIcon className={cn("size-4", className)} {...props} />
|
||||
)
|
||||
},
|
||||
DayButton: ({ ...props }) => (
|
||||
<CalendarDayButton locale={locale} {...props} />
|
||||
),
|
||||
WeekNumber: ({ children, ...props }) => {
|
||||
return (
|
||||
<td {...props}>
|
||||
<div className="flex size-(--cell-size) items-center justify-center text-center">
|
||||
{children}
|
||||
</div>
|
||||
</td>
|
||||
)
|
||||
},
|
||||
...components,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
className,
|
||||
day,
|
||||
modifiers,
|
||||
locale,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton> & { locale?: Partial<Locale> }) {
|
||||
const defaultClassNames = getDefaultClassNames()
|
||||
|
||||
const ref = React.useRef<HTMLButtonElement>(null)
|
||||
React.useEffect(() => {
|
||||
if (modifiers.focused) ref.current?.focus()
|
||||
}, [modifiers.focused])
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
data-day={day.date.toLocaleDateString(locale?.code)}
|
||||
data-selected-single={
|
||||
modifiers.selected &&
|
||||
!modifiers.range_start &&
|
||||
!modifiers.range_end &&
|
||||
!modifiers.range_middle
|
||||
}
|
||||
data-range-start={modifiers.range_start}
|
||||
data-range-end={modifiers.range_end}
|
||||
data-range-middle={modifiers.range_middle}
|
||||
className={cn(
|
||||
"relative isolate z-10 flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 border-0 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-[3px] group-data-[focused=true]/day:ring-ring/50 data-[range-end=true]:rounded-(--cell-radius) data-[range-end=true]:rounded-r-(--cell-radius) data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-muted data-[range-middle=true]:text-foreground data-[range-start=true]:rounded-(--cell-radius) data-[range-start=true]:rounded-l-(--cell-radius) data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground dark:hover:text-foreground [&>span]:text-xs [&>span]:opacity-70",
|
||||
defaultClassNames.day,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
103
frontend/src/components/ui/card.tsx
Normal file
103
frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Card({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn(
|
||||
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
}
|
||||
27
frontend/src/components/ui/checkbox.tsx
Normal file
27
frontend/src/components/ui/checkbox.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||
>
|
||||
<CheckIcon
|
||||
/>
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
158
frontend/src/components/ui/dialog.tsx
Normal file
158
frontend/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({ ...props }: DialogPrimitive.Root.Props) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({ ...props }: DialogPrimitive.Trigger.Props) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({ ...props }: DialogPrimitive.Portal.Props) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({ ...props }: DialogPrimitive.Close.Props) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Backdrop
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: DialogPrimitive.Popup.Props & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Popup
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Popup>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close render={<Button variant="outline" />}>
|
||||
Close
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: DialogPrimitive.Description.Props) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
266
frontend/src/components/ui/dropdown-menu.tsx
Normal file
266
frontend/src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import * as React from "react"
|
||||
import { Menu as MenuPrimitive } from "@base-ui/react/menu"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronRightIcon, CheckIcon } from "lucide-react"
|
||||
|
||||
function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) {
|
||||
return <MenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) {
|
||||
return <MenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) {
|
||||
return <MenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
align = "start",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Popup.Props &
|
||||
Pick<
|
||||
MenuPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<MenuPrimitive.Portal>
|
||||
<MenuPrimitive.Positioner
|
||||
className="isolate z-50 outline-none"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
>
|
||||
<MenuPrimitive.Popup
|
||||
data-slot="dropdown-menu-content"
|
||||
className={cn("z-50 max-h-(--available-height) w-(--anchor-width) min-w-32 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:overflow-hidden data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</MenuPrimitive.Positioner>
|
||||
</MenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) {
|
||||
return <MenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.GroupLabel.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.GroupLabel
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: MenuPrimitive.Item.Props & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) {
|
||||
return <MenuPrimitive.SubmenuRoot data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: MenuPrimitive.SubmenuTrigger.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.SubmenuTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-popup-open:bg-accent data-popup-open:text-accent-foreground data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</MenuPrimitive.SubmenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
align = "start",
|
||||
alignOffset = -3,
|
||||
side = "right",
|
||||
sideOffset = 0,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuContent>) {
|
||||
return (
|
||||
<DropdownMenuContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("w-auto min-w-[96px] rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.CheckboxItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.CheckboxItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.CheckboxItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) {
|
||||
return (
|
||||
<MenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: MenuPrimitive.RadioItem.Props & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<MenuPrimitive.RadioItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</MenuPrimitive.RadioItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: MenuPrimitive.Separator.Props) {
|
||||
return (
|
||||
<MenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
20
frontend/src/components/ui/input.tsx
Normal file
20
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
20
frontend/src/components/ui/label.tsx
Normal file
20
frontend/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
88
frontend/src/components/ui/popover.tsx
Normal file
88
frontend/src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import * as React from "react"
|
||||
import { Popover as PopoverPrimitive } from "@base-ui/react/popover"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Popover({ ...props }: PopoverPrimitive.Root.Props) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({ ...props }: PopoverPrimitive.Trigger.Props) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: PopoverPrimitive.Popup.Props &
|
||||
Pick<
|
||||
PopoverPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset"
|
||||
>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Positioner
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<PopoverPrimitive.Popup
|
||||
data-slot="popover-content"
|
||||
className={cn(
|
||||
"z-50 flex w-72 origin-(--transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-2.5 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Positioner>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="popover-header"
|
||||
className={cn("flex flex-col gap-0.5 text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverTitle({ className, ...props }: PopoverPrimitive.Title.Props) {
|
||||
return (
|
||||
<PopoverPrimitive.Title
|
||||
data-slot="popover-title"
|
||||
className={cn("font-medium", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverDescription({
|
||||
className,
|
||||
...props
|
||||
}: PopoverPrimitive.Description.Props) {
|
||||
return (
|
||||
<PopoverPrimitive.Description
|
||||
data-slot="popover-description"
|
||||
className={cn("text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverDescription,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverTrigger,
|
||||
}
|
||||
55
frontend/src/components/ui/scroll-area.tsx
Normal file
55
frontend/src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "@base-ui/react/scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Root.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: ScrollAreaPrimitive.Scrollbar.Props) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Scrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Thumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.Scrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
201
frontend/src/components/ui/select.tsx
Normal file
201
frontend/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Select as SelectPrimitive } from "@base-ui/react/select"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Group
|
||||
data-slot="select-group"
|
||||
className={cn("scroll-my-1 p-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Value
|
||||
data-slot="select-value"
|
||||
className={cn("flex flex-1 text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Trigger.Props & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon
|
||||
render={
|
||||
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
|
||||
}
|
||||
/>
|
||||
</SelectPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
...props
|
||||
}: SelectPrimitive.Popup.Props &
|
||||
Pick<
|
||||
SelectPrimitive.Positioner.Props,
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Positioner
|
||||
side={side}
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
alignItemWithTrigger={alignItemWithTrigger}
|
||||
className="isolate z-50"
|
||||
>
|
||||
<SelectPrimitive.Popup
|
||||
data-slot="select-content"
|
||||
data-align-trigger={alignItemWithTrigger}
|
||||
className={cn("relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.List>{children}</SelectPrimitive.List>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Popup>
|
||||
</SelectPrimitive.Positioner>
|
||||
</SelectPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.GroupLabel.Props) {
|
||||
return (
|
||||
<SelectPrimitive.GroupLabel
|
||||
data-slot="select-label"
|
||||
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SelectPrimitive.Item.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||
{children}
|
||||
</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator
|
||||
render={
|
||||
<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />
|
||||
}
|
||||
>
|
||||
<CheckIcon className="pointer-events-none" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
...props
|
||||
}: SelectPrimitive.Separator.Props) {
|
||||
return (
|
||||
<SelectPrimitive.Separator
|
||||
data-slot="select-separator"
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollUpButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollUpArrow
|
||||
data-slot="select-scroll-up-button"
|
||||
className={cn(
|
||||
"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUpIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollUpArrow>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectScrollDownButton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
|
||||
return (
|
||||
<SelectPrimitive.ScrollDownArrow
|
||||
data-slot="select-scroll-down-button"
|
||||
className={cn(
|
||||
"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
/>
|
||||
</SelectPrimitive.ScrollDownArrow>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectScrollDownButton,
|
||||
SelectScrollUpButton,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
}
|
||||
138
frontend/src/components/ui/sheet.tsx
Normal file
138
frontend/src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Dialog as SheetPrimitive } from "@base-ui/react/dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Sheet({ ...props }: SheetPrimitive.Root.Props) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />
|
||||
}
|
||||
|
||||
function SheetTrigger({ ...props }: SheetPrimitive.Trigger.Props) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
|
||||
}
|
||||
|
||||
function SheetClose({ ...props }: SheetPrimitive.Close.Props) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
|
||||
}
|
||||
|
||||
function SheetPortal({ ...props }: SheetPrimitive.Portal.Props) {
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
|
||||
}
|
||||
|
||||
function SheetOverlay({ className, ...props }: SheetPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Backdrop
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/10 transition-opacity duration-150 data-ending-style:opacity-0 data-starting-style:opacity-0 supports-backdrop-filter:backdrop-blur-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: SheetPrimitive.Popup.Props & {
|
||||
side?: "top" | "right" | "bottom" | "left"
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Popup
|
||||
data-slot="sheet-content"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
"fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-ending-style:opacity-0 data-starting-style:opacity-0 data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=bottom]:data-ending-style:translate-y-[2.5rem] data-[side=bottom]:data-starting-style:translate-y-[2.5rem] data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=left]:data-ending-style:translate-x-[-2.5rem] data-[side=left]:data-starting-style:translate-x-[-2.5rem] data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=right]:data-ending-style:translate-x-[2.5rem] data-[side=right]:data-starting-style:translate-x-[2.5rem] data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=top]:data-ending-style:translate-y-[-2.5rem] data-[side=top]:data-starting-style:translate-y-[-2.5rem] data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<SheetPrimitive.Close
|
||||
data-slot="sheet-close"
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-3 right-3"
|
||||
size="icon-sm"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Popup>
|
||||
</SheetPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-header"
|
||||
className={cn("flex flex-col gap-0.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="sheet-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetTitle({ className, ...props }: SheetPrimitive.Title.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Title
|
||||
data-slot="sheet-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function SheetDescription({
|
||||
className,
|
||||
...props
|
||||
}: SheetPrimitive.Description.Props) {
|
||||
return (
|
||||
<SheetPrimitive.Description
|
||||
data-slot="sheet-description"
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
50
frontend/src/components/ui/sonner.tsx
Normal file
50
frontend/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import { useTheme } from "next-themes"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
|
||||
import type { CSSProperties } from "react"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme()
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: (
|
||||
<CircleCheckIcon className="size-4" />
|
||||
),
|
||||
info: (
|
||||
<InfoIcon className="size-4" />
|
||||
),
|
||||
warning: (
|
||||
<TriangleAlertIcon className="size-4" />
|
||||
),
|
||||
error: (
|
||||
<OctagonXIcon className="size-4" />
|
||||
),
|
||||
loading: (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
),
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as CSSProperties
|
||||
}
|
||||
toastOptions={{
|
||||
classNames: {
|
||||
toast: "cn-toast",
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
114
frontend/src/components/ui/table.tsx
Normal file
114
frontend/src/components/ui/table.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
80
frontend/src/components/ui/tabs.tsx
Normal file
80
frontend/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: TabsPrimitive.Root.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: TabsPrimitive.List.Props & VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Tab
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({ className, ...props }: TabsPrimitive.Panel.Props) {
|
||||
return (
|
||||
<TabsPrimitive.Panel
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
|
||||
18
frontend/src/components/ui/textarea.tsx
Normal file
18
frontend/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
138
frontend/src/index.css
Normal file
138
frontend/src/index.css
Normal file
@@ -0,0 +1,138 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "shadcn/tailwind.css";
|
||||
@import "@fontsource-variable/geist";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--font-heading: var(--font-sans);
|
||||
--font-sans: 'Geist Variable', sans-serif;
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--radius-sm: calc(var(--radius) * 0.6);
|
||||
--radius-md: calc(var(--radius) * 0.8);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) * 1.4);
|
||||
--radius-2xl: calc(var(--radius) * 1.8);
|
||||
--radius-3xl: calc(var(--radius) * 2.2);
|
||||
--radius-4xl: calc(var(--radius) * 2.6);
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: oklch(0.98 0.005 60); /* Off-white */
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.65 0.22 45); /* Orange */
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--radius: 0.625rem;
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.15 0.005 45); /* Near black/charcoal */
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.20 0.006 45);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.20 0.006 45);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.65 0.22 45); /* Orange */
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
--chart-4: oklch(0.371 0 0);
|
||||
--chart-5: oklch(0.269 0 0);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.65 0.22 45);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
html {
|
||||
@apply font-sans;
|
||||
}
|
||||
button:not(:disabled),
|
||||
[role="button"]:not([aria-disabled="true"]),
|
||||
a[href],
|
||||
select,
|
||||
input[type="checkbox"],
|
||||
input[type="file"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
65
frontend/src/lib/api.ts
Normal file
65
frontend/src/lib/api.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080/api/v1";
|
||||
|
||||
type RequestOptions = RequestInit & {
|
||||
token?: string | null;
|
||||
};
|
||||
|
||||
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||
const headers = new Headers(options.headers);
|
||||
headers.set("Content-Type", "application/json");
|
||||
|
||||
if (options.token) {
|
||||
headers.set("Authorization", `Bearer ${options.token}`);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof data === "object" && data !== null && "message" in data
|
||||
? String(data.message)
|
||||
: "Nao foi possivel concluir a solicitacao.";
|
||||
throw new ApiError(message, response.status);
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export async function apiFormRequest<T>(path: string, formData: FormData, token?: string | null): Promise<T> {
|
||||
const headers = new Headers();
|
||||
if (token) {
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
typeof data === "object" && data !== null && "message" in data
|
||||
? String(data.message)
|
||||
: "Nao foi possivel concluir a solicitacao.";
|
||||
throw new ApiError(message, response.status);
|
||||
}
|
||||
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
62
frontend/src/lib/auth.ts
Normal file
62
frontend/src/lib/auth.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { apiRequest } from "@/lib/api";
|
||||
|
||||
const TOKEN_STORAGE_KEY = "mira_access_token";
|
||||
|
||||
export type AuthUser = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: "super_admin" | "agency_user" | "client_viewer";
|
||||
status: string;
|
||||
};
|
||||
|
||||
type LoginResponse = {
|
||||
access_token: string;
|
||||
token_type: "Bearer";
|
||||
user: AuthUser;
|
||||
};
|
||||
|
||||
type MeResponse = {
|
||||
user: AuthUser;
|
||||
};
|
||||
|
||||
export function getStoredToken() {
|
||||
return localStorage.getItem(TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export function storeToken(token: string) {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, token);
|
||||
}
|
||||
|
||||
export function clearStoredToken() {
|
||||
localStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export async function login(email: string, password: string) {
|
||||
const response = await apiRequest<LoginResponse>("/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
storeToken(response.access_token);
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function acceptInvitation(token: string, name: string, password: string) {
|
||||
const response = await apiRequest<LoginResponse>("/auth/invitations/accept", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ token, name, password }),
|
||||
});
|
||||
|
||||
storeToken(response.access_token);
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function loadCurrentUser(token: string) {
|
||||
const response = await apiRequest<MeResponse>("/me", {
|
||||
method: "GET",
|
||||
token,
|
||||
});
|
||||
|
||||
return response.user;
|
||||
}
|
||||
256
frontend/src/lib/calendar.ts
Normal file
256
frontend/src/lib/calendar.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { API_BASE_URL, apiFormRequest, apiRequest } from "@/lib/api";
|
||||
|
||||
export type Holiday = {
|
||||
id: string;
|
||||
name: string;
|
||||
date: string;
|
||||
type: string;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type CustomDate = {
|
||||
id: string;
|
||||
client_id: string | null;
|
||||
name: string;
|
||||
description: string;
|
||||
date: string;
|
||||
recurs_annually: boolean;
|
||||
visibility: "agency" | "client";
|
||||
category: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type CalendarItem = {
|
||||
id: string;
|
||||
client_id: string;
|
||||
client_name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
content_type: string;
|
||||
status: "rascunho" | "planejado" | "em_producao" | "em_revisao" | "aprovado" | "publicado" | "cancelado";
|
||||
owner_id: string | null;
|
||||
scheduled_date: string;
|
||||
scheduled_at: string | null;
|
||||
copy_text: string;
|
||||
internal_notes: string;
|
||||
client_notes: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type Attachment = {
|
||||
id: string;
|
||||
calendar_item_id: string;
|
||||
original_filename: string;
|
||||
stored_filename: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
storage_driver: string;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type CreateCustomDateInput = {
|
||||
client_id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
date: string;
|
||||
recurs_annually: boolean;
|
||||
visibility: "agency" | "client";
|
||||
category: string;
|
||||
};
|
||||
|
||||
export type UpdateCustomDateInput = {
|
||||
client_id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
date: string;
|
||||
recurs_annually: boolean;
|
||||
visibility: "agency" | "client";
|
||||
};
|
||||
|
||||
export type CreateCalendarItemInput = {
|
||||
client_id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
content_type: string;
|
||||
status: CalendarItem["status"];
|
||||
scheduled_date: string;
|
||||
copy_text?: string;
|
||||
internal_notes?: string;
|
||||
client_notes?: string;
|
||||
};
|
||||
|
||||
export type UpdateCalendarItemInput = Partial<{
|
||||
title: string;
|
||||
description: string;
|
||||
status: CalendarItem["status"];
|
||||
scheduled_date: string;
|
||||
copy_text: string;
|
||||
internal_notes: string;
|
||||
client_notes: string;
|
||||
}>;
|
||||
|
||||
type HolidaysResponse = {
|
||||
holidays: Holiday[];
|
||||
};
|
||||
|
||||
type CustomDatesResponse = {
|
||||
custom_dates: CustomDate[];
|
||||
};
|
||||
|
||||
type CustomDateResponse = {
|
||||
custom_date: CustomDate;
|
||||
};
|
||||
|
||||
type CalendarItemsResponse = {
|
||||
items: CalendarItem[];
|
||||
};
|
||||
|
||||
type CalendarItemResponse = {
|
||||
item: CalendarItem;
|
||||
};
|
||||
|
||||
type AttachmentsResponse = {
|
||||
attachments: Attachment[];
|
||||
};
|
||||
|
||||
type AttachmentResponse = {
|
||||
attachment: Attachment;
|
||||
};
|
||||
|
||||
export async function listHolidays(token: string, year: number) {
|
||||
const response = await apiRequest<HolidaysResponse>(`/calendar/holidays?year=${year}`, {
|
||||
method: "GET",
|
||||
token,
|
||||
});
|
||||
|
||||
return response.holidays;
|
||||
}
|
||||
|
||||
export async function listCustomDates(token: string, year: number, clientId?: string | null) {
|
||||
const params = new URLSearchParams({ year: String(year) });
|
||||
if (clientId) {
|
||||
params.set("client_id", clientId);
|
||||
}
|
||||
|
||||
const response = await apiRequest<CustomDatesResponse>(`/calendar/custom-dates?${params.toString()}`, {
|
||||
method: "GET",
|
||||
token,
|
||||
});
|
||||
|
||||
return response.custom_dates;
|
||||
}
|
||||
|
||||
export async function createCustomDate(token: string, input: CreateCustomDateInput) {
|
||||
const response = await apiRequest<CustomDateResponse>("/calendar/custom-dates", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
return response.custom_date;
|
||||
}
|
||||
|
||||
export async function updateCustomDate(token: string, customDateId: string, input: UpdateCustomDateInput) {
|
||||
const response = await apiRequest<CustomDateResponse>(`/calendar/custom-dates/${customDateId}`, {
|
||||
method: "PATCH",
|
||||
token,
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
return response.custom_date;
|
||||
}
|
||||
|
||||
export async function deleteCustomDate(token: string, customDateId: string) {
|
||||
await apiRequest<{ message: string }>(`/calendar/custom-dates/${customDateId}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listCalendarItems(token: string, year: number, clientId?: string | null) {
|
||||
const params = new URLSearchParams({ year: String(year) });
|
||||
if (clientId) {
|
||||
params.set("client_id", clientId);
|
||||
}
|
||||
|
||||
const response = await apiRequest<CalendarItemsResponse>(`/calendar/items?${params.toString()}`, {
|
||||
method: "GET",
|
||||
token,
|
||||
});
|
||||
|
||||
return response.items;
|
||||
}
|
||||
|
||||
export async function listCalendarItemsByRange(token: string, from: string, to: string, clientId?: string | null) {
|
||||
const params = new URLSearchParams({ from, to });
|
||||
if (clientId) {
|
||||
params.set("client_id", clientId);
|
||||
}
|
||||
|
||||
const response = await apiRequest<CalendarItemsResponse>(`/calendar/items?${params.toString()}`, {
|
||||
method: "GET",
|
||||
token,
|
||||
});
|
||||
|
||||
return response.items;
|
||||
}
|
||||
|
||||
export async function createCalendarItem(token: string, input: CreateCalendarItemInput) {
|
||||
const response = await apiRequest<CalendarItemResponse>("/calendar/items", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
return response.item;
|
||||
}
|
||||
|
||||
export async function getCalendarItem(token: string, itemId: string) {
|
||||
const response = await apiRequest<CalendarItemResponse>(`/calendar/items/${itemId}`, {
|
||||
method: "GET",
|
||||
token,
|
||||
});
|
||||
|
||||
return response.item;
|
||||
}
|
||||
|
||||
export async function updateCalendarItem(token: string, itemId: string, input: UpdateCalendarItemInput) {
|
||||
const response = await apiRequest<CalendarItemResponse>(`/calendar/items/${itemId}`, {
|
||||
method: "PATCH",
|
||||
token,
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
return response.item;
|
||||
}
|
||||
|
||||
export async function cancelCalendarItem(token: string, itemId: string) {
|
||||
await apiRequest<{ message: string }>(`/calendar/items/${itemId}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
});
|
||||
}
|
||||
|
||||
export async function listAttachments(token: string, itemId: string) {
|
||||
const response = await apiRequest<AttachmentsResponse>(`/calendar/items/${itemId}/attachments`, {
|
||||
method: "GET",
|
||||
token,
|
||||
});
|
||||
|
||||
return response.attachments;
|
||||
}
|
||||
|
||||
export async function uploadAttachment(token: string, itemId: string, file: File) {
|
||||
const formData = new FormData();
|
||||
formData.set("file", file);
|
||||
|
||||
const response = await apiFormRequest<AttachmentResponse>(`/calendar/items/${itemId}/attachments`, formData, token);
|
||||
return response.attachment;
|
||||
}
|
||||
|
||||
export function attachmentDownloadURL(attachmentId: string) {
|
||||
return `${API_BASE_URL}/calendar/attachments/${attachmentId}/download`;
|
||||
}
|
||||
79
frontend/src/lib/clients.ts
Normal file
79
frontend/src/lib/clients.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { apiRequest } from "@/lib/api";
|
||||
|
||||
export type Client = {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
status: "active" | "archived";
|
||||
website_url: string;
|
||||
instagram_url: string;
|
||||
facebook_url: string;
|
||||
linkedin_url: string;
|
||||
color: string;
|
||||
notes: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
type ClientsResponse = {
|
||||
clients: Client[];
|
||||
};
|
||||
|
||||
type ClientResponse = {
|
||||
client: Client;
|
||||
};
|
||||
|
||||
export async function listClients(token: string) {
|
||||
const response = await apiRequest<ClientsResponse>("/clients", {
|
||||
method: "GET",
|
||||
token,
|
||||
});
|
||||
|
||||
return response.clients;
|
||||
}
|
||||
|
||||
export async function createClient(token: string, name: string) {
|
||||
const response = await apiRequest<ClientResponse>("/clients", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
|
||||
return response.client;
|
||||
}
|
||||
|
||||
export async function updateClient(token: string, client: Client) {
|
||||
const response = await apiRequest<ClientResponse>(`/clients/${client.id}`, {
|
||||
method: "PATCH",
|
||||
token,
|
||||
body: JSON.stringify({
|
||||
name: client.name,
|
||||
status: client.status,
|
||||
website_url: client.website_url,
|
||||
instagram_url: client.instagram_url,
|
||||
facebook_url: client.facebook_url,
|
||||
linkedin_url: client.linkedin_url,
|
||||
color: client.color,
|
||||
notes: client.notes,
|
||||
}),
|
||||
});
|
||||
|
||||
return response.client;
|
||||
}
|
||||
|
||||
export async function archiveClient(token: string, client: Client) {
|
||||
const response = await apiRequest<ClientResponse>(`/clients/${client.id}`, {
|
||||
method: "PATCH",
|
||||
token,
|
||||
body: JSON.stringify({ ...client, status: "archived" }),
|
||||
});
|
||||
|
||||
return response.client;
|
||||
}
|
||||
|
||||
export async function deleteClient(token: string, clientId: string) {
|
||||
await apiRequest<{ message: string }>(`/clients/${clientId}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
});
|
||||
}
|
||||
54
frontend/src/lib/users.ts
Normal file
54
frontend/src/lib/users.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { apiRequest } from "@/lib/api";
|
||||
import type { AuthUser } from "@/lib/auth";
|
||||
|
||||
export type Invitation = {
|
||||
id: string;
|
||||
email: string;
|
||||
role: "agency_user" | "client_viewer";
|
||||
client_id: string | null;
|
||||
client_name: string | null;
|
||||
expires_at: string;
|
||||
accepted_at: string | null;
|
||||
created_at: string;
|
||||
invitation_url?: string;
|
||||
};
|
||||
|
||||
type UsersResponse = {
|
||||
users: AuthUser[];
|
||||
};
|
||||
|
||||
type InvitationsResponse = {
|
||||
invitations: Invitation[];
|
||||
};
|
||||
|
||||
type InvitationResponse = {
|
||||
invitation: Invitation;
|
||||
};
|
||||
|
||||
export async function listUsers(token: string) {
|
||||
const response = await apiRequest<UsersResponse>("/users", {
|
||||
method: "GET",
|
||||
token,
|
||||
});
|
||||
|
||||
return response.users;
|
||||
}
|
||||
|
||||
export async function listInvitations(token: string) {
|
||||
const response = await apiRequest<InvitationsResponse>("/users/invitations", {
|
||||
method: "GET",
|
||||
token,
|
||||
});
|
||||
|
||||
return response.invitations;
|
||||
}
|
||||
|
||||
export async function createInvitation(token: string, input: { email: string; role: Invitation["role"]; client_id?: string }) {
|
||||
const response = await apiRequest<InvitationResponse>("/users/invitations", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
|
||||
return response.invitation;
|
||||
}
|
||||
6
frontend/src/lib/utils.ts
Normal file
6
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import {StrictMode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
87
frontend/src/views/AcceptInvitationView.tsx
Normal file
87
frontend/src/views/AcceptInvitationView.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { ArrowRight, CalendarDays } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { acceptInvitation, type AuthUser } from "@/lib/auth";
|
||||
|
||||
type AcceptInvitationViewProps = {
|
||||
token: string;
|
||||
onAccepted: (user: AuthUser) => void;
|
||||
};
|
||||
|
||||
export function AcceptInvitationView({ token, onAccepted }: AcceptInvitationViewProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError("");
|
||||
try {
|
||||
const response = await acceptInvitation(token, name, password);
|
||||
onAccepted(response.user);
|
||||
window.history.replaceState({}, "", "/");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Não foi possível aceitar o convite.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground flex items-center justify-center p-6">
|
||||
<div className="w-full max-w-md space-y-8">
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="h-12 w-12 rounded-xl bg-primary text-primary-foreground flex items-center justify-center mx-auto">
|
||||
<CalendarDays className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Aceitar convite</h1>
|
||||
<p className="text-sm text-muted-foreground mt-2">Crie sua senha para acessar o Mira.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="invite-name">Nome</Label>
|
||||
<Input
|
||||
id="invite-name"
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
placeholder="Seu nome"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="invite-password">Senha</Label>
|
||||
<Input
|
||||
id="invite-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="Mínimo de 12 caracteres"
|
||||
minLength={12}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button className="w-full" type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Ativando..." : "Entrar no Mira"}
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1164
frontend/src/views/CalendarView.tsx
Normal file
1164
frontend/src/views/CalendarView.tsx
Normal file
File diff suppressed because it is too large
Load Diff
249
frontend/src/views/ClientDashboardView.tsx
Normal file
249
frontend/src/views/ClientDashboardView.tsx
Normal file
@@ -0,0 +1,249 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AlertCircle, CalendarDays, CheckCircle2, Clock, ExternalLink, FileText, Plus } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { getStoredToken } from "@/lib/auth";
|
||||
import {
|
||||
listCalendarItemsByRange,
|
||||
listCustomDates,
|
||||
listHolidays,
|
||||
type CalendarItem,
|
||||
type CustomDate,
|
||||
type Holiday,
|
||||
} from "@/lib/calendar";
|
||||
import type { Client } from "@/lib/clients";
|
||||
|
||||
type ClientDashboardViewProps = {
|
||||
client: Client | null;
|
||||
selectedClientId: string;
|
||||
onViewChange: (view: string) => void;
|
||||
onPostSelect: (postId: string) => void;
|
||||
};
|
||||
|
||||
const statusLabels: Record<CalendarItem["status"], string> = {
|
||||
rascunho: "Rascunho",
|
||||
planejado: "Planejado",
|
||||
em_producao: "Em produção",
|
||||
em_revisao: "Em revisão",
|
||||
aprovado: "Aprovado",
|
||||
publicado: "Publicado",
|
||||
cancelado: "Cancelado",
|
||||
};
|
||||
|
||||
const shortDateFormatter = new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "short" });
|
||||
const longDateFormatter = new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "long", year: "numeric" });
|
||||
|
||||
export function ClientDashboardView({ client, selectedClientId, onViewChange, onPostSelect }: ClientDashboardViewProps) {
|
||||
const today = useMemo(() => new Date(), []);
|
||||
const monthStart = useMemo(() => new Date(today.getFullYear(), today.getMonth(), 1), [today]);
|
||||
const monthEnd = useMemo(() => new Date(today.getFullYear(), today.getMonth() + 1, 0), [today]);
|
||||
const [items, setItems] = useState<CalendarItem[]>([]);
|
||||
const [holidays, setHolidays] = useState<Holiday[]>([]);
|
||||
const [customDates, setCustomDates] = useState<CustomDate[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const monthStartValue = formatDateValue(monthStart);
|
||||
const monthEndValue = formatDateValue(monthEnd);
|
||||
const todayValue = formatDateValue(today);
|
||||
const year = today.getFullYear();
|
||||
|
||||
useEffect(() => {
|
||||
const token = getStoredToken();
|
||||
if (!token) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
Promise.all([
|
||||
listCalendarItemsByRange(token, monthStartValue, monthEndValue, selectedClientId),
|
||||
listHolidays(token, year),
|
||||
listCustomDates(token, year, selectedClientId),
|
||||
])
|
||||
.then(([loadedItems, loadedHolidays, loadedCustomDates]) => {
|
||||
setItems(loadedItems);
|
||||
setHolidays(loadedHolidays);
|
||||
setCustomDates(loadedCustomDates);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "Não foi possível carregar o dashboard do cliente.");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [monthEndValue, monthStartValue, selectedClientId, year]);
|
||||
|
||||
const visibleItems = items
|
||||
.filter((item) => item.status !== "cancelado")
|
||||
.sort((a, b) => a.scheduled_date.localeCompare(b.scheduled_date));
|
||||
const upcomingItems = visibleItems.filter((item) => item.scheduled_date >= todayValue);
|
||||
const reviewItems = visibleItems.filter((item) => item.status === "em_revisao");
|
||||
const approvedItems = visibleItems.filter((item) => item.status === "aprovado" || item.status === "publicado");
|
||||
const dates = [...holidays, ...customDates]
|
||||
.filter((date) => date.date >= monthStartValue && date.date <= monthEndValue)
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
|
||||
if (!client) {
|
||||
return (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
Cliente não encontrado.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Dashboard de {client.name}</h1>
|
||||
<Badge variant="outline">{client.status === "archived" ? "Arquivado" : "Ativo"}</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Visão do cliente entre {shortDateFormatter.format(monthStart)} e {shortDateFormatter.format(monthEnd)}.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={() => onViewChange("calendar")}>
|
||||
<CalendarDays className="mr-2 h-4 w-4" />
|
||||
Calendário
|
||||
</Button>
|
||||
<Button size="sm" className="h-9" onClick={() => onViewChange("calendar")}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Novo Post
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<MetricCard title="Posts do mês" value={visibleItems.length} detail={`${upcomingItems.length} próximos`} />
|
||||
<MetricCard title="Em aprovação" value={reviewItems.length} detail="Aguardando retorno" />
|
||||
<MetricCard title="Aprovados" value={approvedItems.length} detail="Prontos ou publicados" emphasize />
|
||||
<MetricCard title="Datas do mês" value={dates.length} detail="Feriados e datas úteis" />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<Card className="lg:col-span-2 shadow-sm">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
Linha do tempo de posts
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-2 space-y-3">
|
||||
{isLoading && <p className="text-sm text-muted-foreground py-6">Carregando posts...</p>}
|
||||
{!isLoading && visibleItems.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-6">Nenhum post previsto para este mês.</p>
|
||||
)}
|
||||
{visibleItems.map((item) => (
|
||||
<button key={item.id} type="button" className="w-full rounded-lg border border-border/60 bg-card p-4 text-left hover:bg-muted/30" onClick={() => onPostSelect(item.id)}>
|
||||
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="text-[10px] bg-orange-500/10 text-orange-700 dark:text-orange-300">
|
||||
{statusLabels[item.status]}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{longDateFormatter.format(parseDateValue(item.scheduled_date))}
|
||||
</span>
|
||||
</div>
|
||||
<h2 className="font-medium text-sm text-foreground">{item.title}</h2>
|
||||
{item.description && <p className="text-sm text-muted-foreground">{item.description}</p>}
|
||||
{item.client_notes && <p className="text-sm text-muted-foreground">{item.client_notes}</p>}
|
||||
</div>
|
||||
{item.status === "aprovado" || item.status === "publicado" ? (
|
||||
<CheckCircle2 className="h-5 w-5 shrink-0 text-primary" />
|
||||
) : (
|
||||
<AlertCircle className="h-5 w-5 shrink-0 text-orange-500" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<CalendarDays className="h-4 w-4 text-muted-foreground" />
|
||||
Datas relevantes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-2 space-y-3">
|
||||
{dates.map((date) => (
|
||||
<div key={`${date.date}-${date.name}`} className="flex items-center justify-between gap-3 text-sm">
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium truncate">{date.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{"source" in date ? "Feriado nacional" : date.category === "feriados-brasil" ? "Data comemorativa" : "Data da agência"}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline" className="font-mono bg-muted/40">
|
||||
{shortDateFormatter.format(parseDateValue(date.date))}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
{dates.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-4">Nenhuma data relevante neste mês.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{(client.website_url || client.instagram_url || client.facebook_url || client.linkedin_url) && (
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-base">Links do cliente</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-2 space-y-2">
|
||||
<ClientLink label="Website" url={client.website_url} />
|
||||
<ClientLink label="Instagram" url={client.instagram_url} />
|
||||
<ClientLink label="Facebook" url={client.facebook_url} />
|
||||
<ClientLink label="LinkedIn" url={client.linkedin_url} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ title, value, detail, emphasize = false }: { title: string; value: number; detail: string; emphasize?: boolean }) {
|
||||
return (
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className={`text-2xl font-semibold ${emphasize ? "text-primary" : ""}`}>{value}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{detail}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientLink({ label, url }: { label: string; url: string }) {
|
||||
if (!url) return null;
|
||||
|
||||
return (
|
||||
<a className="flex items-center justify-between gap-3 rounded-lg border border-border/60 px-3 py-2 text-sm hover:bg-muted/30" href={url} target="_blank" rel="noreferrer">
|
||||
<span>{label}</span>
|
||||
<ExternalLink className="h-4 w-4 text-muted-foreground" />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function formatDateValue(date: Date) {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function parseDateValue(value: string) {
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
345
frontend/src/views/ClientsView.tsx
Normal file
345
frontend/src/views/ClientsView.tsx
Normal file
@@ -0,0 +1,345 @@
|
||||
import { Archive, Building2, ChevronDown, ChevronUp, Plus, Save, Trash2 } from "lucide-react";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { archiveClient, createClient, deleteClient, updateClient, type Client } from "@/lib/clients";
|
||||
import { getStoredToken } from "@/lib/auth";
|
||||
|
||||
type ClientsViewProps = {
|
||||
clients: Client[];
|
||||
onClientCreated: (client: Client) => void;
|
||||
onClientUpdated: (client: Client) => void;
|
||||
onClientDeleted: (clientId: string) => void;
|
||||
onClientSelect: (clientId: string) => void;
|
||||
};
|
||||
|
||||
export function ClientsView({ clients, onClientCreated, onClientUpdated, onClientDeleted, onClientSelect }: ClientsViewProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [editing, setEditing] = useState<Record<string, Client>>({});
|
||||
const [expandedClients, setExpandedClients] = useState<Record<string, boolean>>({});
|
||||
const [clientToDelete, setClientToDelete] = useState<Client | null>(null);
|
||||
const [deleteConfirmation, setDeleteConfirmation] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [busyClientId, setBusyClientId] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [deleteError, setDeleteError] = useState("");
|
||||
const colorOptions = ["#f97316", "#06b6d4", "#22c55e", "#a855f7", "#ef4444", "#eab308", "#14b8a6", "#64748b"];
|
||||
|
||||
async function handleCreate(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const token = getStoredToken();
|
||||
if (!token) return;
|
||||
|
||||
setError("");
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const client = await createClient(token, name);
|
||||
onClientCreated(client);
|
||||
setName("");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Nao foi possivel criar o cliente.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function draftFor(client: Client) {
|
||||
return editing[client.id] ?? client;
|
||||
}
|
||||
|
||||
function updateDraft(client: Client, field: keyof Client, value: string) {
|
||||
setEditing((current) => ({
|
||||
...current,
|
||||
[client.id]: { ...draftFor(client), [field]: value },
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleSave(client: Client) {
|
||||
const token = getStoredToken();
|
||||
if (!token) return;
|
||||
|
||||
setBusyClientId(client.id);
|
||||
setError("");
|
||||
try {
|
||||
const updated = await updateClient(token, draftFor(client));
|
||||
onClientUpdated(updated);
|
||||
setEditing((current) => {
|
||||
const copy = { ...current };
|
||||
delete copy[client.id];
|
||||
return copy;
|
||||
});
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Nao foi possivel atualizar o cliente.");
|
||||
} finally {
|
||||
setBusyClientId("");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleArchive(client: Client) {
|
||||
const token = getStoredToken();
|
||||
if (!token) return;
|
||||
|
||||
setBusyClientId(client.id);
|
||||
setError("");
|
||||
try {
|
||||
const archived = await archiveClient(token, draftFor(client));
|
||||
onClientUpdated(archived);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Nao foi possivel arquivar o cliente.");
|
||||
} finally {
|
||||
setBusyClientId("");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteClient() {
|
||||
const token = getStoredToken();
|
||||
if (!token || !clientToDelete || deleteConfirmation !== clientToDelete.name) return;
|
||||
|
||||
setBusyClientId(clientToDelete.id);
|
||||
setDeleteError("");
|
||||
try {
|
||||
await deleteClient(token, clientToDelete.id);
|
||||
onClientDeleted(clientToDelete.id);
|
||||
setClientToDelete(null);
|
||||
setDeleteConfirmation("");
|
||||
} catch (err) {
|
||||
setDeleteError(err instanceof Error ? err.message : "Nao foi possivel excluir o cliente.");
|
||||
} finally {
|
||||
setBusyClientId("");
|
||||
}
|
||||
}
|
||||
|
||||
function viewAsClient(client: Client) {
|
||||
onClientSelect(client.id);
|
||||
setExpandedClients((current) => ({ ...current, [client.id]: true }));
|
||||
}
|
||||
|
||||
function toggleClient(client: Client) {
|
||||
onClientSelect(client.id);
|
||||
setExpandedClients((current) => ({ ...current, [client.id]: !current[client.id] }));
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Clientes</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Cadastre e organize os clientes atendidos pela agência.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Novo cliente</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="flex flex-col sm:flex-row gap-3" onSubmit={handleCreate}>
|
||||
<div className="grid gap-2 flex-1">
|
||||
<Label htmlFor="client-name">Nome do cliente</Label>
|
||||
<Input id="client-name" placeholder="Ex.: TechCorp" value={name} onChange={(event) => setName(event.target.value)} required />
|
||||
</div>
|
||||
<Button className="sm:self-end" type="submit" disabled={isSubmitting}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{isSubmitting ? "Criando..." : "Criar cliente"}
|
||||
</Button>
|
||||
</form>
|
||||
{error && (
|
||||
<div className="mt-3 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{clients.length === 0 && (
|
||||
<div className="flex items-center justify-center h-[260px] border border-dashed border-border rounded-xl">
|
||||
<div className="text-center space-y-3">
|
||||
<div className="bg-muted w-12 h-12 rounded-full flex items-center justify-center mx-auto">
|
||||
<Building2 className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium">Nenhum cliente cadastrado</h3>
|
||||
<p className="text-sm text-muted-foreground w-[300px]">Crie o primeiro cliente para começar a montar calendários e posts.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{clients.map((client) => {
|
||||
const draft = draftFor(client);
|
||||
const isArchived = client.status === "archived";
|
||||
const isExpanded = expandedClients[client.id] ?? false;
|
||||
return (
|
||||
<Card key={client.id} className={isArchived ? "opacity-70" : ""}>
|
||||
<CardContent className="p-4 space-y-4">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="h-3 w-3 shrink-0 rounded-full" style={{ backgroundColor: client.color || "#f97316" }} />
|
||||
<h3 className="font-medium truncate">{client.name}</h3>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground truncate">/{client.slug}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Badge variant={isArchived ? "outline" : "secondary"}>{isArchived ? "Arquivado" : "Ativo"}</Badge>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => toggleClient(client)}>
|
||||
{isExpanded ? <ChevronUp className="mr-2 h-4 w-4" /> : <ChevronDown className="mr-2 h-4 w-4" />}
|
||||
{isExpanded ? "Minimizar" : "Detalhes"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<Field label="Nome" value={draft.name} onChange={(value) => updateDraft(client, "name", value)} />
|
||||
<Field label="Website" value={draft.website_url} onChange={(value) => updateDraft(client, "website_url", value)} />
|
||||
<Field label="Instagram" value={draft.instagram_url} onChange={(value) => updateDraft(client, "instagram_url", value)} />
|
||||
<Field label="Facebook" value={draft.facebook_url} onChange={(value) => updateDraft(client, "facebook_url", value)} />
|
||||
<Field label="LinkedIn" value={draft.linkedin_url} onChange={(value) => updateDraft(client, "linkedin_url", value)} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Cor do cliente</Label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{colorOptions.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
aria-label={`Selecionar cor ${color}`}
|
||||
className={`h-8 w-8 rounded-full border-2 transition ${draft.color === color ? "border-foreground" : "border-transparent"}`}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => updateDraft(client, "color", color)}
|
||||
/>
|
||||
))}
|
||||
<Input
|
||||
className="h-9 w-[120px] font-mono"
|
||||
value={draft.color}
|
||||
onChange={(event) => updateDraft(client, "color", event.target.value)}
|
||||
pattern="^#[0-9a-fA-F]{6}$"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Notas</Label>
|
||||
<Textarea value={draft.notes} onChange={(event) => updateDraft(client, "notes", event.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" onClick={() => handleSave(client)} disabled={busyClientId === client.id}>
|
||||
<Save className="mr-2 h-4 w-4" />
|
||||
Salvar
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => viewAsClient(client)}>
|
||||
Atualizar dashboard
|
||||
</Button>
|
||||
{!isArchived && (
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => handleArchive(client)} disabled={busyClientId === client.id}>
|
||||
<Archive className="mr-2 h-4 w-4" />
|
||||
Arquivar
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
setClientToDelete(client);
|
||||
setDeleteConfirmation("");
|
||||
setDeleteError("");
|
||||
}}
|
||||
disabled={busyClientId === client.id}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Excluir
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<Dialog open={clientToDelete !== null} onOpenChange={(open) => {
|
||||
if (open) return;
|
||||
setClientToDelete(null);
|
||||
setDeleteConfirmation("");
|
||||
setDeleteError("");
|
||||
}}>
|
||||
<DialogContent className="sm:max-w-[460px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Excluir cliente</DialogTitle>
|
||||
<DialogDescription>
|
||||
Esta ação exclui permanentemente o cliente, seus vínculos, posts e datas personalizadas. Para confirmar, digite o nome do cliente.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{clientToDelete && (
|
||||
<div className="space-y-4 pt-2">
|
||||
<div className="rounded-lg border border-border/60 bg-muted/30 px-3 py-2 text-sm">
|
||||
<span className="text-muted-foreground">Nome do cliente: </span>
|
||||
<span className="font-medium">{clientToDelete.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="delete-client-confirmation">Digite o nome do cliente</Label>
|
||||
<Input
|
||||
id="delete-client-confirmation"
|
||||
value={deleteConfirmation}
|
||||
onChange={(event) => setDeleteConfirmation(event.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{deleteError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{deleteError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setClientToDelete(null);
|
||||
setDeleteConfirmation("");
|
||||
setDeleteError("");
|
||||
}}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={deleteConfirmation !== clientToDelete.name || busyClientId === clientToDelete.id}
|
||||
onClick={handleDeleteClient}
|
||||
>
|
||||
{busyClientId === clientToDelete.id ? "Excluindo..." : "Excluir definitivamente"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, value, onChange }: { label: string; value: string; onChange: (value: string) => void }) {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Label>{label}</Label>
|
||||
<Input value={value} onChange={(event) => onChange(event.target.value)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
344
frontend/src/views/DashboardView.tsx
Normal file
344
frontend/src/views/DashboardView.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { AlertCircle, Calendar as CalendarIcon, CheckCircle2, Clock, FileImage, Filter, Plus } from "lucide-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { getStoredToken } from "@/lib/auth";
|
||||
import {
|
||||
listCalendarItemsByRange,
|
||||
listCustomDates,
|
||||
listHolidays,
|
||||
type CalendarItem,
|
||||
type CustomDate,
|
||||
type Holiday,
|
||||
} from "@/lib/calendar";
|
||||
|
||||
type DashboardViewProps = {
|
||||
selectedClientId?: string | null;
|
||||
title?: string;
|
||||
description?: string;
|
||||
compact?: boolean;
|
||||
onViewChange: (view: string) => void;
|
||||
onPostSelect: (postId: string) => void;
|
||||
};
|
||||
|
||||
const statusLabels: Record<CalendarItem["status"], string> = {
|
||||
rascunho: "Rascunho",
|
||||
planejado: "Planejado",
|
||||
em_producao: "Em produção",
|
||||
em_revisao: "Em revisão",
|
||||
aprovado: "Aprovado",
|
||||
publicado: "Publicado",
|
||||
cancelado: "Cancelado",
|
||||
};
|
||||
|
||||
const shortDateFormatter = new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "short" });
|
||||
|
||||
export function DashboardView({
|
||||
selectedClientId = null,
|
||||
title = "Dashboard Semanal",
|
||||
description,
|
||||
compact = false,
|
||||
onViewChange,
|
||||
onPostSelect,
|
||||
}: DashboardViewProps) {
|
||||
const today = useMemo(() => new Date(), []);
|
||||
const weekStart = useMemo(() => startOfWeek(today), [today]);
|
||||
const weekEnd = useMemo(() => addDays(weekStart, 6), [weekStart]);
|
||||
const [items, setItems] = useState<CalendarItem[]>([]);
|
||||
const [holidays, setHolidays] = useState<Holiday[]>([]);
|
||||
const [customDates, setCustomDates] = useState<CustomDate[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | CalendarItem["status"]>("all");
|
||||
const [searchFilter, setSearchFilter] = useState("");
|
||||
|
||||
const todayValue = formatDateValue(today);
|
||||
const weekStartValue = formatDateValue(weekStart);
|
||||
const weekEndValue = formatDateValue(weekEnd);
|
||||
const years = useMemo(() => yearsInRange(weekStart, weekEnd), [weekEnd, weekStart]);
|
||||
const yearsKey = years.join(",");
|
||||
|
||||
useEffect(() => {
|
||||
const token = getStoredToken();
|
||||
if (!token) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
Promise.all([
|
||||
listCalendarItemsByRange(token, weekStartValue, weekEndValue, selectedClientId),
|
||||
Promise.all(years.map((year) => listHolidays(token, year))),
|
||||
Promise.all(years.map((year) => listCustomDates(token, year, selectedClientId))),
|
||||
])
|
||||
.then(([loadedItems, loadedHolidaysByYear, loadedCustomDatesByYear]) => {
|
||||
setItems(loadedItems);
|
||||
setHolidays(loadedHolidaysByYear.flat());
|
||||
setCustomDates(loadedCustomDatesByYear.flat());
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "Não foi possível carregar o dashboard.");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [selectedClientId, weekEndValue, weekStartValue, yearsKey]);
|
||||
|
||||
const filteredItems = items.filter((item) => {
|
||||
const matchesStatus = statusFilter === "all" || item.status === statusFilter;
|
||||
const term = searchFilter.trim().toLowerCase();
|
||||
const matchesSearch =
|
||||
term === "" ||
|
||||
item.title.toLowerCase().includes(term) ||
|
||||
item.client_name.toLowerCase().includes(term);
|
||||
return matchesStatus && matchesSearch;
|
||||
});
|
||||
const todayItems = filteredItems.filter((item) => item.scheduled_date === todayValue);
|
||||
const reviewItems = filteredItems.filter((item) => item.status === "em_revisao");
|
||||
const approvedItems = filteredItems.filter((item) => item.status === "aprovado" || item.status === "publicado");
|
||||
const pendingItems = filteredItems.filter((item) => !["aprovado", "publicado", "cancelado"].includes(item.status));
|
||||
const weekDates = [...holidays, ...customDates]
|
||||
.filter((date) => date.date >= weekStartValue && date.date <= weekEndValue)
|
||||
.sort((a, b) => a.date.localeCompare(b.date));
|
||||
const warningDate = weekDates[0];
|
||||
const periodDescription =
|
||||
description ??
|
||||
`Visão geral entre ${shortDateFormatter.format(weekStart)} e ${shortDateFormatter.format(weekEnd)}.`;
|
||||
|
||||
return (
|
||||
<div className={compact ? "space-y-4" : "space-y-6"}>
|
||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className={compact ? "text-lg font-semibold tracking-tight" : "text-2xl font-semibold tracking-tight"}>{title}</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{periodDescription}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" className="h-9" onClick={() => setShowFilters((current) => !current)}>
|
||||
<Filter className="mr-2 h-4 w-4" />
|
||||
Filtros
|
||||
</Button>
|
||||
<Button size="sm" className="h-9" onClick={() => onViewChange("calendar")}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Novo Post
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFilters && (
|
||||
<div className="grid gap-3 rounded-lg border border-border/60 bg-card p-4 sm:grid-cols-[180px_1fr]">
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">Status</label>
|
||||
<select
|
||||
className="h-9 rounded-lg border border-input bg-background px-3 text-sm"
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value as "all" | CalendarItem["status"])}
|
||||
>
|
||||
<option value="all">Todos</option>
|
||||
{Object.entries(statusLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm font-medium">Buscar</label>
|
||||
<input
|
||||
className="h-9 rounded-lg border border-input bg-background px-3 text-sm"
|
||||
placeholder="Título ou cliente"
|
||||
value={searchFilter}
|
||||
onChange={(event) => setSearchFilter(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<SummaryCard title="Para Hoje" value={todayItems.length} detail={`${pendingItems.length} pendentes na semana`} />
|
||||
<SummaryCard title="Em Aprovação" value={reviewItems.length} detail="Aguardando revisão ou cliente" />
|
||||
<SummaryCard title="Aprovados" value={approvedItems.length} detail="Prontos ou publicados" emphasize />
|
||||
<Card className="shadow-sm bg-muted/40 border-dashed border-2">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">Avisos</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className="text-sm font-medium text-foreground flex items-center gap-1.5">
|
||||
<AlertCircle className="h-4 w-4 text-orange-500" />
|
||||
{warningDate ? warningDate.name : "Sem datas críticas"}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{warningDate ? shortDateFormatter.format(parseDateValue(warningDate.date)) : "Nenhum feriado ou data na semana"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<Tabs defaultValue="pending" className="w-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<TabsList className="bg-transparent border-b border-border/40 w-full justify-start h-auto p-0 rounded-none">
|
||||
<TabsTrigger value="pending" className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2 font-medium">
|
||||
Ações Pendentes
|
||||
<Badge variant="secondary" className="ml-2 h-5 px-1.5 rounded-sm">{pendingItems.length}</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="approved" className="rounded-none border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:bg-transparent px-4 py-2 font-medium">
|
||||
Aprovados
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="pending" className="pt-4 space-y-3 m-0">
|
||||
<PostList items={pendingItems} isLoading={isLoading} emptyText="Nenhuma ação pendente nesta semana." onPostSelect={onPostSelect} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="approved" className="pt-4 space-y-3 m-0">
|
||||
<PostList items={approvedItems} isLoading={isLoading} emptyText="Nenhum post aprovado nesta semana." onPostSelect={onPostSelect} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="p-4 pb-0">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<CalendarIcon className="h-4 w-4 text-muted-foreground" />
|
||||
Datas da Semana
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-3 space-y-3">
|
||||
{weekDates.map((date) => (
|
||||
<div key={`${date.date}-${date.name}`} className="flex items-center justify-between text-sm">
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium text-foreground">{date.name}</span>
|
||||
<span className="text-xs text-muted-foreground">{"source" in date ? "Nacional" : "Personalizada"}</span>
|
||||
</div>
|
||||
<Badge variant="outline" className="font-mono bg-muted/40">
|
||||
{shortDateFormatter.format(parseDateValue(date.date))}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
{weekDates.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-3">Nenhuma data comemorativa nesta semana.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Button variant="ghost" className="w-full text-xs h-8 text-primary" onClick={() => onViewChange("calendar")}>
|
||||
Ver calendário completo
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryCard({ title, value, detail, emphasize = false }: { title: string; value: number; detail: string; emphasize?: boolean }) {
|
||||
return (
|
||||
<Card className="shadow-sm">
|
||||
<CardHeader className="p-4 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0">
|
||||
<div className={`text-2xl font-semibold ${emphasize ? "text-primary" : ""}`}>{value}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">{detail}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function PostList({
|
||||
items,
|
||||
isLoading,
|
||||
emptyText,
|
||||
onPostSelect,
|
||||
}: {
|
||||
items: CalendarItem[];
|
||||
isLoading: boolean;
|
||||
emptyText: string;
|
||||
onPostSelect: (postId: string) => void;
|
||||
}) {
|
||||
if (isLoading) {
|
||||
return <p className="text-sm text-muted-foreground py-6">Carregando postagens...</p>;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground py-6">{emptyText}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{items.map((post) => (
|
||||
<button key={post.id} type="button" className="flex w-full flex-col sm:flex-row gap-4 p-4 rounded-lg border border-border/60 bg-card hover:bg-muted/30 transition-colors shadow-sm text-left" onClick={() => onPostSelect(post.id)}>
|
||||
<div className="flex-1 space-y-1 z-10">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Badge variant="outline" className="text-[10px] uppercase font-semibold text-muted-foreground">{post.client_name}</Badge>
|
||||
<Badge variant="secondary" className="text-[10px] bg-orange-500/10 text-orange-700 dark:text-orange-300 hover:bg-orange-500/20">
|
||||
{post.content_type}
|
||||
</Badge>
|
||||
</div>
|
||||
<h3 className="font-medium text-sm text-foreground line-clamp-1">{post.title}</h3>
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground pt-1">
|
||||
<span className="flex items-center gap-1.5"><Clock className="h-3.5 w-3.5" /> {shortDateFormatter.format(parseDateValue(post.scheduled_date))}</span>
|
||||
<span className="flex items-center gap-1.5"><FileImage className="h-3.5 w-3.5" /> Anexos pendentes</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex sm:flex-col items-center sm:items-end justify-between sm:justify-center border-t sm:border-t-0 sm:border-l border-border/40 pt-3 sm:pt-0 sm:pl-4 mt-2 sm:mt-0 gap-2">
|
||||
<span className="text-xs font-medium text-orange-600 dark:text-orange-300 flex items-center gap-1.5">
|
||||
{post.status === "aprovado" || post.status === "publicado" ? <CheckCircle2 className="h-3.5 w-3.5" /> : <AlertCircle className="h-3.5 w-3.5" />}
|
||||
{statusLabels[post.status]}
|
||||
</span>
|
||||
<Avatar className="h-6 w-6 border-border border">
|
||||
<AvatarFallback className="text-[10px] bg-primary/5 text-primary font-medium">{initials(post.client_name)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function startOfWeek(date: Date) {
|
||||
const copy = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
const day = copy.getDay();
|
||||
const mondayOffset = day === 0 ? -6 : 1 - day;
|
||||
return addDays(copy, mondayOffset);
|
||||
}
|
||||
|
||||
function addDays(date: Date, days: number) {
|
||||
const copy = new Date(date);
|
||||
copy.setDate(copy.getDate() + days);
|
||||
return copy;
|
||||
}
|
||||
|
||||
function formatDateValue(date: Date) {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function parseDateValue(value: string) {
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
function yearsInRange(from: Date, to: Date) {
|
||||
const years: number[] = [];
|
||||
for (let year = from.getFullYear(); year <= to.getFullYear(); year += 1) {
|
||||
years.push(year);
|
||||
}
|
||||
return years;
|
||||
}
|
||||
|
||||
function initials(value: string) {
|
||||
return value
|
||||
.split(" ")
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0])
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
}
|
||||
118
frontend/src/views/LoginView.tsx
Normal file
118
frontend/src/views/LoginView.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { ModeToggle } from "@/components/mode-toggle";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { login, type AuthUser } from "@/lib/auth";
|
||||
|
||||
export function LoginView({ onLogin }: { onLogin: (user: AuthUser) => void }) {
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError("");
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await login(email, password);
|
||||
onLogin(response.user);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Nao foi possivel fazer login.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-muted/40 flex items-center justify-center p-4 relative">
|
||||
<div className="absolute top-4 right-4">
|
||||
<ModeToggle />
|
||||
</div>
|
||||
|
||||
<div className="w-full max-w-md space-y-6">
|
||||
<div className="flex justify-center flex-col items-center gap-2 mb-8">
|
||||
<div className="h-12 w-12 bg-primary rounded-xl flex items-center justify-center">
|
||||
<span className="text-primary-foreground font-bold text-2xl tracking-tighter">M</span>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-foreground">Mira</h1>
|
||||
<p className="text-sm text-muted-foreground">Planejamento Editorial & Operações</p>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-sm border-border/50">
|
||||
<CardHeader className="space-y-1 pb-4">
|
||||
<CardTitle className="text-xl">Acessar a Plataforma</CardTitle>
|
||||
<CardDescription>
|
||||
Insira suas credenciais corporativas ou do cliente.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">E-mail corporativo</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="nome@agencia.com.br"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2 relative">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">Senha</Label>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Sua senha de acesso"
|
||||
autoComplete="current-password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
required
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
type="button"
|
||||
className="absolute right-0 top-0 h-9 w-9 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 pt-2">
|
||||
<Checkbox id="remember" />
|
||||
<Label htmlFor="remember" className="text-sm font-normal text-muted-foreground">Lembrar neste dispositivo</Label>
|
||||
</div>
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button className="w-full" type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Entrando..." : "Entrar"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<div className="text-center text-xs text-muted-foreground">
|
||||
Problemas com o acesso? Fale com o gestor de contas.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
287
frontend/src/views/PostDetailView.tsx
Normal file
287
frontend/src/views/PostDetailView.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import { ArrowLeft, Download, FileText, Image as ImageIcon, Upload } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { getStoredToken } from "@/lib/auth";
|
||||
import {
|
||||
attachmentDownloadURL,
|
||||
getCalendarItem,
|
||||
listAttachments,
|
||||
uploadAttachment,
|
||||
type Attachment,
|
||||
type CalendarItem,
|
||||
} from "@/lib/calendar";
|
||||
|
||||
type PostDetailViewProps = {
|
||||
itemId: string;
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
const statusLabels: Record<CalendarItem["status"], string> = {
|
||||
rascunho: "Rascunho",
|
||||
planejado: "Planejado",
|
||||
em_producao: "Em produção",
|
||||
em_revisao: "Em revisão",
|
||||
aprovado: "Aprovado",
|
||||
publicado: "Publicado",
|
||||
cancelado: "Cancelado",
|
||||
};
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "long", year: "numeric" });
|
||||
|
||||
export function PostDetailView({ itemId, onBack }: PostDetailViewProps) {
|
||||
const [item, setItem] = useState<CalendarItem | null>(null);
|
||||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||||
const [previewAttachment, setPreviewAttachment] = useState<Attachment | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [attachmentError, setAttachmentError] = useState("");
|
||||
|
||||
const imageAttachments = useMemo(() => attachments.filter((attachment) => isImageAttachment(attachment)), [attachments]);
|
||||
const fileAttachments = useMemo(() => attachments.filter((attachment) => !isImageAttachment(attachment)), [attachments]);
|
||||
|
||||
useEffect(() => {
|
||||
const token = getStoredToken();
|
||||
if (!token) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
Promise.all([getCalendarItem(token, itemId), listAttachments(token, itemId)])
|
||||
.then(([loadedItem, loadedAttachments]) => {
|
||||
setItem(loadedItem);
|
||||
setAttachments(loadedAttachments);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err instanceof Error ? err.message : "Não foi possível carregar a postagem.");
|
||||
})
|
||||
.finally(() => setIsLoading(false));
|
||||
}, [itemId]);
|
||||
|
||||
async function handleUploadAttachment(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const token = getStoredToken();
|
||||
const fileInput = event.currentTarget.elements.namedItem("attachment") as HTMLInputElement | null;
|
||||
const file = fileInput?.files?.[0];
|
||||
if (!token || !file) return;
|
||||
|
||||
setIsUploading(true);
|
||||
setAttachmentError("");
|
||||
try {
|
||||
const attachment = await uploadAttachment(token, itemId, file);
|
||||
setAttachments((current) => [attachment, ...current]);
|
||||
if (fileInput) fileInput.value = "";
|
||||
} catch (err) {
|
||||
setAttachmentError(err instanceof Error ? err.message : "Não foi possível enviar o anexo.");
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading && !item) {
|
||||
return <p className="text-sm text-muted-foreground">Carregando postagem...</p>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Button variant="outline" size="sm" onClick={onBack}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Voltar
|
||||
</Button>
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div className="space-y-3">
|
||||
<Button variant="outline" size="sm" onClick={onBack}>
|
||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||
Voltar
|
||||
</Button>
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{item.title}</h1>
|
||||
<Badge variant="secondary" className="bg-orange-500/10 text-orange-700 dark:text-orange-300">
|
||||
{statusLabels[item.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{item.client_name} · {dateFormatter.format(parseDateValue(item.scheduled_date))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div className="space-y-6 lg:col-span-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Detalhes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<ReadOnlyField label="Resumo" value={item.description} fallback="Sem resumo." />
|
||||
<ReadOnlyField label="Texto da postagem" value={item.copy_text} fallback="Sem texto cadastrado." multiline />
|
||||
<ReadOnlyField label="Notas para o cliente" value={item.client_notes} fallback="Sem notas para o cliente." multiline />
|
||||
<ReadOnlyField label="Notas internas" value={item.internal_notes} fallback="Sem notas internas." multiline />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Anexos</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<form className="grid gap-3 sm:grid-cols-[1fr_auto]" onSubmit={handleUploadAttachment}>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="post-detail-attachment">Enviar anexo</Label>
|
||||
<Input id="post-detail-attachment" name="attachment" type="file" accept="image/jpeg,image/png,image/webp,application/pdf" />
|
||||
</div>
|
||||
<Button className="sm:self-end" type="submit" disabled={isUploading}>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
{isUploading ? "Enviando..." : "Enviar"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{attachmentError && (
|
||||
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{attachmentError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attachments.length === 0 && (
|
||||
<p className="text-sm text-muted-foreground py-4">Nenhum anexo enviado.</p>
|
||||
)}
|
||||
|
||||
{imageAttachments.length > 0 && (
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{imageAttachments.map((attachment) => (
|
||||
<button
|
||||
key={attachment.id}
|
||||
type="button"
|
||||
className="group overflow-hidden rounded-lg border border-border/60 bg-muted/20 text-left"
|
||||
onClick={() => setPreviewAttachment(attachment)}
|
||||
>
|
||||
<img
|
||||
src={attachmentDownloadURL(attachment.id)}
|
||||
alt={attachment.original_filename}
|
||||
className="aspect-square w-full object-cover transition group-hover:scale-[1.02]"
|
||||
/>
|
||||
<div className="flex items-center gap-2 px-2 py-2 text-xs">
|
||||
<ImageIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{attachment.original_filename}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileAttachments.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{fileAttachments.map((attachment) => (
|
||||
<a
|
||||
key={attachment.id}
|
||||
href={attachmentDownloadURL(attachment.id)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-border/60 px-3 py-2 text-sm hover:bg-muted/30"
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{attachment.original_filename}</span>
|
||||
</span>
|
||||
<span className="flex shrink-0 items-center gap-2 text-xs text-muted-foreground">
|
||||
{formatBytes(attachment.size_bytes)}
|
||||
<Download className="h-4 w-4" />
|
||||
</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="h-fit">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Informações</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm">
|
||||
<InfoRow label="Cliente" value={item.client_name} />
|
||||
<InfoRow label="Status" value={statusLabels[item.status]} />
|
||||
<InfoRow label="Tipo" value={item.content_type} />
|
||||
<InfoRow label="Data" value={dateFormatter.format(parseDateValue(item.scheduled_date))} />
|
||||
<InfoRow label="Anexos" value={String(attachments.length)} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Dialog open={previewAttachment !== null} onOpenChange={(open) => !open && setPreviewAttachment(null)}>
|
||||
<DialogContent className="sm:max-w-[860px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{previewAttachment?.original_filename}</DialogTitle>
|
||||
<DialogDescription>Visualização do anexo.</DialogDescription>
|
||||
</DialogHeader>
|
||||
{previewAttachment && (
|
||||
<div className="overflow-hidden rounded-lg border border-border/60 bg-black/5">
|
||||
<img
|
||||
src={attachmentDownloadURL(previewAttachment.id)}
|
||||
alt={previewAttachment.original_filename}
|
||||
className="max-h-[70vh] w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadOnlyField({ label, value, fallback, multiline = false }: { label: string; value: string; fallback: string; multiline?: boolean }) {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Label>{label}</Label>
|
||||
{multiline ? (
|
||||
<Textarea value={value || fallback} readOnly className="min-h-[110px]" />
|
||||
) : (
|
||||
<Input value={value || fallback} readOnly />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border/40 pb-2 last:border-b-0 last:pb-0">
|
||||
<span className="text-muted-foreground">{label}</span>
|
||||
<span className="font-medium text-right">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isImageAttachment(attachment: Attachment) {
|
||||
return attachment.mime_type.startsWith("image/");
|
||||
}
|
||||
|
||||
function parseDateValue(value: string) {
|
||||
const [year, month, day] = value.split("-").map(Number);
|
||||
return new Date(year, month - 1, day);
|
||||
}
|
||||
|
||||
function formatBytes(value: number) {
|
||||
if (value < 1024) return `${value} B`;
|
||||
if (value < 1024 * 1024) return `${Math.round(value / 1024)} KB`;
|
||||
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
226
frontend/src/views/UsersView.tsx
Normal file
226
frontend/src/views/UsersView.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useEffect, useState, type FormEvent, type ReactNode } from "react";
|
||||
import { Copy, Plus, Users } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { getStoredToken, type AuthUser } from "@/lib/auth";
|
||||
import { createInvitation, listInvitations, listUsers, type Invitation } from "@/lib/users";
|
||||
import type { Client } from "@/lib/clients";
|
||||
|
||||
type UsersViewProps = {
|
||||
clients: Client[];
|
||||
};
|
||||
|
||||
const roleLabels: Record<AuthUser["role"] | Invitation["role"], string> = {
|
||||
super_admin: "Super-admin",
|
||||
agency_user: "Agência",
|
||||
client_viewer: "Cliente",
|
||||
};
|
||||
|
||||
export function UsersView({ clients }: UsersViewProps) {
|
||||
const [users, setUsers] = useState<AuthUser[]>([]);
|
||||
const [invitations, setInvitations] = useState<Invitation[]>([]);
|
||||
const [email, setEmail] = useState("");
|
||||
const [role, setRole] = useState<Invitation["role"]>("agency_user");
|
||||
const [clientId, setClientId] = useState("");
|
||||
const [createdURL, setCreatedURL] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, []);
|
||||
|
||||
async function refresh() {
|
||||
const token = getStoredToken();
|
||||
if (!token) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const [loadedUsers, loadedInvitations] = await Promise.all([listUsers(token), listInvitations(token)]);
|
||||
setUsers(loadedUsers);
|
||||
setInvitations(loadedInvitations);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Não foi possível carregar acessos.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInvite(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const token = getStoredToken();
|
||||
if (!token) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError("");
|
||||
setCreatedURL("");
|
||||
try {
|
||||
const invitation = await createInvitation(token, {
|
||||
email,
|
||||
role,
|
||||
client_id: role === "client_viewer" ? clientId : undefined,
|
||||
});
|
||||
setInvitations((current) => [invitation, ...current]);
|
||||
setEmail("");
|
||||
setClientId("");
|
||||
setCreatedURL(invitation.invitation_url ?? "");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Não foi possível criar o convite.");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyInvitationURL() {
|
||||
if (!createdURL) return;
|
||||
await navigator.clipboard.writeText(createdURL);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Gestão de Acessos</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Gerencie usuários da agência e clientes.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Convidar usuário</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form className="grid gap-3 md:grid-cols-[1fr_180px_220px_auto]" onSubmit={handleInvite}>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="invite-email">E-mail</Label>
|
||||
<Input
|
||||
id="invite-email"
|
||||
type="email"
|
||||
placeholder="nome@empresa.com.br"
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="invite-role">Perfil</Label>
|
||||
<select
|
||||
id="invite-role"
|
||||
className="h-9 rounded-lg border border-input bg-background px-3 text-sm"
|
||||
value={role}
|
||||
onChange={(event) => setRole(event.target.value as Invitation["role"])}
|
||||
>
|
||||
<option value="agency_user">Agência</option>
|
||||
<option value="client_viewer">Cliente</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="invite-client">Cliente</Label>
|
||||
<select
|
||||
id="invite-client"
|
||||
className="h-9 rounded-lg border border-input bg-background px-3 text-sm disabled:opacity-60"
|
||||
value={clientId}
|
||||
onChange={(event) => setClientId(event.target.value)}
|
||||
disabled={role !== "client_viewer"}
|
||||
required={role === "client_viewer"}
|
||||
>
|
||||
<option value="">Selecione</option>
|
||||
{clients.map((client) => (
|
||||
<option key={client.id} value={client.id}>{client.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<Button className="md:self-end" type="submit" disabled={isSubmitting || (role === "client_viewer" && !clientId)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{isSubmitting ? "Criando..." : "Convidar"}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{createdURL && (
|
||||
<div className="mt-4 rounded-lg border border-border bg-muted/30 p-3">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2">Link do convite</div>
|
||||
<div className="flex flex-col sm:flex-row gap-2">
|
||||
<Input value={createdURL} readOnly />
|
||||
<Button type="button" variant="outline" onClick={copyInvitationURL}>
|
||||
<Copy className="mr-2 h-4 w-4" />
|
||||
Copiar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<AccessList title="Usuários ativos" emptyText="Nenhum usuário carregado." isLoading={isLoading}>
|
||||
{users.map((user) => (
|
||||
<div key={user.id} className="flex items-center justify-between gap-4 rounded-lg border border-border/60 bg-card p-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-medium truncate">{user.name}</h3>
|
||||
<p className="text-sm text-muted-foreground truncate">{user.email}</p>
|
||||
</div>
|
||||
<Badge variant="secondary">{roleLabels[user.role]}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</AccessList>
|
||||
|
||||
<AccessList title="Convites" emptyText="Nenhum convite criado." isLoading={isLoading}>
|
||||
{invitations.map((invitation) => (
|
||||
<div key={invitation.id} className="rounded-lg border border-border/60 bg-card p-4 space-y-2">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-medium truncate">{invitation.email}</h3>
|
||||
<p className="text-sm text-muted-foreground truncate">
|
||||
{invitation.client_name ? `Cliente: ${invitation.client_name}` : "Acesso da agência"}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={invitation.accepted_at ? "secondary" : "outline"}>
|
||||
{invitation.accepted_at ? "Aceito" : roleLabels[invitation.role]}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Expira em {new Intl.DateTimeFormat("pt-BR", { dateStyle: "medium" }).format(new Date(invitation.expires_at))}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</AccessList>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AccessList({ title, emptyText, isLoading, children }: { title: string; emptyText: string; isLoading: boolean; children: ReactNode }) {
|
||||
const hasChildren = Array.isArray(children) ? children.length > 0 : Boolean(children);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-base font-semibold">{title}</h2>
|
||||
{isLoading && <p className="text-sm text-muted-foreground py-4">Carregando...</p>}
|
||||
{!isLoading && !hasChildren && (
|
||||
<div className="flex items-center justify-center h-[220px] border border-dashed border-border rounded-xl">
|
||||
<div className="text-center space-y-3">
|
||||
<div className="bg-muted w-12 h-12 rounded-full flex items-center justify-center mx-auto">
|
||||
<Users className="h-6 w-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground w-[260px]">{emptyText}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && hasChildren && <div className="grid gap-3">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
26
frontend/tsconfig.json
Normal file
26
frontend/tsconfig.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
22
frontend/vite.config.ts
Normal file
22
frontend/vite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import {defineConfig} from 'vite';
|
||||
|
||||
export default defineConfig(() => {
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
||||
hmr: process.env.DISABLE_HMR !== 'true',
|
||||
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
|
||||
watch: process.env.DISABLE_HMR === 'true' ? null : {},
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user