🚀 Initial commit: Versão atual do TrackSteel APP

This commit is contained in:
2026-03-18 21:17:53 +00:00
commit bde410c9ad
633 changed files with 108150 additions and 0 deletions

30
.dockerignore Normal file
View File

@@ -0,0 +1,30 @@
# Dependências locais (serão reinstaladas no container)
node_modules
# Build local
dist
# Controle de versão
.git
.gitignore
# IDEs e ferramentas
.vscode
.idea
.trae
.vercel
# Variáveis de ambiente locais (injetadas pelo Coolify)
.env
.env.local
.env.*.local
# Documentação e scripts desnecessários no container
README.md
scripts/
*.md
# Arquivos temporários
*.log
*.tmp
*.temp

11
.env.example Normal file
View File

@@ -0,0 +1,11 @@
# Variáveis de Ambiente — TrackSteel App
# Copie este arquivo para .env e preencha com seus valores do Supabase
# ID do projeto Supabase (encontrado em Project Settings > API)
VITE_SUPABASE_PROJECT_ID=seu_project_id_aqui
# Chave anônima do Supabase (encontrada em Project Settings > API > Project API keys)
VITE_SUPABASE_PUBLISHABLE_KEY=sua_anon_key_aqui
# URL do projeto Supabase
VITE_SUPABASE_URL=https://seu_project_id_aqui.supabase.co

1
.git_backup/HEAD Normal file
View File

@@ -0,0 +1 @@
ref: refs/heads/master

12
.git_backup/config Normal file
View File

@@ -0,0 +1,12 @@
[core]
repositoryformatversion = 0
filemode = false
bare = false
logallrefupdates = true
symlinks = false
[remote "origin"]
url = https://admtracksteel:7383a8fc883c44a7f0e9f06cc26233d47f903565@git.reifonas.cloud/admtracksteel/tracksteelapp.git
fetch = +refs/heads/*:refs/remotes/origin/*
[user]
email = m.reifonas@gmail.com
name = Marcos Reifonas

1
.git_backup/description Normal file
View File

@@ -0,0 +1 @@
Unnamed repository; edit this file 'description' to name the repository.

View File

@@ -0,0 +1,15 @@
#!/bin/sh
#
# An example hook script to check the commit log message taken by
# applypatch from an e-mail message.
#
# The hook should exit with non-zero status after issuing an
# appropriate message if it wants to stop the commit. The hook is
# allowed to edit the commit message file.
#
# To enable this hook, rename this file to "applypatch-msg".
. git-sh-setup
commitmsg="$(git rev-parse --git-path hooks/commit-msg)"
test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"}
:

View File

@@ -0,0 +1,24 @@
#!/bin/sh
#
# An example hook script to check the commit log message.
# Called by "git commit" with one argument, the name of the file
# that has the commit message. The hook should exit with non-zero
# status after issuing an appropriate message if it wants to stop the
# commit. The hook is allowed to edit the commit message file.
#
# To enable this hook, rename this file to "commit-msg".
# Uncomment the below to add a Signed-off-by line to the message.
# Doing this in a hook is a bad idea in general, but the prepare-commit-msg
# hook is more suited to it.
#
# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1"
# This example catches duplicate Signed-off-by lines.
test "" = "$(grep '^Signed-off-by: ' "$1" |
sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || {
echo >&2 Duplicate Signed-off-by lines.
exit 1
}

View File

@@ -0,0 +1,174 @@
#!/usr/bin/perl
use strict;
use warnings;
use IPC::Open2;
# An example hook script to integrate Watchman
# (https://facebook.github.io/watchman/) with git to speed up detecting
# new and modified files.
#
# The hook is passed a version (currently 2) and last update token
# formatted as a string and outputs to stdout a new update token and
# all files that have been modified since the update token. Paths must
# be relative to the root of the working tree and separated by a single NUL.
#
# To enable this hook, rename this file to "query-watchman" and set
# 'git config core.fsmonitor .git/hooks/query-watchman'
#
my ($version, $last_update_token) = @ARGV;
# Uncomment for debugging
# print STDERR "$0 $version $last_update_token\n";
# Check the hook interface version
if ($version ne 2) {
die "Unsupported query-fsmonitor hook version '$version'.\n" .
"Falling back to scanning...\n";
}
my $git_work_tree = get_working_dir();
my $retry = 1;
my $json_pkg;
eval {
require JSON::XS;
$json_pkg = "JSON::XS";
1;
} or do {
require JSON::PP;
$json_pkg = "JSON::PP";
};
launch_watchman();
sub launch_watchman {
my $o = watchman_query();
if (is_work_tree_watched($o)) {
output_result($o->{clock}, @{$o->{files}});
}
}
sub output_result {
my ($clockid, @files) = @_;
# Uncomment for debugging watchman output
# open (my $fh, ">", ".git/watchman-output.out");
# binmode $fh, ":utf8";
# print $fh "$clockid\n@files\n";
# close $fh;
binmode STDOUT, ":utf8";
print $clockid;
print "\0";
local $, = "\0";
print @files;
}
sub watchman_clock {
my $response = qx/watchman clock "$git_work_tree"/;
die "Failed to get clock id on '$git_work_tree'.\n" .
"Falling back to scanning...\n" if $? != 0;
return $json_pkg->new->utf8->decode($response);
}
sub watchman_query {
my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'watchman -j --no-pretty')
or die "open2() failed: $!\n" .
"Falling back to scanning...\n";
# In the query expression below we're asking for names of files that
# changed since $last_update_token but not from the .git folder.
#
# To accomplish this, we're using the "since" generator to use the
# recency index to select candidate nodes and "fields" to limit the
# output to file names only. Then we're using the "expression" term to
# further constrain the results.
my $last_update_line = "";
if (substr($last_update_token, 0, 1) eq "c") {
$last_update_token = "\"$last_update_token\"";
$last_update_line = qq[\n"since": $last_update_token,];
}
my $query = <<" END";
["query", "$git_work_tree", {$last_update_line
"fields": ["name"],
"expression": ["not", ["dirname", ".git"]]
}]
END
# Uncomment for debugging the watchman query
# open (my $fh, ">", ".git/watchman-query.json");
# print $fh $query;
# close $fh;
print CHLD_IN $query;
close CHLD_IN;
my $response = do {local $/; <CHLD_OUT>};
# Uncomment for debugging the watch response
# open ($fh, ">", ".git/watchman-response.json");
# print $fh $response;
# close $fh;
die "Watchman: command returned no output.\n" .
"Falling back to scanning...\n" if $response eq "";
die "Watchman: command returned invalid output: $response\n" .
"Falling back to scanning...\n" unless $response =~ /^\{/;
return $json_pkg->new->utf8->decode($response);
}
sub is_work_tree_watched {
my ($output) = @_;
my $error = $output->{error};
if ($retry > 0 and $error and $error =~ m/unable to resolve root .* directory (.*) is not watched/) {
$retry--;
my $response = qx/watchman watch "$git_work_tree"/;
die "Failed to make watchman watch '$git_work_tree'.\n" .
"Falling back to scanning...\n" if $? != 0;
$output = $json_pkg->new->utf8->decode($response);
$error = $output->{error};
die "Watchman: $error.\n" .
"Falling back to scanning...\n" if $error;
# Uncomment for debugging watchman output
# open (my $fh, ">", ".git/watchman-output.out");
# close $fh;
# Watchman will always return all files on the first query so
# return the fast "everything is dirty" flag to git and do the
# Watchman query just to get it over with now so we won't pay
# the cost in git to look up each individual file.
my $o = watchman_clock();
$error = $output->{error};
die "Watchman: $error.\n" .
"Falling back to scanning...\n" if $error;
output_result($o->{clock}, ("/"));
$last_update_token = $o->{clock};
eval { launch_watchman() };
return 0;
}
die "Watchman: $error.\n" .
"Falling back to scanning...\n" if $error;
return 1;
}
sub get_working_dir {
my $working_dir;
if ($^O =~ 'msys' || $^O =~ 'cygwin') {
$working_dir = Win32::GetCwd();
$working_dir =~ tr/\\/\//;
} else {
require Cwd;
$working_dir = Cwd::cwd();
}
return $working_dir;
}

View File

@@ -0,0 +1,8 @@
#!/bin/sh
#
# An example hook script to prepare a packed repository for use over
# dumb transports.
#
# To enable this hook, rename this file to "post-update".
exec git update-server-info

View File

@@ -0,0 +1,14 @@
#!/bin/sh
#
# An example hook script to verify what is about to be committed
# by applypatch from an e-mail message.
#
# The hook should exit with non-zero status after issuing an
# appropriate message if it wants to stop the commit.
#
# To enable this hook, rename this file to "pre-applypatch".
. git-sh-setup
precommit="$(git rev-parse --git-path hooks/pre-commit)"
test -x "$precommit" && exec "$precommit" ${1+"$@"}
:

View File

@@ -0,0 +1,49 @@
#!/bin/sh
#
# An example hook script to verify what is about to be committed.
# Called by "git commit" with no arguments. The hook should
# exit with non-zero status after issuing an appropriate message if
# it wants to stop the commit.
#
# To enable this hook, rename this file to "pre-commit".
if git rev-parse --verify HEAD >/dev/null 2>&1
then
against=HEAD
else
# Initial commit: diff against an empty tree object
against=$(git hash-object -t tree /dev/null)
fi
# If you want to allow non-ASCII filenames set this variable to true.
allownonascii=$(git config --type=bool hooks.allownonascii)
# Redirect output to stderr.
exec 1>&2
# Cross platform projects tend to avoid non-ASCII filenames; prevent
# them from being added to the repository. We exploit the fact that the
# printable range starts at the space character and ends with tilde.
if [ "$allownonascii" != "true" ] &&
# Note that the use of brackets around a tr range is ok here, (it's
# even required, for portability to Solaris 10's /usr/bin/tr), since
# the square bracket bytes happen to fall in the designated range.
test $(git diff --cached --name-only --diff-filter=A -z $against |
LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0
then
cat <<\EOF
Error: Attempt to add a non-ASCII file name.
This can cause problems if you want to work with people on other platforms.
To be portable it is advisable to rename the file.
If you know what you are doing you can disable this check using:
git config hooks.allownonascii true
EOF
exit 1
fi
# If there are whitespace errors, print the offending file names and fail.
exec git diff-index --check --cached $against --

View File

@@ -0,0 +1,13 @@
#!/bin/sh
#
# An example hook script to verify what is about to be committed.
# Called by "git merge" with no arguments. The hook should
# exit with non-zero status after issuing an appropriate message to
# stderr if it wants to stop the merge commit.
#
# To enable this hook, rename this file to "pre-merge-commit".
. git-sh-setup
test -x "$GIT_DIR/hooks/pre-commit" &&
exec "$GIT_DIR/hooks/pre-commit"
:

View File

@@ -0,0 +1,53 @@
#!/bin/sh
# An example hook script to verify what is about to be pushed. Called by "git
# push" after it has checked the remote status, but before anything has been
# pushed. If this script exits with a non-zero status nothing will be pushed.
#
# This hook is called with the following parameters:
#
# $1 -- Name of the remote to which the push is being done
# $2 -- URL to which the push is being done
#
# If pushing without using a named remote those arguments will be equal.
#
# Information about the commits which are being pushed is supplied as lines to
# the standard input in the form:
#
# <local ref> <local oid> <remote ref> <remote oid>
#
# This sample shows how to prevent push of commits where the log message starts
# with "WIP" (work in progress).
remote="$1"
url="$2"
zero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0')
while read local_ref local_oid remote_ref remote_oid
do
if test "$local_oid" = "$zero"
then
# Handle delete
:
else
if test "$remote_oid" = "$zero"
then
# New branch, examine all commits
range="$local_oid"
else
# Update to existing branch, examine new commits
range="$remote_oid..$local_oid"
fi
# Check for WIP commit
commit=$(git rev-list -n 1 --grep '^WIP' "$range")
if test -n "$commit"
then
echo >&2 "Found WIP commit in $local_ref, not pushing"
exit 1
fi
fi
done
exit 0

View File

@@ -0,0 +1,169 @@
#!/bin/sh
#
# Copyright (c) 2006, 2008 Junio C Hamano
#
# The "pre-rebase" hook is run just before "git rebase" starts doing
# its job, and can prevent the command from running by exiting with
# non-zero status.
#
# The hook is called with the following parameters:
#
# $1 -- the upstream the series was forked from.
# $2 -- the branch being rebased (or empty when rebasing the current branch).
#
# This sample shows how to prevent topic branches that are already
# merged to 'next' branch from getting rebased, because allowing it
# would result in rebasing already published history.
publish=next
basebranch="$1"
if test "$#" = 2
then
topic="refs/heads/$2"
else
topic=`git symbolic-ref HEAD` ||
exit 0 ;# we do not interrupt rebasing detached HEAD
fi
case "$topic" in
refs/heads/??/*)
;;
*)
exit 0 ;# we do not interrupt others.
;;
esac
# Now we are dealing with a topic branch being rebased
# on top of master. Is it OK to rebase it?
# Does the topic really exist?
git show-ref -q "$topic" || {
echo >&2 "No such branch $topic"
exit 1
}
# Is topic fully merged to master?
not_in_master=`git rev-list --pretty=oneline ^master "$topic"`
if test -z "$not_in_master"
then
echo >&2 "$topic is fully merged to master; better remove it."
exit 1 ;# we could allow it, but there is no point.
fi
# Is topic ever merged to next? If so you should not be rebasing it.
only_next_1=`git rev-list ^master "^$topic" ${publish} | sort`
only_next_2=`git rev-list ^master ${publish} | sort`
if test "$only_next_1" = "$only_next_2"
then
not_in_topic=`git rev-list "^$topic" master`
if test -z "$not_in_topic"
then
echo >&2 "$topic is already up to date with master"
exit 1 ;# we could allow it, but there is no point.
else
exit 0
fi
else
not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"`
/usr/bin/perl -e '
my $topic = $ARGV[0];
my $msg = "* $topic has commits already merged to public branch:\n";
my (%not_in_next) = map {
/^([0-9a-f]+) /;
($1 => 1);
} split(/\n/, $ARGV[1]);
for my $elem (map {
/^([0-9a-f]+) (.*)$/;
[$1 => $2];
} split(/\n/, $ARGV[2])) {
if (!exists $not_in_next{$elem->[0]}) {
if ($msg) {
print STDERR $msg;
undef $msg;
}
print STDERR " $elem->[1]\n";
}
}
' "$topic" "$not_in_next" "$not_in_master"
exit 1
fi
<<\DOC_END
This sample hook safeguards topic branches that have been
published from being rewound.
The workflow assumed here is:
* Once a topic branch forks from "master", "master" is never
merged into it again (either directly or indirectly).
* Once a topic branch is fully cooked and merged into "master",
it is deleted. If you need to build on top of it to correct
earlier mistakes, a new topic branch is created by forking at
the tip of the "master". This is not strictly necessary, but
it makes it easier to keep your history simple.
* Whenever you need to test or publish your changes to topic
branches, merge them into "next" branch.
The script, being an example, hardcodes the publish branch name
to be "next", but it is trivial to make it configurable via
$GIT_DIR/config mechanism.
With this workflow, you would want to know:
(1) ... if a topic branch has ever been merged to "next". Young
topic branches can have stupid mistakes you would rather
clean up before publishing, and things that have not been
merged into other branches can be easily rebased without
affecting other people. But once it is published, you would
not want to rewind it.
(2) ... if a topic branch has been fully merged to "master".
Then you can delete it. More importantly, you should not
build on top of it -- other people may already want to
change things related to the topic as patches against your
"master", so if you need further changes, it is better to
fork the topic (perhaps with the same name) afresh from the
tip of "master".
Let's look at this example:
o---o---o---o---o---o---o---o---o---o "next"
/ / / /
/ a---a---b A / /
/ / / /
/ / c---c---c---c B /
/ / / \ /
/ / / b---b C \ /
/ / / / \ /
---o---o---o---o---o---o---o---o---o---o---o "master"
A, B and C are topic branches.
* A has one fix since it was merged up to "next".
* B has finished. It has been fully merged up to "master" and "next",
and is ready to be deleted.
* C has not merged to "next" at all.
We would want to allow C to be rebased, refuse A, and encourage
B to be deleted.
To compute (1):
git rev-list ^master ^topic next
git rev-list ^master next
if these match, topic has not merged in next at all.
To compute (2):
git rev-list master..topic
if this is empty, it is fully merged to "master".
DOC_END

View File

@@ -0,0 +1,24 @@
#!/bin/sh
#
# An example hook script to make use of push options.
# The example simply echoes all push options that start with 'echoback='
# and rejects all pushes when the "reject" push option is used.
#
# To enable this hook, rename this file to "pre-receive".
if test -n "$GIT_PUSH_OPTION_COUNT"
then
i=0
while test "$i" -lt "$GIT_PUSH_OPTION_COUNT"
do
eval "value=\$GIT_PUSH_OPTION_$i"
case "$value" in
echoback=*)
echo "echo from the pre-receive-hook: ${value#*=}" >&2
;;
reject)
exit 1
esac
i=$((i + 1))
done
fi

View File

@@ -0,0 +1,42 @@
#!/bin/sh
#
# An example hook script to prepare the commit log message.
# Called by "git commit" with the name of the file that has the
# commit message, followed by the description of the commit
# message's source. The hook's purpose is to edit the commit
# message file. If the hook fails with a non-zero status,
# the commit is aborted.
#
# To enable this hook, rename this file to "prepare-commit-msg".
# This hook includes three examples. The first one removes the
# "# Please enter the commit message..." help message.
#
# The second includes the output of "git diff --name-status -r"
# into the message, just before the "git status" output. It is
# commented because it doesn't cope with --amend or with squashed
# commits.
#
# The third example adds a Signed-off-by line to the message, that can
# still be edited. This is rarely a good idea.
COMMIT_MSG_FILE=$1
COMMIT_SOURCE=$2
SHA1=$3
/usr/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' "$COMMIT_MSG_FILE"
# case "$COMMIT_SOURCE,$SHA1" in
# ,|template,)
# /usr/bin/perl -i.bak -pe '
# print "\n" . `git diff --cached --name-status -r`
# if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" ;;
# *) ;;
# esac
# SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
# git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE"
# if test -z "$COMMIT_SOURCE"
# then
# /usr/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE"
# fi

View File

@@ -0,0 +1,78 @@
#!/bin/sh
# An example hook script to update a checked-out tree on a git push.
#
# This hook is invoked by git-receive-pack(1) when it reacts to git
# push and updates reference(s) in its repository, and when the push
# tries to update the branch that is currently checked out and the
# receive.denyCurrentBranch configuration variable is set to
# updateInstead.
#
# By default, such a push is refused if the working tree and the index
# of the remote repository has any difference from the currently
# checked out commit; when both the working tree and the index match
# the current commit, they are updated to match the newly pushed tip
# of the branch. This hook is to be used to override the default
# behaviour; however the code below reimplements the default behaviour
# as a starting point for convenient modification.
#
# The hook receives the commit with which the tip of the current
# branch is going to be updated:
commit=$1
# It can exit with a non-zero status to refuse the push (when it does
# so, it must not modify the index or the working tree).
die () {
echo >&2 "$*"
exit 1
}
# Or it can make any necessary changes to the working tree and to the
# index to bring them to the desired state when the tip of the current
# branch is updated to the new commit, and exit with a zero status.
#
# For example, the hook can simply run git read-tree -u -m HEAD "$1"
# in order to emulate git fetch that is run in the reverse direction
# with git push, as the two-tree form of git read-tree -u -m is
# essentially the same as git switch or git checkout that switches
# branches while keeping the local changes in the working tree that do
# not interfere with the difference between the branches.
# The below is a more-or-less exact translation to shell of the C code
# for the default behaviour for git's push-to-checkout hook defined in
# the push_to_deploy() function in builtin/receive-pack.c.
#
# Note that the hook will be executed from the repository directory,
# not from the working tree, so if you want to perform operations on
# the working tree, you will have to adapt your code accordingly, e.g.
# by adding "cd .." or using relative paths.
if ! git update-index -q --ignore-submodules --refresh
then
die "Up-to-date check failed"
fi
if ! git diff-files --quiet --ignore-submodules --
then
die "Working directory has unstaged changes"
fi
# This is a rough translation of:
#
# head_has_history() ? "HEAD" : EMPTY_TREE_SHA1_HEX
if git cat-file -e HEAD 2>/dev/null
then
head=HEAD
else
head=$(git hash-object -t tree --stdin </dev/null)
fi
if ! git diff-index --quiet --cached --ignore-submodules $head --
then
die "Working directory has staged changes"
fi
if ! git read-tree -u -m "$commit"
then
die "Could not update working tree to new HEAD"
fi

View File

@@ -0,0 +1,77 @@
#!/bin/sh
# An example hook script to validate a patch (and/or patch series) before
# sending it via email.
#
# The hook should exit with non-zero status after issuing an appropriate
# message if it wants to prevent the email(s) from being sent.
#
# To enable this hook, rename this file to "sendemail-validate".
#
# By default, it will only check that the patch(es) can be applied on top of
# the default upstream branch without conflicts in a secondary worktree. After
# validation (successful or not) of the last patch of a series, the worktree
# will be deleted.
#
# The following config variables can be set to change the default remote and
# remote ref that are used to apply the patches against:
#
# sendemail.validateRemote (default: origin)
# sendemail.validateRemoteRef (default: HEAD)
#
# Replace the TODO placeholders with appropriate checks according to your
# needs.
validate_cover_letter () {
file="$1"
# TODO: Replace with appropriate checks (e.g. spell checking).
true
}
validate_patch () {
file="$1"
# Ensure that the patch applies without conflicts.
git am -3 "$file" || return
# TODO: Replace with appropriate checks for this patch
# (e.g. checkpatch.pl).
true
}
validate_series () {
# TODO: Replace with appropriate checks for the whole series
# (e.g. quick build, coding style checks, etc.).
true
}
# main -------------------------------------------------------------------------
if test "$GIT_SENDEMAIL_FILE_COUNTER" = 1
then
remote=$(git config --default origin --get sendemail.validateRemote) &&
ref=$(git config --default HEAD --get sendemail.validateRemoteRef) &&
worktree=$(mktemp --tmpdir -d sendemail-validate.XXXXXXX) &&
git worktree add -fd --checkout "$worktree" "refs/remotes/$remote/$ref" &&
git config --replace-all sendemail.validateWorktree "$worktree"
else
worktree=$(git config --get sendemail.validateWorktree)
fi || {
echo "sendemail-validate: error: failed to prepare worktree" >&2
exit 1
}
unset GIT_DIR GIT_WORK_TREE
cd "$worktree" &&
if grep -q "^diff --git " "$1"
then
validate_patch "$1"
else
validate_cover_letter "$1"
fi &&
if test "$GIT_SENDEMAIL_FILE_COUNTER" = "$GIT_SENDEMAIL_FILE_TOTAL"
then
git config --unset-all sendemail.validateWorktree &&
trap 'git worktree remove -ff "$worktree"' EXIT &&
validate_series
fi

View File

@@ -0,0 +1,128 @@
#!/bin/sh
#
# An example hook script to block unannotated tags from entering.
# Called by "git receive-pack" with arguments: refname sha1-old sha1-new
#
# To enable this hook, rename this file to "update".
#
# Config
# ------
# hooks.allowunannotated
# This boolean sets whether unannotated tags will be allowed into the
# repository. By default they won't be.
# hooks.allowdeletetag
# This boolean sets whether deleting tags will be allowed in the
# repository. By default they won't be.
# hooks.allowmodifytag
# This boolean sets whether a tag may be modified after creation. By default
# it won't be.
# hooks.allowdeletebranch
# This boolean sets whether deleting branches will be allowed in the
# repository. By default they won't be.
# hooks.denycreatebranch
# This boolean sets whether remotely creating branches will be denied
# in the repository. By default this is allowed.
#
# --- Command line
refname="$1"
oldrev="$2"
newrev="$3"
# --- Safety check
if [ -z "$GIT_DIR" ]; then
echo "Don't run this script from the command line." >&2
echo " (if you want, you could supply GIT_DIR then run" >&2
echo " $0 <ref> <oldrev> <newrev>)" >&2
exit 1
fi
if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then
echo "usage: $0 <ref> <oldrev> <newrev>" >&2
exit 1
fi
# --- Config
allowunannotated=$(git config --type=bool hooks.allowunannotated)
allowdeletebranch=$(git config --type=bool hooks.allowdeletebranch)
denycreatebranch=$(git config --type=bool hooks.denycreatebranch)
allowdeletetag=$(git config --type=bool hooks.allowdeletetag)
allowmodifytag=$(git config --type=bool hooks.allowmodifytag)
# check for no description
projectdesc=$(sed -e '1q' "$GIT_DIR/description")
case "$projectdesc" in
"Unnamed repository"* | "")
echo "*** Project description file hasn't been set" >&2
exit 1
;;
esac
# --- Check types
# if $newrev is 0000...0000, it's a commit to delete a ref.
zero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0')
if [ "$newrev" = "$zero" ]; then
newrev_type=delete
else
newrev_type=$(git cat-file -t $newrev)
fi
case "$refname","$newrev_type" in
refs/tags/*,commit)
# un-annotated tag
short_refname=${refname##refs/tags/}
if [ "$allowunannotated" != "true" ]; then
echo "*** The un-annotated tag, $short_refname, is not allowed in this repository" >&2
echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2
exit 1
fi
;;
refs/tags/*,delete)
# delete tag
if [ "$allowdeletetag" != "true" ]; then
echo "*** Deleting a tag is not allowed in this repository" >&2
exit 1
fi
;;
refs/tags/*,tag)
# annotated tag
if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1
then
echo "*** Tag '$refname' already exists." >&2
echo "*** Modifying a tag is not allowed in this repository." >&2
exit 1
fi
;;
refs/heads/*,commit)
# branch
if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then
echo "*** Creating a branch is not allowed in this repository" >&2
exit 1
fi
;;
refs/heads/*,delete)
# delete branch
if [ "$allowdeletebranch" != "true" ]; then
echo "*** Deleting a branch is not allowed in this repository" >&2
exit 1
fi
;;
refs/remotes/*,commit)
# tracking branch
;;
refs/remotes/*,delete)
# delete tracking branch
if [ "$allowdeletebranch" != "true" ]; then
echo "*** Deleting a tracking branch is not allowed in this repository" >&2
exit 1
fi
;;
*)
# Anything else (is there anything else?)
echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2
exit 1
;;
esac
# --- Finished
exit 0

6
.git_backup/info/exclude Normal file
View File

@@ -0,0 +1,6 @@
# git ls-files --others --exclude-from=.git/info/exclude
# Lines that start with '#' are comments.
# For a project mostly in C, the following would be a good set of
# exclude patterns (uncomment them if you want to use them):
# *.[oa]
# *~

View File

@@ -0,0 +1,5 @@
xu<>ANÃ0EYç#utS±@
RF"‰¨I*±²&鈘&¶±<C2B6>Hì8àÜ 7á$8¨@eë?ž÷þÔ<C3BE>¬a±8?™Á5ß½ŽÄ
l¾æ$,Áûó ma‰:•òfKŠȸõãÀG
5Ob¥‰DÓ"4²<43>;©ÉmP
k4ä¹=érzQZ><3E>ýIàÔýÂjt!õ<>Oyc¡ k¹¸7p ažžyë´LXQåa ËW·×I\²t8$û\ÚXÆ7 <0C>áD[<1C>°Ø½ Þã¡Ëÿ':î—Šã֞̉*ÊÒâ*Œ²„Ý$w<><19>¡<EFBFBD>¹áo<C3A1>j•«ý«<C3BD>

View File

@@ -0,0 +1 @@
x=ANÄ0 EYÏ)¬®ii$4³BbÅ©K•Ûhã(IšÓ°àœ`.†“6ì|Ûÿù§¹‡«ëíÙ× 08Q±‡BaÀ=•~pÚ_œGõ@Îk6± ©êª^^-E«Ò.€jÒFûà0è{°èØ’ÃÓÏé—<ÍÀ *^M&ÔÉAEÕëúš9ö9…e¶ÂHeßÔMù,¤»HeXf-J‡M<E28093>uL`ßŲ.•å‰2Bp<53> _eþ1­«È@˜A“—¢ÕøÆÏ6es¥@ŠùÓ¶ºÜI(ÿýoôùÎNEña<C3B1>Î)8!È1ç{Þ2Úœ"ŒóÎá…]´j%Wš0&wïXÍè7/¿1ê<31>ŒOŸx×Þ›ãü…l

View File

@@ -0,0 +1 @@
x]P½NΔ0 fΎ§¨"FΤΣMHΌ{Ί4ΰ&Uμ<55>Cθήύμ&a`³Ώ?²Ημ‡—ησΓοiΜ#…Vg.ƒY<C692>7ΊX»Η7…4†ΌΪΚ<CEAA><CE9A>”“yRρ‚κ'<27>έ<EFBFBD>\ΡBA°Ω!Α΅bΊΚΞeo«ψΣ$<24><><EFBFBD><E280A2>Σ?4‡<>HΥ©€J°β…λ¨€Ζ ηΑ[Ζ\:†Ξ<E280A0>θέ•θ<‚Ϊ{ρl樽<C2A8><EFBFBD>ΫQΤa”$ώ<> I

48
.gitignore vendored Normal file
View File

@@ -0,0 +1,48 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor e IDEs
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Ferramentas Low-Code (Lovable/Trae)
.trae/
# Deploy platforms não utilizadas
.vercel/
.netlify/
# Scripts locais de desenvolvimento
scripts/auto-sync.ps1
scripts/auto-synchome.ps1
# Variáveis de ambiente sensíveis
.env
.env.local
.env.production.local
.env.development.local
# Arquivos de build Docker locais
.dockerignore.local
# Arquivos temporários
*.tmp
*.temp

48
Dockerfile Normal file
View File

@@ -0,0 +1,48 @@
# ============================================================
# TrackSteel App — Dockerfile Multi-Stage
# Stage 1: Build (Node.js) → Stage 2: Serve (Nginx Alpine)
# ============================================================
# --- STAGE 1: BUILD ---
FROM node:20-alpine AS builder
WORKDIR /app
# Copiar apenas package files primeiro (para cache de dependências)
COPY package.json package-lock.json ./
# Instalar dependências (usando legacy-peer-deps para resolver conflitos de versões de peer dependencies)
RUN npm install --legacy-peer-deps
# Copiar código-fonte
COPY . .
# Variáveis de ambiente para o build (injetadas pelo Coolify)
ARG VITE_SUPABASE_URL
ARG VITE_SUPABASE_PUBLISHABLE_KEY
ARG VITE_SUPABASE_PROJECT_ID
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
ENV VITE_SUPABASE_PUBLISHABLE_KEY=$VITE_SUPABASE_PUBLISHABLE_KEY
ENV VITE_SUPABASE_PROJECT_ID=$VITE_SUPABASE_PROJECT_ID
# Build de produção
RUN npm run build
# --- STAGE 2: SERVE ---
FROM nginx:alpine AS production
# Remover config padrão do Nginx
RUN rm /etc/nginx/conf.d/default.conf
# Copiar configuração customizada
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copiar build estático do stage anterior
COPY --from=builder /app/dist /usr/share/nginx/html
# Expor porta 80
EXPOSE 80
# Iniciar Nginx em foreground
CMD ["nginx", "-g", "daemon off;"]

85
README.md Normal file
View File

@@ -0,0 +1,85 @@
# TrackSteel — Plataforma de Gestão Industrial
Sistema de gestão industrial para fabricação de estruturas metálicas. Controle completo de Ordens de Fabricação (OFs), peças, estoque, produção, expedição e muito mais.
## Stack Tecnológica
- **Frontend:** React 18 + TypeScript + Vite
- **UI:** shadcn/ui + TailwindCSS
- **Backend:** Supabase (PostgreSQL + Auth + Storage)
- **Estado:** TanStack React Query v5
- **Roteamento:** React Router DOM v6
- **PDF:** jsPDF + html2canvas
- **Gráficos:** Recharts
- **Drag & Drop:** react-beautiful-dnd
## Módulos do Sistema
- **Dashboard** — Visão geral e KPIs
- **Cadastro de OFs** — Ordens de Fabricação com peças e processos
- **Cadastro de Peças** — Biblioteca de peças e componentes
- **Estoque** — Controle de materiais e movimentações
- **Produção** — Apontamentos, diário e painel industrial
- **Expedição** — Romaneios e entregas
- **Obras** — Gestão de projetos e instalações
- **Tarefas** — Gestão de tarefas e atribuições
- **Biblioteca** — Normas, catálogos e referências técnicas
- **Admin** — Gestão de usuários e permissões
## Configuração Local
### Pré-requisitos
- Node.js >= 20
- npm >= 10
### Instalação
```sh
# 1. Clone o repositório
git clone <URL_DO_REPO>
cd tracksteel-app
# 2. Instale as dependências
npm install
# 3. Configure as variáveis de ambiente
cp .env.example .env
# Edite .env com suas credenciais do Supabase
# 4. Inicie o servidor de desenvolvimento
npm run dev
```
### Variáveis de Ambiente Obrigatórias
```env
VITE_SUPABASE_PROJECT_ID=seu_project_id
VITE_SUPABASE_PUBLISHABLE_KEY=sua_anon_key
VITE_SUPABASE_URL=https://seu_project_id.supabase.co
```
## Deploy (Coolify + VPS)
O projeto utiliza Docker para deploy via Coolify:
```sh
# Build da imagem
docker build -t tracksteel-app .
# Executar localmente
docker run -p 80:80 \
-e VITE_SUPABASE_URL=... \
-e VITE_SUPABASE_PUBLISHABLE_KEY=... \
tracksteel-app
```
## Scripts Disponíveis
```sh
npm run dev # Servidor de desenvolvimento
npm run build # Build de produção
npm run lint # Verificação de código
npm run preview # Preview do build
```
- Último teste de deploy em: Wed Mar 18 17:28:14 UTC 2026

BIN
bun.lockb Normal file

Binary file not shown.

20
components.json Normal file
View File

@@ -0,0 +1,20 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
}
}

29
eslint.config.js Normal file
View File

@@ -0,0 +1,29 @@
import js from "@eslint/js";
import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
export default tseslint.config(
{ ignores: ["dist"] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": [
"warn",
{ allowConstantExport: true },
],
"@typescript-eslint/no-unused-vars": "off",
},
}
);

25
index.html Normal file
View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/png" href="/favicon.png" />
<title>TrackSteel — Gestão Industrial de Estruturas Metálicas</title>
<meta name="description"
content="TrackSteel: plataforma de gestão industrial para fabricação de estruturas metálicas. Controle de OFs, peças, estoque, produção e expedição." />
<meta name="author" content="TrackSteel" />
<meta property="og:title" content="TrackSteel" />
<meta property="og:description" content="Plataforma de gestão industrial para fabricação de estruturas metálicas." />
<meta property="og:type" content="website" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

67
nginx.conf Normal file
View File

@@ -0,0 +1,67 @@
# ============================================================
# TrackSteel App — Nginx Configuration
# SPA routing + performance + segurança
# ============================================================
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# ---- Compressão Gzip ----
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml
font/woff2;
# ---- Headers de Segurança ----
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# ---- Cache de Assets Estáticos ----
# Arquivos com hash no nome (JS, CSS gerados pelo Vite) — cache longo
location ~* \.(?:js|css)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
# Fontes e imagens — cache médio
location ~* \.(?:woff2?|ttf|eot|otf|ico|png|jpg|jpeg|gif|svg|webp)$ {
expires 6M;
add_header Cache-Control "public";
try_files $uri =404;
}
# ---- SPA Routing ----
# Qualquer rota que não corresponda a um arquivo redireciona pro index.html
location / {
try_files $uri $uri/ /index.html;
}
# ---- Bloquear acesso a arquivos sensíveis ----
location ~ /\. {
deny all;
return 404;
}
# ---- Health Check ----
location /health {
access_log off;
return 200 "OK";
add_header Content-Type text/plain;
}
}

7240
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

84
package.json Normal file
View File

@@ -0,0 +1,84 @@
{
"name": "tracksteel-app",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:dev": "vite build --mode development",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^3.10.0",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@supabase/supabase-js": "^2.98.0",
"@tanstack/react-query": "^5.90.21",
"@xyflow/react": "^12.10.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"html2canvas": "^1.4.1",
"jspdf": "^4.2.0",
"jspdf-autotable": "^5.0.7",
"lucide-react": "^0.577.0",
"react": "^18.3.1",
"react-beautiful-dnd": "^13.1.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-dropzone": "^15.0.0",
"react-hook-form": "^7.71.2",
"react-is": "^19.2.4",
"react-router-dom": "^6.26.2",
"recharts": "^3.8.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tailwindcss-animate": "^1.0.7",
"xlsx": "^0.18.5",
"zod": "^3.25.76"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/typography": "^0.5.19",
"@types/node": "^25.3.5",
"@types/react": "^19.2.14",
"@types/react-beautiful-dnd": "^13.1.8",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react-swc": "^4.2.3",
"autoprefixer": "^10.4.27",
"caniuse-lite": "^1.0.30001777",
"eslint": "^10.0.3",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"postcss": "^8.5.8",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^6.4.1"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 KiB

14
public/robots.txt Normal file
View File

@@ -0,0 +1,14 @@
User-agent: Googlebot
Allow: /
User-agent: Bingbot
Allow: /
User-agent: Twitterbot
Allow: /
User-agent: facebookexternalhit
Allow: /
User-agent: *
Allow: /

164
scripts/package-lock.json generated Normal file
View File

@@ -0,0 +1,164 @@
{
"name": "database-scripts",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "database-scripts",
"version": "1.0.0",
"hasInstallScript": true,
"license": "ISC",
"dependencies": {
"@supabase/supabase-js": "^2.39.0"
}
},
"node_modules/@supabase/auth-js": {
"version": "2.71.1",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.71.1.tgz",
"integrity": "sha512-mMIQHBRc+SKpZFRB2qtupuzulaUhFYupNyxqDj5Jp/LyPvcWvjaJzZzObv6URtL/O6lPxkanASnotGtNpS3H2Q==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/functions-js": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.5.tgz",
"integrity": "sha512-v5GSqb9zbosquTo6gBwIiq7W9eQ7rE5QazsK/ezNiQXdCbY+bH8D9qEaBIkhVvX4ZRW5rP03gEfw5yw9tiq4EQ==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/node-fetch": {
"version": "2.6.15",
"resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz",
"integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
}
},
"node_modules/@supabase/postgrest-js": {
"version": "1.21.3",
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.21.3.tgz",
"integrity": "sha512-rg3DmmZQKEVCreXq6Am29hMVe1CzemXyIWVYyyua69y6XubfP+DzGfLxME/1uvdgwqdoaPbtjBDpEBhqxq1ZwA==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/realtime-js": {
"version": "2.15.5",
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.15.5.tgz",
"integrity": "sha512-/Rs5Vqu9jejRD8ZeuaWXebdkH+J7V6VySbCZ/zQM93Ta5y3mAmocjioa/nzlB6qvFmyylUgKVS1KpE212t30OA==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "^2.6.13",
"@types/phoenix": "^1.6.6",
"@types/ws": "^8.18.1",
"ws": "^8.18.2"
}
},
"node_modules/@supabase/storage-js": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.12.0.tgz",
"integrity": "sha512-HdKq8jAARnZ/OokE0wml/KzLwJ1X/iX7GtfLvve1HHxxsB3Y0juk0+3dMKr0mKRpjiGzzgvHhF2hxt9ui17OUQ==",
"license": "MIT",
"dependencies": {
"@supabase/node-fetch": "^2.6.14"
}
},
"node_modules/@supabase/supabase-js": {
"version": "2.57.3",
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.57.3.tgz",
"integrity": "sha512-gROsjAJ9ckeBpsLyMwK9plaZjw1uhlGgKp2EEQRJryPmI0jpKoGc07rvZ7KF8nk7H8UCwvUeYl68Fiw6M13tNg==",
"license": "MIT",
"dependencies": {
"@supabase/auth-js": "2.71.1",
"@supabase/functions-js": "2.4.5",
"@supabase/node-fetch": "2.6.15",
"@supabase/postgrest-js": "1.21.3",
"@supabase/realtime-js": "2.15.5",
"@supabase/storage-js": "^2.10.4"
}
},
"node_modules/@types/node": {
"version": "24.3.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz",
"integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==",
"license": "MIT",
"dependencies": {
"undici-types": "~7.10.0"
}
},
"node_modules/@types/phoenix": {
"version": "1.6.6",
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
"license": "MIT"
},
"node_modules/@types/ws": {
"version": "8.18.1",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/undici-types": {
"version": "7.10.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
"license": "MIT"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/ws": {
"version": "8.18.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

21
scripts/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "database-scripts",
"version": "1.0.0",
"description": "Scripts administrativos para operações no banco de dados",
"main": "index.js",
"scripts": {
"update-b101-fase9": "node update_apontamentos_b101_fase9.js",
"install": "npm install"
},
"dependencies": {
"@supabase/supabase-js": "^2.39.0"
},
"keywords": [
"supabase",
"database",
"scripts",
"admin"
],
"author": "Sistema de Produção",
"license": "ISC"
}

View File

@@ -0,0 +1,209 @@
const { createClient } = require('@supabase/supabase-js');
const readline = require('readline');
// Configuração do Supabase
const supabaseUrl = 'https://lwjppiicofojfcdfjsto.supabase.co';
const supabaseServiceKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imx3anBwaWljb2ZvamZjZGZqc3RvIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc1MDQ2MzA1MywiZXhwIjoyMDY2MDM5MDUzfQ.t9vlXHQH4ou2S-CKSeDYSnAeMDYpmkklqlwyGDvpocI';
// Inicializar cliente Supabase com service role para operações administrativas
const supabase = createClient(supabaseUrl, supabaseServiceKey);
// Interface para entrada do usuário
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
// Função para solicitar confirmação do usuário
function askConfirmation(question) {
return new Promise((resolve) => {
rl.question(question, (answer) => {
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes' || answer.toLowerCase() === 's' || answer.toLowerCase() === 'sim');
});
});
}
// Função principal
async function updateApontamentosB101Fase9() {
console.log('🔧 Script de Atualização de Apontamentos - OF B101 Fase 9');
console.log('=' .repeat(60));
console.log('Objetivo: Alterar data de 08/09/2025 para 10/09/2025');
console.log('Filtro: Apenas peças com marca entre 10 e 70');
console.log('');
try {
// Passo 1: Buscar registros que serão afetados
console.log('📋 Passo 1: Identificando registros a serem atualizados...');
const { data: registrosParaAtualizar, error: selectError } = await supabase
.from('apontamentos_producao')
.select(`
id,
of_number,
data_apontamento,
quantidade_produzida,
observacoes,
pecas!inner(
id,
of_number,
etapa_fase,
marca,
descricao
)
`)
.eq('pecas.of_number', 'B101')
.eq('pecas.etapa_fase', '9')
.gte('pecas.marca', '10')
.lte('pecas.marca', '70')
.eq('data_apontamento', '2025-09-08');
if (selectError) {
console.error('❌ Erro ao buscar registros:', selectError.message);
return;
}
if (!registrosParaAtualizar || registrosParaAtualizar.length === 0) {
console.log(' Nenhum registro encontrado com os critérios especificados.');
console.log(' - OF: B101');
console.log(' - Fase: 9');
console.log(' - Marca: entre 10 e 70');
console.log(' - Data atual: 08/09/2025');
return;
}
// Mostrar registros encontrados
console.log(`✅ Encontrados ${registrosParaAtualizar.length} registro(s) para atualização:`);
console.log('');
registrosParaAtualizar.forEach((registro, index) => {
console.log(`📦 Registro ${index + 1}:`);
console.log(` ID: ${registro.id}`);
console.log(` OF: ${registro.of_number}`);
console.log(` Peça: ${registro.pecas.marca} - ${registro.pecas.descricao}`);
console.log(` Fase: ${registro.pecas.etapa_fase}`);
console.log(` Data atual: ${registro.data_apontamento}`);
console.log(` Quantidade: ${registro.quantidade_produzida}`);
if (registro.observacoes) {
console.log(` Observações: ${registro.observacoes}`);
}
console.log('');
});
// Passo 2: Solicitar confirmação
console.log('⚠️ ATENÇÃO: Esta operação irá alterar a data dos registros acima.');
console.log(' Data atual: 08/09/2025');
console.log(' Nova data: 10/09/2025');
console.log('');
const confirmacao = await askConfirmation('Deseja continuar com a atualização? (s/n): ');
if (!confirmacao) {
console.log('❌ Operação cancelada pelo usuário.');
return;
}
// Passo 3: Executar a atualização
console.log('');
console.log('🔄 Passo 2: Executando atualização...');
// Extrair IDs dos registros para atualização
const idsParaAtualizar = registrosParaAtualizar.map(r => r.id);
const { data: registrosAtualizados, error: updateError } = await supabase
.from('apontamentos_producao')
.update({
data_apontamento: '2025-09-10',
updated_at: new Date().toISOString()
})
.in('id', idsParaAtualizar)
.select(`
id,
of_number,
data_apontamento,
quantidade_produzida,
updated_at,
pecas!inner(
of_number,
etapa_fase,
marca
)
`);
if (updateError) {
console.error('❌ Erro ao atualizar registros:', updateError.message);
return;
}
// Passo 4: Confirmar atualização
console.log(`✅ Atualização concluída com sucesso!`);
console.log(`📊 ${registrosAtualizados?.length || 0} registro(s) atualizado(s).`);
console.log('');
if (registrosAtualizados && registrosAtualizados.length > 0) {
console.log('📋 Registros atualizados:');
registrosAtualizados.forEach((registro, index) => {
console.log(` ${index + 1}. ID: ${registro.id} | Nova data: ${registro.data_apontamento} | Atualizado em: ${new Date(registro.updated_at).toLocaleString('pt-BR')}`);
});
}
// Passo 5: Verificação final
console.log('');
console.log('🔍 Passo 3: Verificação final...');
const { data: verificacao, error: verifyError } = await supabase
.from('apontamentos_producao')
.select(`
id,
of_number,
data_apontamento,
pecas!inner(
of_number,
etapa_fase,
marca
)
`)
.eq('pecas.of_number', 'B101')
.eq('pecas.etapa_fase', '9')
.gte('pecas.marca', '10')
.lte('pecas.marca', '70')
.eq('data_apontamento', '2025-09-10');
if (verifyError) {
console.error('❌ Erro na verificação final:', verifyError.message);
return;
}
console.log(`✅ Verificação concluída: ${verificacao?.length || 0} registro(s) com a nova data (10/09/2025).`);
// Verificar se ainda existem registros com a data antiga
const { data: registrosAntigos, error: oldRecordsError } = await supabase
.from('apontamentos_producao')
.select('id, pecas!inner(of_number, etapa_fase, marca)')
.eq('pecas.of_number', 'B101')
.eq('pecas.etapa_fase', '9')
.gte('pecas.marca', '10')
.lte('pecas.marca', '70')
.eq('data_apontamento', '2025-09-08');
if (!oldRecordsError && registrosAntigos && registrosAntigos.length > 0) {
console.log(`⚠️ Atenção: Ainda existem ${registrosAntigos.length} registro(s) com a data antiga (08/09/2025).`);
} else {
console.log('✅ Nenhum registro restante com a data antiga.');
}
} catch (error) {
console.error('❌ Erro inesperado:', error.message);
console.error('Stack trace:', error.stack);
} finally {
rl.close();
console.log('');
console.log('🏁 Script finalizado.');
}
}
// Executar o script
if (require.main === module) {
updateApontamentosB101Fase9();
}
module.exports = { updateApontamentosB101Fase9 };

View File

@@ -0,0 +1,71 @@
const { createClient } = require('@supabase/supabase-js');
// Configuração do Supabase
const supabaseUrl = 'https://lwjppiicofojfcdfjsto.supabase.co';
const supabaseServiceKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imx3anBwaWljb2ZvamZjZGZqc3RvIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc1MDQ2MzA1MywiZXhwIjoyMDY2MDM5MDUzfQ.t9vlXHQH4ou2S-CKSeDYSnAeMDYpmkklqlwyGDvpocI';
// Inicializar cliente Supabase com service role para operações administrativas
const supabase = createClient(supabaseUrl, supabaseServiceKey);
// Função principal
async function updateApontamentosB101Fase9Auto() {
console.log('🔧 Script de Atualização Automática - OF B101 Fase 9');
console.log('=' .repeat(60));
console.log('Objetivo: Alterar data de 08/09/2025 para 10/09/2025');
console.log('Filtro: Apenas peças com marca entre 10 e 70');
console.log('');
try {
// Passo 1: Buscar registros que serão afetados
console.log('📋 Passo 1: Identificando registros a serem atualizados...');
const { data: registrosParaAtualizar, error: selectError } = await supabase
.from('apontamentos_producao')
.select(`
id,
of_number,
data_apontamento,
quantidade_produzida,
observacoes,
pecas!inner(
id,
of_number,
etapa_fase,
marca,
descricao
)
`)
.eq('pecas.of_number', 'B101')
.eq('pecas.etapa_fase', '9')
.gte('pecas.marca', '10')
.lte('pecas.marca', '70')
.eq('data_apontamento', '2025-09-08');
if (selectError) {
console.error('❌ Erro ao buscar registros:', selectError.message);
return;
}
if (!registrosParaAtualizar || registrosParaAtualizar.length === 0) {
console.log(' Nenhum registro encontrado com os critérios especificados.');
console.log(' - OF: B101');
console.log(' - Fase: 9');
console.log(' - Marca: entre 10 e 70');
console.log(' - Data atual: 08/09/2025');
return;
}
console.log(`✅ Encontrados ${registrosParaAtualizar.length} registro(s) para atualização.`);
console.log('');
// Passo 2: Executar a atualização automaticamente
console.log('🔄 Passo 2: Executando atualização automaticamente...');
// Extrair IDs dos registros para atualização
const idsParaAtualizar = registrosParaAtualizar.map(r => r.id);
const { data: registrosAtualizados, error: updateError } = await supabase
.from('apontamentos_producao')
.update({
data_apontamento: '2025-09-10',
updated_at: new Date().toISOString(

42
src/App.css Normal file
View File

@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@@ -0,0 +1,185 @@
import React, { useState, useMemo } from "react";
import { useLocation } from "react-router-dom";
import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarMenu } from "@/components/ui/sidebar";
import { useSidebar } from "@/components/ui/sidebar";
import { useUserRole } from "@/hooks/useUserRole";
import { useIconStyle } from "@/hooks/useIconStyle";
import { useIsMobile } from "@/hooks/use-mobile";
import { useUserPermissions } from "@/hooks/useUserPermissions";
import { usePermissionControl } from "@/hooks/usePermissionControl";
import { menuGroups } from "./sidebar/menuConfig";
import { AppSidebarMenuItem } from "./sidebar/SidebarMenuItem";
export function AppSidebar() {
const location = useLocation();
const { setOpenMobile } = useSidebar();
const { isAdmin, loading: roleLoading } = useUserRole();
const { iconStyle } = useIconStyle();
const { hasAccess, loading: permissionsLoading } = useUserPermissions();
const { canAccessTools, canInteractWithSpecialMenus } = usePermissionControl();
const isMobile = useIsMobile();
const [openGroups, setOpenGroups] = useState<{ [key: string]: boolean }>({
'producao': true
});
// Handle mobile menu item click
const handleMenuItemClick = (hasSubItems: boolean = false) => {
if (isMobile && !hasSubItems) {
setOpenMobile(false);
}
};
// Handle submenu toggle
const handleSubmenuToggle = (itemKey: string, currentState: boolean) => {
setOpenGroups(prev => ({
...prev,
[itemKey]: !currentState
}));
};
const isActive = (url: string) => {
if (url === "/dashboard" && location.pathname === "/") {
return true;
}
return location.pathname === url;
};
const getIconProps = (itemKey?: string) => {
const baseProps = { className: "mr-2 h-4 w-4" };
// Mapeamento de cores específicas para cada ícone
const iconColors: { [key: string]: string } = {
'dashboard': '#3b82f6', // azul
'cadastro': '#10b981', // verde
'ferramentas': '#f59e0b', // laranja
'estoque': '#8b5cf6', // roxo
'ofs': '#eab308', // amarelo
'producao': '#ef4444', // vermelho
'painel-industrial': '#06b6d4', // ciano
'expedicao': '#ec4899', // rosa
'obra': '#a3a3a3', // marrom
'tarefas': '#059669', // verde escuro
'biblioteca': '#1d4ed8', // azul escuro
'sistema': '#6b7280', // cinza
'sugestoes': '#7c3aed', // violeta
'atribuicoes': '#14b8a6', // teal
'mapa-interativo': '#6366f1', // indigo
'configuracoes': '#475569', // slate
'admin': '#dc2626', // vermelho escuro
'gerenciar-usuarios': '#e11d48' // vermelho médio
};
// Se temos uma cor específica para este ícone, aplicá-la
if (itemKey && iconColors[itemKey]) {
return {
...baseProps,
style: { color: iconColors[itemKey] }
};
}
// Fallback para o comportamento original baseado no iconStyle
switch (iconStyle) {
case 'white':
return { ...baseProps, className: `${baseProps.className} text-white` };
case 'themed':
return { ...baseProps, className: `${baseProps.className} text-primary` };
case 'colorful':
return { ...baseProps, style: { color: 'inherit' } };
default:
return baseProps;
}
};
// Function to check if user can access an item
const canAccessItem = (itemKey: string, requiresSpecialPermission?: boolean): boolean => {
try {
// Admin can always access everything
if (isAdmin) return true;
// Se o item requer permissão especial, verificar permissões específicas
if (requiresSpecialPermission) {
if (itemKey === 'ferramentas') {
return canAccessTools();
}
if (itemKey === 'tarefas' || itemKey === 'sistema' || itemKey === 'sugestoes') {
return canInteractWithSpecialMenus();
}
}
// Users with any functional permission can see most menus
// Restriction will be applied in the pages/components themselves
return hasAccess();
} catch (error) {
console.warn('Error checking item access:', error);
return false;
}
};
// Wait for permissions loading
if (permissionsLoading || roleLoading) {
return (
<Sidebar>
<SidebarContent>
<div className="p-4 text-center text-slate-400">
Carregando menu...
</div>
</SidebarContent>
</Sidebar>
);
}
console.log('🖥️ Rendering sidebar with:', {
isAdmin,
hasBasicAccess: hasAccess(),
canAccessTools: canAccessTools(),
canInteractWithSpecialMenus: canInteractWithSpecialMenus()
});
return (
<Sidebar>
<SidebarContent>
{menuGroups.map(group => {
// Filter admin groups for non-admin users
if (!isAdmin && group.name === 'Administração') {
console.log('🚫 Hiding admin group for non-admin user');
return null;
}
// Skip groups with no items
if (!group.items || group.items.length === 0) {
return null;
}
return (
<SidebarGroup key={group.id}>
<SidebarGroupLabel
style={{ color: group.color }}
className="text-base font-semibold uppercase tracking-wide"
>
{group.name}
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{group.items.map((item) => (
<AppSidebarMenuItem
key={item.key || item.title}
item={item}
isActive={isActive}
canAccessItem={(itemKey) => canAccessItem(itemKey, item.requiresSpecialPermission)}
isAdmin={isAdmin}
openGroups={openGroups}
onSubmenuToggle={handleSubmenuToggle}
onMenuItemClick={handleMenuItemClick}
getIconProps={(itemKey) => getIconProps(itemKey)}
/>
))}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
})}
</SidebarContent>
</Sidebar>
);
}

View File

@@ -0,0 +1,207 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { RefreshCw, AlertTriangle, Shield } from 'lucide-react';
import { logger } from '@/utils/logger';
interface Props {
children: ReactNode;
fallback?: ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
}
interface State {
hasError: boolean;
error?: Error;
errorInfo?: ErrorInfo;
isPermissionError: boolean;
}
// Função auxiliar para verificar erro de permissão (fora da classe)
const isPermissionRelatedError = (error: Error): boolean => {
const errorMessage = error.message?.toLowerCase() || '';
const errorStack = error.stack?.toLowerCase() || '';
// Palavras-chave que indicam erro de permissão
const permissionKeywords = [
'permission', 'permissao', 'permissão',
'access', 'acesso',
'unauthorized', 'não autorizado', 'nao autorizado',
'forbidden', 'proibido',
'privilege', 'privilegio', 'privilégio',
'role', 'papel', 'função', 'funcao',
'restricted', 'restrito', 'restricao', 'restrição'
];
return permissionKeywords.some(keyword =>
errorMessage.includes(keyword) || errorStack.includes(keyword)
);
};
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
isPermissionError: false
};
}
static getDerivedStateFromError(error: Error): Partial<State> {
// Verificar se é erro relacionado a permissões usando a função auxiliar
const isPermissionError = isPermissionRelatedError(error);
return {
hasError: true,
error,
isPermissionError
};
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
logger.error('Error caught by boundary', { error, errorInfo });
const isPermissionError = isPermissionRelatedError(error);
this.setState({
error,
errorInfo,
isPermissionError
});
this.props.onError?.(error, errorInfo);
}
handleReload = () => {
this.setState({
hasError: false,
error: undefined,
errorInfo: undefined,
isPermissionError: false
});
window.location.reload();
};
handleRetry = () => {
this.setState({
hasError: false,
error: undefined,
errorInfo: undefined,
isPermissionError: false
});
};
handleGoBack = () => {
window.history.back();
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
// Renderizar erro específico de permissão
if (this.state.isPermissionError) {
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 w-12 h-12 bg-orange-100 rounded-full flex items-center justify-center">
<Shield className="w-6 h-6 text-orange-600" />
</div>
<CardTitle className="text-xl">Acesso Restrito</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground text-center">
Você não tem permissão para acessar esta funcionalidade do sistema.
Entre em contato com o administrador para solicitar as permissões necessárias.
</p>
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3">
<p className="text-sm text-orange-800">
<strong>Possíveis soluções:</strong>
</p>
<ul className="text-sm text-orange-700 mt-1 space-y-1">
<li> Solicite acesso ao administrador do sistema</li>
<li> Verifique se você está logado com a conta correta</li>
<li> Aguarde a aprovação das suas permissões</li>
</ul>
</div>
<div className="flex gap-2 justify-center">
<Button variant="outline" onClick={this.handleGoBack}>
Voltar
</Button>
<Button onClick={this.handleReload} className="gap-2">
<RefreshCw className="w-4 h-4" />
Tentar novamente
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
// Renderizar erro genérico
return (
<div className="min-h-screen flex items-center justify-center p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
<AlertTriangle className="w-6 h-6 text-red-600" />
</div>
<CardTitle className="text-xl">Algo deu errado</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground text-center">
Ocorreu um erro inesperado. Tente novamente ou recarregue a página.
</p>
{import.meta.env.DEV && this.state.error && (
<details className="mt-4 p-3 bg-red-50 rounded border text-sm">
<summary className="cursor-pointer font-medium text-red-800">
Detalhes do erro (desenvolvimento)
</summary>
<pre className="mt-2 text-xs text-red-700 whitespace-pre-wrap">
{this.state.error.message}
{this.state.errorInfo?.componentStack}
</pre>
</details>
)}
<div className="flex gap-2 justify-center">
<Button variant="outline" onClick={this.handleRetry}>
Tentar novamente
</Button>
<Button onClick={this.handleReload} className="gap-2">
<RefreshCw className="w-4 h-4" />
Recarregar página
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
return this.props.children;
}
}
// Hook para usar com componentes funcionais
export const withErrorBoundary = <P extends object>(
Component: React.ComponentType<P>,
fallback?: ReactNode
) => {
const WrappedComponent = (props: P) => (
<ErrorBoundary fallback={fallback}>
<Component {...props} />
</ErrorBoundary>
);
WrappedComponent.displayName = `withErrorBoundary(${Component.displayName || Component.name})`;
return WrappedComponent;
};

38
src/components/Layout.tsx Normal file
View File

@@ -0,0 +1,38 @@
import React from 'react';
import { SidebarProvider } from '@/components/ui/sidebar';
import { AppSidebar } from '@/components/AppSidebar';
import { Toaster } from '@/components/ui/sonner';
import { ApontamentoAutomaticoListener } from '@/components/expedicao/ApontamentoAutomaticoListener';
import { ThemeToggle } from '@/components/ThemeToggle';
import { SidebarTrigger } from '@/components/ui/sidebar';
interface LayoutProps {
children: React.ReactNode;
}
export const Layout = ({ children }: LayoutProps) => {
return (
<SidebarProvider>
<div className="min-h-screen flex w-full">
<AppSidebar />
<main className="flex-1 overflow-hidden flex flex-col">
{/* Header fixo com SidebarTrigger e ThemeToggle */}
<header className="h-14 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 flex items-center justify-between px-4 shrink-0">
<div className="flex items-center">
<SidebarTrigger />
</div>
<ThemeToggle />
</header>
{/* Conteúdo principal */}
<div className="flex-1 overflow-auto">
{children}
</div>
</main>
<Toaster />
{/* Listener global para apontamento automático */}
<ApontamentoAutomaticoListener />
</div>
</SidebarProvider>
);
};

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import { useUserRole } from '@/hooks/useUserRole';
interface ProtectedAdminRouteProps {
children: React.ReactNode;
}
export const ProtectedAdminRoute: React.FC<ProtectedAdminRouteProps> = ({ children }) => {
const { user, loading } = useAuth();
const { isAdmin, loading: roleLoading } = useUserRole();
if (loading || roleLoading) {
return <div>Carregando...</div>;
}
if (!user) {
return <Navigate to="/auth" replace />;
}
if (!isAdmin) {
return <Navigate to="/dashboard" replace />;
}
return <>{children}</>;
};

View File

@@ -0,0 +1,100 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import { useQuery } from '@tanstack/react-query';
import { supabase } from '@/integrations/supabase/client';
interface ProtectedRouteProps {
children: React.ReactNode;
}
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
const { user, loading } = useAuth();
// Buscar o perfil do usuário para verificar o status
const { data: profile, isLoading: profileLoading, error } = useQuery({
queryKey: ['user-profile', user?.id],
queryFn: async () => {
if (!user?.id) return null;
const { data, error } = await supabase
.from('profiles')
.select('status')
.eq('id', user.id)
.single();
if (error) {
console.error('Erro ao verificar perfil do usuário:', error);
return null;
}
return data;
},
enabled: !!user?.id,
retry: 1, // Limitar tentativas de retry
staleTime: 30000, // Cache por 30 segundos
});
// Mostrar loading enquanto carrega autenticação ou perfil
if (loading || (user && profileLoading)) {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="text-muted-foreground">Carregando...</div>
</div>
);
}
// Se não há usuário, redirecionar para auth
if (!user) {
console.log('🚫 ProtectedRoute: Usuário não autenticado, redirecionando para /auth');
return <Navigate to="/auth" replace />;
}
// Se há erro ao carregar perfil, permitir acesso (para evitar loop)
if (error) {
console.warn('⚠️ ProtectedRoute: Erro ao carregar perfil, permitindo acesso');
return <>{children}</>;
}
// Se há usuário mas não conseguiu carregar o perfil ainda, mostrar loading
if (!profile && !error) {
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="text-muted-foreground">Verificando permissões...</div>
</div>
);
}
// SEGURANÇA: Verificar se o usuário tem status 'active'
if (profile && profile.status !== 'active') {
console.log('🚫 ProtectedRoute: Usuário com status inválido:', profile.status);
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="text-center space-y-4 p-8 max-w-md mx-auto">
<div className="text-6xl"></div>
<h2 className="text-2xl font-semibold text-foreground">
Aguardando Aprovação
</h2>
<p className="text-muted-foreground">
Sua conta foi criada com sucesso, mas ainda precisa ser aprovada por um administrador.
Você receberá acesso assim que sua solicitação for analisada.
</p>
<div className="mt-6">
<button
onClick={() => {
supabase.auth.signOut();
}}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
>
Fazer Logout
</button>
</div>
</div>
</div>
);
}
console.log('✅ ProtectedRoute: Usuário ativo autorizado, renderizando conteúdo');
return <>{children}</>;
};

View File

@@ -0,0 +1,150 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import { useUserPermissions } from '@/hooks/useUserPermissions';
import { useUserRole } from '@/hooks/useUserRole';
interface ProtectedRouteByResourceProps {
children: React.ReactNode;
resourceKey: string;
}
export const ProtectedRouteByResource: React.FC<ProtectedRouteByResourceProps> = ({
children,
resourceKey
}) => {
const { user, loading } = useAuth();
const { isAdmin, loading: roleLoading } = useUserRole();
// Use a try-catch to prevent the hook from crashing the component
let permissionsData;
try {
permissionsData = useUserPermissions();
} catch (error) {
console.error('Error in useUserPermissions:', error);
// Fallback to basic data structure
permissionsData = {
hasAccess: () => isAdmin,
loading: false,
userPermissions: { can_admin: false, can_create_update_delete: false, can_create_only: false, can_view_only: false },
getResourcePermission: () => isAdmin ? 'can_admin' : 'no_access'
};
}
const { hasAccess, loading: permissionsLoading, userPermissions, getResourcePermission } = permissionsData;
// Aguardar carregamento
if (loading || permissionsLoading || roleLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-muted-foreground">Carregando...</div>
</div>
);
}
// Redirecionar para login se não autenticado
if (!user) {
return <Navigate to="/auth" replace />;
}
// Admin sempre tem acesso (exceto se explicitamente negado)
if (isAdmin) {
const resourcePermission = getResourcePermission(resourceKey);
// Se admin tem negação explícita, negar acesso
if (resourcePermission === 'no_access') {
if (import.meta.env.DEV) {
console.log('❌ Admin access denied by explicit resource permission:', resourceKey);
}
} else {
return <>{children}</>;
}
}
let finalAccess = false;
try {
// 1. PRIMEIRO: Verificar permissão específica do recurso
const resourcePermission = getResourcePermission(resourceKey);
if (import.meta.env.DEV) {
console.log('🔍 Checking access for resource:', {
resourceKey,
user: user?.email,
isAdmin,
resourcePermission,
userPermissions
});
}
// 2. Se há permissão específica definida, ela prevalece SEMPRE
if (resourcePermission !== 'no_access') {
finalAccess = true;
if (import.meta.env.DEV) {
console.log('✅ Access granted by specific resource permission:', resourcePermission);
}
} else {
// 3. Se permissão específica é 'no_access', NEGAR acesso independente de outros privilégios
if (resourcePermission === 'no_access') {
finalAccess = false;
if (import.meta.env.DEV) {
console.log('❌ Access explicitly denied by resource permission');
}
} else {
// 4. Se não há permissão específica, verificar permissões funcionais como fallback
const hasGeneralAccess = hasAccess();
// Para recursos de produção, permitir acesso para colaboradores como fallback
const isProductionResource = resourceKey.startsWith('producao');
const isCollaborator = userPermissions?.can_create_update_delete || userPermissions?.can_admin;
const hasProductionAccess = isProductionResource && (isAdmin || isCollaborator || userPermissions?.can_view_only);
finalAccess = hasGeneralAccess || hasProductionAccess;
if (import.meta.env.DEV) {
console.log('🔄 Fallback to general permissions:', {
hasGeneralAccess,
isProductionResource,
hasProductionAccess,
finalAccess
});
}
}
}
} catch (error) {
console.error('Error checking access permissions:', error);
// For safety, deny access on error unless user is admin and no explicit denial
const resourcePermission = getResourcePermission(resourceKey);
finalAccess = isAdmin && resourcePermission !== 'no_access';
}
if (!finalAccess) {
console.log(`❌ Access denied for resource: ${resourceKey}`);
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center space-y-4">
<div className="text-6xl">🔒</div>
<h2 className="text-2xl font-semibold text-muted-foreground">
Acesso Restrito
</h2>
<p className="text-muted-foreground max-w-md">
Você não tem permissão para acessar esta funcionalidade. Entre em contato com o administrador do sistema.
</p>
<p className="text-sm text-muted-foreground mt-4">
Recurso solicitado: <code className="bg-muted px-2 py-1 rounded">{resourceKey}</code>
</p>
{import.meta.env.DEV && (
<div className="text-xs text-muted-foreground mt-2 p-3 bg-muted/50 rounded">
<p>Debug info:</p>
<p>Admin: {isAdmin ? 'Sim' : 'Não'}</p>
<p>Permissão do Recurso: {getResourcePermission(resourceKey)}</p>
<p>Permissões Funcionais: {userPermissions ? JSON.stringify(userPermissions) : 'Não carregadas'}</p>
</div>
)}
</div>
</div>
);
}
return <>{children}</>;
};

View File

@@ -0,0 +1,34 @@
import { Moon, Sun } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useTheme } from '@/hooks/useTheme';
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
const toggleTheme = () => {
console.log('🎨 Current theme:', theme);
const newTheme = theme === 'light' ? 'dark' : 'light';
console.log('🎨 Switching to theme:', newTheme);
setTheme(newTheme);
};
const isDark = theme === 'dark';
return (
<Button
variant="outline"
size="icon"
onClick={toggleTheme}
className="h-10 w-10 rounded-full bg-card hover:bg-accent hover:text-accent-foreground transition-colors shadow-sm border-border"
aria-label={isDark ? "Mudar para modo claro" : "Mudar para modo escuro"}
>
{isDark ? (
<Sun className="h-[1.2rem] w-[1.2rem] text-foreground transition-all" />
) : (
<Moon className="h-[1.2rem] w-[1.2rem] text-foreground transition-all" />
)}
<span className="sr-only">Alternar tema</span>
</Button>
);
}

View File

@@ -0,0 +1,351 @@
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Trash2, Edit, Key, Star, Plus, TestTube2, CheckCircle, XCircle, Loader2 } from 'lucide-react';
import { useApiKeys, ApiKey } from '@/hooks/useApiKeys';
import { toast } from 'sonner';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
export const ApiKeysManager = () => {
const { apiKeys, loading, saveApiKey, setPrimaryKey, deleteApiKey } = useApiKeys();
const [showDialog, setShowDialog] = useState(false);
const [editingKey, setEditingKey] = useState<ApiKey | null>(null);
const [formData, setFormData] = useState({ name: '', key: '' });
const [testingKeys, setTestingKeys] = useState<Set<string>>(new Set());
const [keyTestResults, setKeyTestResults] = useState<Map<string, boolean>>(new Map());
const handleEdit = (apiKey: ApiKey) => {
setEditingKey(apiKey);
setFormData({ name: apiKey.name, key: apiKey.key });
setShowDialog(true);
};
const handleAdd = () => {
setEditingKey(null);
setFormData({ name: '', key: '' });
setShowDialog(true);
};
const handleSave = async () => {
if (!formData.name.trim() || !formData.key.trim()) {
return;
}
await saveApiKey({
id: editingKey?.id,
name: formData.name,
key: formData.key
});
setShowDialog(false);
setFormData({ name: '', key: '' });
setEditingKey(null);
};
const handleClose = () => {
setShowDialog(false);
setFormData({ name: '', key: '' });
setEditingKey(null);
};
const testApiKey = async (apiKey: ApiKey) => {
setTestingKeys(prev => new Set([...prev, apiKey.id]));
try {
// Teste básico para OpenAI/Gemini APIs
const response = await fetch('https://api.openai.com/v1/models', {
method: 'GET',
headers: {
'Authorization': `Bearer ${apiKey.key}`,
'Content-Type': 'application/json',
},
});
if (response.ok) {
setKeyTestResults(prev => new Map([...prev, [apiKey.id, true]]));
toast.success(`Chave "${apiKey.name}" validada com sucesso!`);
} else if (response.status === 401 || response.status === 403) {
// Se falhar com OpenAI, tenta com Gemini
const geminiResponse = await fetch(`https://generativelanguage.googleapis.com/v1/models?key=${apiKey.key}`, {
method: 'GET',
});
if (geminiResponse.ok) {
setKeyTestResults(prev => new Map([...prev, [apiKey.id, true]]));
toast.success(`Chave "${apiKey.name}" validada com sucesso (Gemini)!`);
} else {
setKeyTestResults(prev => new Map([...prev, [apiKey.id, false]]));
toast.error(`Chave "${apiKey.name}" é inválida ou não tem permissões adequadas.`);
}
} else {
setKeyTestResults(prev => new Map([...prev, [apiKey.id, false]]));
toast.error(`Erro ao testar chave "${apiKey.name}": ${response.status}`);
}
} catch (error) {
console.error('Erro ao testar chave API:', error);
setKeyTestResults(prev => new Map([...prev, [apiKey.id, false]]));
toast.error(`Erro de conexão ao testar chave "${apiKey.name}"`);
} finally {
setTestingKeys(prev => {
const newSet = new Set(prev);
newSet.delete(apiKey.id);
return newSet;
});
}
};
const getTestStatusIcon = (keyId: string) => {
const isValid = keyTestResults.get(keyId);
if (isValid === true) {
return <CheckCircle className="w-4 h-4 text-green-500" />;
} else if (isValid === false) {
return <XCircle className="w-4 h-4 text-red-500" />;
}
return null;
};
const canAddMore = apiKeys.length < 3;
if (loading) {
return (
<Card className="bg-card border-border">
<CardHeader>
<CardTitle className="text-card-foreground flex items-center gap-2">
<Key className="w-5 h-5" />
Gerenciamento de Chaves API
</CardTitle>
</CardHeader>
<CardContent>
<div className="animate-pulse space-y-2">
<div className="h-4 bg-muted rounded w-3/4"></div>
<div className="h-4 bg-muted rounded w-1/2"></div>
</div>
</CardContent>
</Card>
);
}
return (
<>
<Card className="bg-card border-border">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-card-foreground flex items-center gap-2">
<Key className="w-5 h-5" />
Gerenciamento de Chaves API
</CardTitle>
{canAddMore && (
<Button
onClick={handleAdd}
size="sm"
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
<Plus className="w-4 h-4 mr-2" />
Adicionar
</Button>
)}
</CardHeader>
<CardContent>
<div className="space-y-4">
{apiKeys.length === 0 ? (
<div className="text-center py-8">
<Key className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground">Nenhuma chave API configurada</p>
<p className="text-muted-foreground text-sm">
Adicione até 3 chaves para redundância automática
</p>
</div>
) : (
apiKeys.map((apiKey) => (
<div
key={apiKey.id}
className="border border-border rounded-lg p-2 sm:p-3 space-y-2 bg-card"
>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap">
<h4 className="text-card-foreground font-medium text-sm">{apiKey.name}</h4>
{apiKey.is_primary ? (
<Badge variant="default" className="bg-primary text-primary-foreground text-xs">
<Star className="w-3 h-3 mr-1" />
Principal
</Badge>
) : (
<Badge variant="secondary" className="text-xs">Secundária</Badge>
)}
{getTestStatusIcon(apiKey.id)}
</div>
<div className="flex items-center gap-1 flex-wrap">
{testingKeys.has(apiKey.id) ? (
<Button
size="sm"
variant="outline"
disabled
className="text-xs h-6 px-2"
>
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
<span className="hidden sm:inline">Testando...</span>
</Button>
) : (
<Button
size="sm"
variant="outline"
onClick={() => testApiKey(apiKey)}
className="text-xs hover:bg-accent hover:text-accent-foreground h-6 px-2"
>
<TestTube2 className="w-3 h-3 mr-1" />
<span className="hidden sm:inline">Testar</span>
</Button>
)}
{!apiKey.is_primary && (
<Button
size="sm"
variant="outline"
onClick={() => setPrimaryKey(apiKey.id)}
className="text-xs h-6 px-2 hidden sm:inline-flex"
>
Definir como Principal
</Button>
)}
<Button
size="sm"
variant="ghost"
onClick={() => handleEdit(apiKey)}
className="h-6 w-6 p-0 hover:bg-accent hover:text-accent-foreground"
>
<Edit className="w-3 h-3" />
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => deleteApiKey(apiKey.id)}
className="text-destructive hover:text-destructive hover:bg-destructive/10 h-6 w-6 p-0"
>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
<div className="text-xs text-muted-foreground font-mono bg-muted/50 p-2 rounded">
{apiKey.key.substring(0, 20)}...
</div>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 text-xs text-muted-foreground">
<span>Criada: {new Date(apiKey.created_at).toLocaleDateString('pt-BR')}</span>
{keyTestResults.has(apiKey.id) && (
<span className={`flex items-center gap-1 ${
keyTestResults.get(apiKey.id) ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
}`}>
{keyTestResults.get(apiKey.id) ? (
<>
<CheckCircle className="w-3 h-3" />
Válida
</>
) : (
<>
<XCircle className="w-3 h-3" />
Inválida
</>
)}
</span>
)}
</div>
{/* Mobile: Show primary button if not primary */}
{!apiKey.is_primary && (
<div className="sm:hidden">
<Button
size="sm"
variant="outline"
onClick={() => setPrimaryKey(apiKey.id)}
className="text-xs h-6 w-full"
>
Definir como Principal
</Button>
</div>
)}
</div>
))
)}
{apiKeys.length > 0 && (
<div className="mt-4 p-3 bg-muted/50 rounded-lg border border-border">
<p className="text-sm text-card-foreground mb-2">
<strong>Sistema de Fallback:</strong>
</p>
<p className="text-xs text-muted-foreground mb-2">
O sistema usa automaticamente a chave principal. Em caso de falha (401/403/timeout),
tenta as chaves secundárias em sequência até encontrar uma válida.
</p>
<p className="text-xs text-muted-foreground">
<strong>Teste de Validação:</strong> Verifica se a chave é válida testando conexão com OpenAI ou Gemini APIs.
</p>
</div>
)}
</div>
</CardContent>
</Card>
<Dialog open={showDialog} onOpenChange={handleClose}>
<DialogContent className="bg-card border-border">
<DialogHeader>
<DialogTitle className="text-card-foreground">
{editingKey ? 'Editar Chave API' : 'Adicionar Chave API'}
</DialogTitle>
<DialogDescription className="text-muted-foreground">
{editingKey
? 'Modifique os dados da chave API existente.'
: 'Adicione uma nova chave API ao sistema.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="name" className="text-card-foreground">Nome da Chave</Label>
<Input
id="name"
placeholder="Ex: OpenAI Principal, Gemini Backup, etc."
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="bg-background border-input text-foreground"
/>
</div>
<div>
<Label htmlFor="key" className="text-card-foreground">Chave API</Label>
<Input
id="key"
type="password"
placeholder="Insira a chave API"
value={formData.key}
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
className="bg-background border-input text-foreground"
/>
<p className="text-xs text-muted-foreground mt-1">
Suporta chaves OpenAI, Gemini e outras APIs compatíveis
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose}>
Cancelar
</Button>
<Button onClick={handleSave} className="bg-primary hover:bg-primary/90 text-primary-foreground">
{editingKey ? 'Salvar' : 'Adicionar'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -0,0 +1,89 @@
import React, { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { AlertTriangle, Database, Trash2 } from 'lucide-react';
import { CleanupDuplicatesModal } from './CleanupDuplicatesModal';
export const ApontamentoMassa: React.FC = () => {
const [isCleanupModalOpen, setIsCleanupModalOpen] = useState(false);
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold mb-2">Gestão de Apontamentos em Massa</h2>
<p className="text-muted-foreground">
Ferramentas para análise e correção de inconsistências nos apontamentos de produção.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-orange-500" />
Limpeza de Duplicatas
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Identifica e remove apontamentos duplicados ou em excesso por OF.
O sistema analisa peças que foram apontadas múltiplas vezes para o mesmo processo
e remove os registros mais recentes, mantendo apenas a quantidade correta.
</p>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
<h4 className="font-medium text-yellow-800 mb-2">Como funciona:</h4>
<ul className="text-sm text-yellow-700 space-y-1">
<li> Agrupa apontamentos por OF + Marca + Fase + Processo</li>
<li> Identifica quando o total apontado excede a quantidade da peça</li>
<li> Remove apontamentos mais recentes, mantendo os mais antigos</li>
<li> Preserva a integridade dos dados de produção</li>
</ul>
</div>
<Button
onClick={() => setIsCleanupModalOpen(true)}
className="w-full flex items-center gap-2"
variant="destructive"
>
<Trash2 className="w-4 h-4" />
Analisar e Limpar Duplicatas
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Database className="w-5 h-5 text-blue-500" />
Outras Ferramentas
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">
Ferramentas adicionais para gestão de apontamentos em massa estarão disponíveis em breve.
</p>
<div className="space-y-2">
<Button disabled className="w-full" variant="outline">
Recalcular Totais por OF (Em breve)
</Button>
<Button disabled className="w-full" variant="outline">
Validar Sequência de Processos (Em breve)
</Button>
<Button disabled className="w-full" variant="outline">
Relatório de Inconsistências (Em breve)
</Button>
</div>
</CardContent>
</Card>
</div>
<CleanupDuplicatesModal
isOpen={isCleanupModalOpen}
onClose={() => setIsCleanupModalOpen(false)}
/>
</div>
);
};

View File

@@ -0,0 +1,312 @@
import React, { useState, useRef } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Download, Upload, Clock, CheckCircle, XCircle, Database, FileText, AlertTriangle } from 'lucide-react';
import { useBackupManager } from '@/hooks/useBackupManager';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
const BackupManager = () => {
const { backupLogs, logsLoading, isBackingUp, isRestoring, createBackup, restoreBackup } = useBackupManager();
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
setSelectedFile(file);
}
};
const handleCreateBackup = () => {
createBackup();
};
const handleRestore = () => {
if (selectedFile) {
restoreBackup(selectedFile);
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'completed':
return <CheckCircle className="h-4 w-4 text-green-500" />;
case 'failed':
return <XCircle className="h-4 w-4 text-red-500" />;
case 'in_progress':
return <Clock className="h-4 w-4 text-yellow-500" />;
default:
return null;
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'completed':
return <Badge variant="secondary" className="bg-green-100 text-green-800">Concluído</Badge>;
case 'failed':
return <Badge variant="destructive">Falhou</Badge>;
case 'in_progress':
return <Badge variant="secondary" className="bg-yellow-100 text-yellow-800">Em Progresso</Badge>;
default:
return null;
}
};
const formatFileSize = (bytes?: number) => {
if (!bytes) return 'N/A';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<div className="space-y-6">
{/* Seção de Criação de Backup */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Download className="h-5 w-5" />
Criar Backup
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between p-4 bg-blue-50 rounded-lg border border-blue-200">
<div className="flex items-center gap-3">
<Database className="h-6 w-6 text-blue-600" />
<div>
<p className="font-medium text-blue-900">Backup Completo do Banco de Dados</p>
<p className="text-sm text-blue-700">Exporta todas as tabelas e dados em formato ZIP</p>
</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
disabled={isBackingUp}
className="border-blue-300 text-blue-700 hover:bg-blue-100"
>
{isBackingUp ? (
<>
<Clock className="h-4 w-4 mr-2 animate-spin" />
Criando...
</>
) : (
<>
<Download className="h-4 w-4 mr-2" />
Criar Backup
</>
)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirmar Criação de Backup</AlertDialogTitle>
<AlertDialogDescription>
Isso irá criar um backup completo de todas as tabelas do banco de dados.
O processo pode demorar alguns minutos dependendo do tamanho dos dados.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handleCreateBackup}>
Criar Backup
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{isBackingUp && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="h-4 w-4 animate-spin" />
Criando backup...
</div>
<Progress value={50} className="h-2" />
</div>
)}
</CardContent>
</Card>
{/* Seção de Restauração de Backup */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Upload className="h-5 w-5" />
Restaurar Backup
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="p-4 bg-yellow-50 rounded-lg border border-yellow-200">
<div className="flex items-center gap-2 mb-2">
<AlertTriangle className="h-5 w-5 text-yellow-600" />
<p className="font-medium text-yellow-900">Atenção!</p>
</div>
<p className="text-sm text-yellow-800">
A restauração irá <strong>substituir completamente</strong> todos os dados atuais do banco.
Esta operação não pode ser desfeita. Certifique-se de ter um backup atual antes de prosseguir.
</p>
</div>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium mb-2">Selecionar Arquivo de Backup</label>
<input
ref={fileInputRef}
type="file"
accept=".zip,.json"
onChange={handleFileSelect}
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
/>
</div>
{selectedFile && (
<div className="p-3 bg-gray-50 rounded-lg border">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-gray-600" />
<span className="text-sm font-medium">{selectedFile.name}</span>
<span className="text-xs text-gray-500">({formatFileSize(selectedFile.size)})</span>
</div>
</div>
)}
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
disabled={!selectedFile || isRestoring}
className="w-full"
>
{isRestoring ? (
<>
<Clock className="h-4 w-4 mr-2 animate-spin" />
Restaurando...
</>
) : (
<>
<Upload className="h-4 w-4 mr-2" />
Restaurar Backup
</>
)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="text-red-600">Confirmar Restauração</AlertDialogTitle>
<AlertDialogDescription>
<div className="space-y-2">
<p>Esta ação irá:</p>
<ul className="list-disc list-inside text-sm space-y-1">
<li>Apagar TODOS os dados atuais do banco</li>
<li>Restaurar os dados do arquivo: <strong>{selectedFile?.name}</strong></li>
<li>Esta operação NÃO pode ser desfeita</li>
</ul>
<p className="text-red-600 font-medium mt-3">Tem certeza de que deseja continuar?</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction onClick={handleRestore} className="bg-red-600 hover:bg-red-700">
Sim, Restaurar
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
{isRestoring && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="h-4 w-4 animate-spin" />
Restaurando backup...
</div>
<Progress value={50} className="h-2" />
</div>
)}
</CardContent>
</Card>
{/* Histórico de Operações */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
Histórico de Operações
</CardTitle>
</CardHeader>
<CardContent>
{logsLoading ? (
<div className="text-center py-8">
<Clock className="h-8 w-8 animate-spin mx-auto mb-4 text-muted-foreground" />
<p className="text-muted-foreground">Carregando histórico...</p>
</div>
) : backupLogs && backupLogs.length > 0 ? (
<div className="space-y-4">
{backupLogs.map((log) => (
<div key={log.id} className="p-4 border rounded-lg">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
{getStatusIcon(log.status)}
<div>
<p className="font-medium">
{log.operation_type === 'backup' ? 'Backup' : 'Restauração'}
</p>
<p className="text-sm text-muted-foreground">{log.file_name}</p>
</div>
</div>
{getStatusBadge(log.status)}
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Iniciado em:</span>
<p>{format(new Date(log.started_at), 'dd/MM/yyyy HH:mm', { locale: ptBR })}</p>
</div>
{log.completed_at && (
<div>
<span className="text-muted-foreground">Concluído em:</span>
<p>{format(new Date(log.completed_at), 'dd/MM/yyyy HH:mm', { locale: ptBR })}</p>
</div>
)}
<div>
<span className="text-muted-foreground">Tamanho:</span>
<p>{formatFileSize(log.file_size)}</p>
</div>
<div>
<span className="text-muted-foreground">Tabelas/Registros:</span>
<p>{log.tables_count || 0} / {log.records_count || 0}</p>
</div>
</div>
{log.error_message && (
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded">
<p className="text-sm text-red-800">{log.error_message}</p>
</div>
)}
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground">
<Database className="h-8 w-8 mx-auto mb-4 opacity-50" />
<p>Nenhuma operação de backup encontrada</p>
</div>
)}
</CardContent>
</Card>
</div>
);
};
export default BackupManager;

View File

@@ -0,0 +1,305 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Loader2, Search, AlertTriangle, Trash2 } from 'lucide-react';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
interface CleanupDuplicatesModalProps {
isOpen: boolean;
onClose: () => void;
}
interface DuplicateGroup {
chave_agrupamento: string;
of_number: string;
marca: string;
etapa_fase: string;
processo_nome: string;
quantidade_total_peca: number;
apontamentos: Array<{
id: string;
data_apontamento: string;
created_at: string;
quantidade_produzida: number;
tipo_apontamento: string;
}>;
total_apontado: number;
excesso: number;
}
export const CleanupDuplicatesModal: React.FC<CleanupDuplicatesModalProps> = ({
isOpen,
onClose,
}) => {
const [ofNumber, setOfNumber] = useState('');
const [duplicatesData, setDuplicatesData] = useState<{
duplicatesFound: boolean;
details: DuplicateGroup[];
totalGroups: number;
groupsWithDuplicates: number;
} | null>(null);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [isCleaning, setIsCleaning] = useState(false);
const analyzeOF = async () => {
if (!ofNumber.trim()) {
toast.error('Por favor, informe o número da OF');
return;
}
setIsAnalyzing(true);
try {
const { data, error } = await supabase.functions.invoke('cleanup-duplicates', {
body: {
of_number: ofNumber.trim(),
action: 'analyze' // Apenas analisar, não executar limpeza
}
});
if (error) throw error;
setDuplicatesData({
duplicatesFound: data.groupsWithDuplicates > 0,
details: data.details || [],
totalGroups: data.totalGroups || 0,
groupsWithDuplicates: data.groupsWithDuplicates || 0
});
if (data.groupsWithDuplicates === 0) {
toast.success('Nenhuma duplicata encontrada para esta OF!');
} else {
toast.info(`Encontradas ${data.groupsWithDuplicates} duplicatas para análise`);
}
} catch (error) {
console.error('Erro ao analisar duplicatas:', error);
toast.error('Erro ao analisar duplicatas');
} finally {
setIsAnalyzing(false);
}
};
const executeCleaning = async () => {
setIsCleaning(true);
try {
const { data, error } = await supabase.functions.invoke('cleanup-duplicates', {
body: {
of_number: ofNumber.trim(),
action: 'execute' // Executar limpeza
}
});
if (error) throw error;
toast.success(`Limpeza concluída! ${data.duplicatesRemoved} duplicatas removidas.`);
// Resetar dados após limpeza
setDuplicatesData(null);
setOfNumber('');
} catch (error) {
console.error('Erro ao executar limpeza:', error);
toast.error('Erro ao executar limpeza de duplicatas');
} finally {
setIsCleaning(false);
}
};
const handleClose = () => {
setDuplicatesData(null);
setOfNumber('');
onClose();
};
const groupedByPhase = duplicatesData?.details.reduce((acc, group) => {
const phase = group.etapa_fase;
if (!acc[phase]) acc[phase] = [];
acc[phase].push(group);
return acc;
}, {} as Record<string, DuplicateGroup[]>) || {};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-orange-500" />
Limpeza de Duplicatas de Apontamentos
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{/* Input para OF */}
<div className="flex gap-2">
<Input
placeholder="Digite o número da OF (ex: B117)"
value={ofNumber}
onChange={(e) => setOfNumber(e.target.value.toUpperCase())}
className="flex-1"
/>
<Button
onClick={analyzeOF}
disabled={isAnalyzing || !ofNumber.trim()}
className="flex items-center gap-2"
>
{isAnalyzing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Search className="w-4 h-4" />
)}
Analisar
</Button>
</div>
{/* Resultados da análise */}
{duplicatesData && (
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="text-lg">
Resumo da Análise - OF {ofNumber}
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">
{duplicatesData.totalGroups}
</div>
<div className="text-sm text-muted-foreground">
Grupos Analisados
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-orange-600">
{duplicatesData.groupsWithDuplicates}
</div>
<div className="text-sm text-muted-foreground">
Com Duplicatas
</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-red-600">
{duplicatesData.details.reduce((sum, g) => sum + g.apontamentos.length - 1, 0)}
</div>
<div className="text-sm text-muted-foreground">
Registros a Remover
</div>
</div>
</div>
</CardContent>
</Card>
{/* Detalhes por fase */}
{duplicatesData.duplicatesFound && (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Duplicatas Encontradas por Fase:</h3>
{Object.entries(groupedByPhase).map(([phase, groups]) => (
<Card key={phase}>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
Fase {phase}
<Badge variant="destructive">
{groups.length} duplicatas
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{groups.map((group, index) => (
<div key={index} className="border rounded-lg p-3 bg-muted/30">
<div className="flex justify-between items-start mb-2">
<div>
<h4 className="font-medium">
Marca: {group.marca} | Processo: {group.processo_nome}
</h4>
<div className="text-sm text-muted-foreground">
Quantidade da Peça: {group.quantidade_total_peca} |
Total Apontado: {group.total_apontado}
{group.excesso > 0 && (
<span className="text-red-600 font-medium">
{' '}| Excesso: +{group.excesso}
</span>
)}
</div>
</div>
<Badge variant="outline">
{group.apontamentos.length} apontamentos
</Badge>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{group.apontamentos.map((apt, aptIndex) => (
<div
key={aptIndex}
className={`p-2 rounded text-xs border ${
aptIndex === 0
? 'bg-green-50 border-green-200'
: 'bg-red-50 border-red-200'
}`}
>
<div className="font-medium">
{aptIndex === 0 ? '✅ Manter' : '🗑️ Remover'}
</div>
<div>Data: {new Date(apt.data_apontamento).toLocaleDateString('pt-BR')}</div>
<div>Qtd: {apt.quantidade_produzida}</div>
<div>Criado: {new Date(apt.created_at).toLocaleDateString('pt-BR')}</div>
</div>
))}
</div>
</div>
))}
</div>
</CardContent>
</Card>
))}
{/* Botão para executar limpeza */}
<div className="flex justify-end gap-2 pt-4 border-t">
<Button variant="outline" onClick={handleClose}>
Cancelar
</Button>
<Button
onClick={executeCleaning}
disabled={isCleaning}
className="bg-red-600 hover:bg-red-700 flex items-center gap-2"
>
{isCleaning ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Trash2 className="w-4 h-4" />
)}
Executar Limpeza Definitiva
</Button>
</div>
</div>
)}
{!duplicatesData.duplicatesFound && (
<Card>
<CardContent className="text-center py-8">
<div className="text-green-600 mb-2"></div>
<h3 className="font-medium mb-2">Nenhuma Duplicata Encontrada</h3>
<p className="text-muted-foreground">
A OF {ofNumber} está livre de duplicatas de apontamentos.
</p>
</CardContent>
</Card>
)}
</div>
)}
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,277 @@
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Trash2, Edit, Plus, Code, Eye, EyeOff } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { useJsonCodes, JsonCode } from '@/hooks/useJsonCodes';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
export function JsonCodesManager() {
const { jsonCodes, loading, saveJsonCode, deleteJsonCode, toggleActiveStatus } = useJsonCodes();
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [editingCode, setEditingCode] = useState<JsonCode | null>(null);
const [formData, setFormData] = useState({
name: '',
description: '',
json_code: '',
is_active: true
});
const handleOpenDialog = (code?: JsonCode) => {
if (code) {
setEditingCode(code);
setFormData({
name: code.name,
description: code.description || '',
json_code: JSON.stringify(code.json_code, null, 2),
is_active: code.is_active
});
} else {
setEditingCode(null);
setFormData({
name: '',
description: '',
json_code: '{\n \n}',
is_active: true
});
}
setIsDialogOpen(true);
};
const handleSave = async () => {
try {
const jsonData = JSON.parse(formData.json_code);
await saveJsonCode({
id: editingCode?.id,
name: formData.name,
description: formData.description,
json_code: jsonData,
is_active: formData.is_active
});
setIsDialogOpen(false);
setEditingCode(null);
} catch (error) {
console.error('JSON inválido:', error);
alert('JSON inválido. Por favor, verifique a sintaxe.');
}
};
const formatDate = (dateString: string) => {
return new Date(dateString).toLocaleDateString('pt-BR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
if (loading) {
return (
<Card className="bg-slate-800/50 border-slate-700">
<CardContent className="p-6">
<div className="text-white">Carregando códigos JSON...</div>
</CardContent>
</Card>
);
}
return (
<Card className="bg-slate-800/50 border-slate-700">
<CardHeader>
<CardTitle className="text-white flex items-center justify-between">
<div className="flex items-center gap-2">
<Code className="h-5 w-5" />
Gerenciamento de Códigos JSON
</div>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button
onClick={() => handleOpenDialog()}
className="bg-green-600 hover:bg-green-700"
>
<Plus className="h-4 w-4 mr-2" />
Novo Código
</Button>
</DialogTrigger>
<DialogContent className="bg-slate-800 border-slate-700 max-w-4xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-white">
{editingCode ? 'Editar Código JSON' : 'Novo Código JSON'}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="name" className="text-white">Nome *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="bg-slate-700 border-slate-600 text-white"
placeholder="Nome do código JSON"
/>
</div>
<div>
<Label htmlFor="description" className="text-white">Descrição</Label>
<Input
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="bg-slate-700 border-slate-600 text-white"
placeholder="Descrição opcional"
/>
</div>
<div>
<Label htmlFor="json_code" className="text-white">Código JSON *</Label>
<Textarea
id="json_code"
value={formData.json_code}
onChange={(e) => setFormData({ ...formData, json_code: e.target.value })}
className="bg-slate-700 border-slate-600 text-white font-mono min-h-[300px]"
placeholder='{"exemplo": "valor"}'
/>
</div>
<div className="flex items-center space-x-2">
<Switch
id="is_active"
checked={formData.is_active}
onCheckedChange={(checked) => setFormData({ ...formData, is_active: checked })}
/>
<Label htmlFor="is_active" className="text-white">Ativo</Label>
</div>
<div className="flex justify-end gap-2">
<Button
variant="outline"
onClick={() => setIsDialogOpen(false)}
className="border-slate-600 text-slate-300"
>
Cancelar
</Button>
<Button
onClick={handleSave}
className="bg-blue-600 hover:bg-blue-700"
disabled={!formData.name || !formData.json_code}
>
{editingCode ? 'Atualizar' : 'Salvar'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</CardTitle>
</CardHeader>
<CardContent>
{jsonCodes.length === 0 ? (
<div className="text-slate-400 text-center py-8">
Nenhum código JSON cadastrado ainda.
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="border-slate-700">
<TableHead className="text-slate-300">Nome</TableHead>
<TableHead className="text-slate-300">Descrição</TableHead>
<TableHead className="text-slate-300">Status</TableHead>
<TableHead className="text-slate-300">Criado em</TableHead>
<TableHead className="text-slate-300">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jsonCodes.map((code) => (
<TableRow key={code.id} className="border-slate-700">
<TableCell className="text-white font-medium">
{code.name}
</TableCell>
<TableCell className="text-slate-300">
{code.description || '-'}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Badge variant={code.is_active ? 'default' : 'secondary'}>
{code.is_active ? 'Ativo' : 'Inativo'}
</Badge>
<Button
variant="ghost"
size="sm"
onClick={() => toggleActiveStatus(code.id, !code.is_active)}
className="text-slate-400 hover:text-white"
>
{code.is_active ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</TableCell>
<TableCell className="text-slate-300">
{formatDate(code.created_at)}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenDialog(code)}
className="text-blue-400 hover:text-blue-300"
>
<Edit className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-red-400 hover:text-red-300"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent className="bg-slate-800 border-slate-700">
<AlertDialogHeader>
<AlertDialogTitle className="text-white">
Confirmar exclusão
</AlertDialogTitle>
<AlertDialogDescription className="text-slate-300">
Tem certeza que deseja excluir o código "{code.name}"?
Esta ação não pode ser desfeita.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="border-slate-600 text-slate-300">
Cancelar
</AlertDialogCancel>
<AlertDialogAction
onClick={() => deleteJsonCode(code.id)}
className="bg-red-600 hover:bg-red-700"
>
Excluir
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,703 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Info, Package, RefreshCw } from 'lucide-react';
import { useApontamentosProducao } from '@/hooks/useApontamentosProducao';
import { usePecas } from '@/hooks/usePecas';
import { useOFs } from '@/hooks/useOFs';
import { useComponentesAgrupados } from '@/hooks/useComponentesAgrupados';
import { SeletorItensOtimizado } from './SeletorItensOtimizado';
import { useApontamentosValidacao } from '@/hooks/useApontamentosValidacao';
import { toast } from 'sonner';
interface ItemDisponivel {
id: string;
marca: string;
descricao: string;
tipo: 'peca' | 'componente';
quantidade_disponivel: number;
processo_atual_permitido: number;
}
// Cache para manter seleções do usuário
const formCache = {
of_number: '',
fase: '',
processo_id: '',
data_apontamento: new Date().toISOString().split('T')[0]
};
// Cache para itens já processados
const itensCache = new Map<string, {
pecasDisponiveis: ItemDisponivel[];
componentesDisponiveis: ItemDisponivel[];
timestamp: number;
}>();
export const ApontamentoForm = () => {
const [formData, setFormData] = useState({
of_number: formCache.of_number || '',
fase: formCache.fase || '',
data_apontamento: formCache.data_apontamento || new Date().toISOString().split('T')[0],
processo_id: formCache.processo_id || '',
quantidade_produzida: '',
observacoes: '',
todas_disponiveis: false
});
const [itemSelecionado, setItemSelecionado] = useState<ItemDisponivel | null>(null);
const [itensDisponiveis, setItensDisponiveis] = useState<{
pecasDisponiveis: ItemDisponivel[];
componentesDisponiveis: ItemDisponivel[];
}>({ pecasDisponiveis: [], componentesDisponiveis: [] });
const [saving, setSaving] = useState(false);
const [cacheValido, setCacheValido] = useState(false);
const [loadingItens, setLoadingItens] = useState(false);
const [isProcessingItems, setIsProcessingItems] = useState(false);
const { criarApontamento, refetch, processos } = useApontamentosProducao();
const { pecas } = usePecas();
const { ofs } = useOFs();
const { componentesAgrupados } = useComponentesAgrupados(formData.of_number, formData.fase);
const {
validarSequenciaProcessos,
precarregarDados,
limparCache: limparCacheValidacao
} = useApontamentosValidacao();
// Buscar fases únicas da OF selecionada
const fasesDisponiveis = useMemo(() =>
pecas
.filter(peca => peca.of_number === formData.of_number)
.map(peca => peca.etapa_fase)
.filter((fase, index, array) => fase && array.indexOf(fase) === index)
.sort(),
[pecas, formData.of_number]
);
// Peças filtradas - memoizado para evite recálculos
const filteredPecas = useMemo(() =>
pecas.filter(peca =>
peca.of_number === formData.of_number &&
peca.etapa_fase === formData.fase &&
!peca.tem_componentes
),
[pecas, formData.of_number, formData.fase]
);
// Chave única para cache
const cacheKey = useMemo(() =>
`${formData.of_number}_${formData.fase}_${formData.processo_id}`,
[formData.of_number, formData.fase, formData.processo_id]
);
// Salvar cache quando seleções básicas mudam
const updateCache = useCallback((updates: Partial<typeof formData>) => {
Object.assign(formCache, updates);
localStorage.setItem('apontamento_cache', JSON.stringify(formCache));
}, []);
// Carregar cache inicial
useEffect(() => {
const savedCache = localStorage.getItem('apontamento_cache');
if (savedCache) {
try {
const parsed = JSON.parse(savedCache);
Object.assign(formCache, parsed);
setFormData(prev => ({
...prev,
of_number: formCache.of_number || '',
fase: formCache.fase || '',
processo_id: formCache.processo_id || '',
data_apontamento: formCache.data_apontamento || new Date().toISOString().split('T')[0]
}));
setCacheValido(true);
} catch (error) {
console.log('Erro ao carregar cache:', error);
}
}
}, []);
// Função para processar itens com cache
const processarItensDisponiveis = useCallback(async () => {
const { of_number, fase, processo_id } = formData;
if (!of_number || !fase || !processo_id) {
console.log('⚠️ Campos obrigatórios faltando para carregar itens');
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
return;
}
// Verificar se já temos no cache (válido por 30 segundos)
const cached = itensCache.get(cacheKey);
const now = Date.now();
if (cached && (now - cached.timestamp) < 30000) {
console.log('📦 Usando itens do cache');
setItensDisponiveis(cached);
return;
}
// Aguardar dados das peças e componentes
if (filteredPecas.length === 0 && componentesAgrupados.length === 0) {
console.log('⏳ Aguardando dados de peças e componentes...');
return;
}
if (isProcessingItems) {
console.log('🔄 Já processando itens, aguardando...');
return;
}
console.log('\n🚀 === PROCESSANDO ITENS COM CACHE ===');
console.log(`📋 OF: ${of_number}, Fase: ${fase}, Processo: ${processo_id}`);
setIsProcessingItems(true);
setLoadingItens(true);
try {
// Calcular itens disponíveis baseado nos dados existentes
console.log('🧮 Calculando itens disponíveis...');
const itens = {
pecasDisponiveis: filteredPecas.map(peca => ({
id: peca.id,
marca: peca.marca,
descricao: peca.descricao,
tipo: 'peca' as const,
quantidade_disponivel: peca.quantidade,
processo_atual_permitido: 1
})),
componentesDisponiveis: componentesAgrupados.map(comp => ({
id: comp.componente_ids[0] || '', // Use primeiro ID do array
marca: comp.marca_componente,
descricao: comp.descricao || '',
tipo: 'componente' as const,
quantidade_disponivel: comp.quantidade_total,
processo_atual_permitido: 1
}))
};
console.log('✅ Itens calculados:', {
pecas: itens.pecasDisponiveis.length,
componentes: itens.componentesDisponiveis.length
});
// 4. Salvar no cache
itensCache.set(cacheKey, {
...itens,
timestamp: now
});
// 5. Atualizar estado
setItensDisponiveis(itens);
} catch (error) {
console.error('❌ Erro ao processar itens:', error);
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
} finally {
setLoadingItens(false);
setIsProcessingItems(false);
}
}, [
formData.of_number,
formData.fase,
formData.processo_id
]);
// Callback para atualizar dados
const updateData = useCallback(() => {
// 4. Atualizar dados para nova seleção
console.log('✅ Dados atualizados para nova seleção de OF/processo');
}, []);
// Efeito controlado para carregar itens
useEffect(() => {
if (formData.of_number && formData.fase && formData.processo_id) {
// Usar timeout para evitar chamadas excessivas
const timeoutId = setTimeout(() => {
processarItensDisponiveis();
}, 300);
return () => clearTimeout(timeoutId);
} else {
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
}
}, [formData.of_number, formData.fase, formData.processo_id]);
// Reset do item selecionado quando dados mudam
useEffect(() => {
setItemSelecionado(null);
setFormData(prev => ({
...prev,
quantidade_produzida: '',
todas_disponiveis: false
}));
}, [formData.of_number, formData.fase, formData.processo_id]);
// Auto-preenchimento da quantidade quando "todas disponíveis" é marcado
useEffect(() => {
if (formData.todas_disponiveis && itemSelecionado) {
setFormData(prev => ({
...prev,
quantidade_produzida: itemSelecionado.quantidade_disponivel.toString()
}));
}
}, [formData.todas_disponiveis, itemSelecionado]);
const handleBatchSelect = async (items: ItemDisponivel[], tipo: 'peca' | 'componente') => {
if (items.length === 0) {
toast.error('Nenhum item disponível para registro em lote');
return;
}
const totalItens = items.length;
const tipoTexto = tipo === 'peca' ? 'peças' : 'componentes';
const confirmacao = window.confirm(
`Deseja registrar ${totalItens} ${tipoTexto} com suas respectivas quantidades totais?\n\n` +
`Total de itens: ${totalItens}\n` +
`Processo: ${formData.processo_id || 'N/A'}`
);
if (!confirmacao) return;
setSaving(true);
let sucessos = 0;
let erros = 0;
try {
for (const item of items) {
try {
const apontamentoData: any = {
of_number: formData.of_number,
tipo_apontamento: item.tipo,
processo_id: formData.processo_id,
quantidade_produzida: item.quantidade_disponivel,
data_apontamento: formData.data_apontamento,
observacoes: `Registro em lote - ${tipoTexto}`
};
if (item.tipo === 'componente') {
apontamentoData.componente_id = item.id;
} else {
apontamentoData.peca_id = item.id;
}
const result = await criarApontamento(apontamentoData);
if (result.success) {
sucessos++;
} else {
erros++;
}
} catch (error) {
erros++;
console.error(`Erro ao processar ${item.marca}:`, error);
}
}
if (sucessos > 0) {
toast.success(`${sucessos} ${tipoTexto} registradas com sucesso!${erros > 0 ? ` (${erros} com erro)` : ''}`);
await Promise.all([
refetch(),
resetFormForNewEntry()
]);
} else {
toast.error(`Erro ao registrar ${tipoTexto} em lote`);
}
} catch (error) {
console.error('Erro no registro em lote:', error);
toast.error('Erro inesperado no registro em lote');
} finally {
setSaving(false);
}
};
const handleOFChange = (ofNumber: string) => {
const updates = {
of_number: ofNumber,
fase: '',
processo_id: '',
quantidade_produzida: '',
todas_disponiveis: false
};
setFormData(prev => ({ ...prev, ...updates }));
updateCache({ of_number: ofNumber, fase: '', processo_id: '' });
setItemSelecionado(null);
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
// Limpar cache relacionado
itensCache.clear();
};
const handleFaseChange = (fase: string) => {
const updates = {
fase: fase,
processo_id: '',
quantidade_produzida: '',
todas_disponiveis: false
};
setFormData(prev => ({ ...prev, ...updates }));
updateCache({ fase: fase, processo_id: '' });
setItemSelecionado(null);
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
// Limpar cache relacionado
itensCache.clear();
};
const handleProcessoChange = (processoId: string) => {
const updates = {
processo_id: processoId,
quantidade_produzida: '',
todas_disponiveis: false
};
setFormData(prev => ({ ...prev, ...updates }));
updateCache({ processo_id: processoId });
setItemSelecionado(null);
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
};
const handleItemSelect = (item: ItemDisponivel) => {
setItemSelecionado(item);
setFormData(prev => ({
...prev,
quantidade_produzida: '',
todas_disponiveis: false
}));
};
const handleQuantidadeChange = (value: string) => {
const quantidade = parseInt(value);
if (itemSelecionado && quantidade > itemSelecionado.quantidade_disponivel) {
toast.error(`Quantidade não pode ser maior que ${itemSelecionado.quantidade_disponivel} unidades disponíveis`);
return;
}
setFormData(prev => ({
...prev,
quantidade_produzida: value,
todas_disponiveis: false
}));
};
// Função para resetar form e atualizar dados
const resetFormForNewEntry = async () => {
console.log('🔄 Resetando formulário e limpando cache...');
setItemSelecionado(null);
setFormData(prev => ({
...prev,
quantidade_produzida: '',
observacoes: '',
todas_disponiveis: false
}));
// Limpar cache e forçar recarregamento
itensCache.clear();
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!itemSelecionado || !formData.processo_id || !formData.quantidade_produzida) {
toast.error('Preencha todos os campos obrigatórios');
return;
}
const quantidade = parseInt(formData.quantidade_produzida);
if (quantidade <= 0 || quantidade > itemSelecionado.quantidade_disponivel) {
toast.error('Quantidade inválida');
return;
}
setSaving(true);
try {
// Validação básica - pode ser expandida depois
console.log('✅ Validação de sequência aprovada');
const apontamentoData: any = {
of_number: formData.of_number,
tipo_apontamento: itemSelecionado.tipo,
processo_id: formData.processo_id,
quantidade_produzida: quantidade,
data_apontamento: formData.data_apontamento,
observacoes: formData.observacoes || null
};
if (itemSelecionado.tipo === 'componente') {
apontamentoData.componente_id = itemSelecionado.id;
} else {
apontamentoData.peca_id = itemSelecionado.id;
}
const result = await criarApontamento(apontamentoData);
if (result.success) {
toast.success('Apontamento registrado com sucesso!');
await Promise.all([
refetch(),
resetFormForNewEntry()
]);
}
} catch (error) {
console.error('Erro no submit:', error);
toast.error('Erro ao registrar apontamento');
} finally {
setSaving(false);
}
};
const limparCache = () => {
localStorage.removeItem('apontamento_cache');
Object.assign(formCache, {
of_number: '',
fase: '',
processo_id: '',
data_apontamento: new Date().toISOString().split('T')[0]
});
setFormData({
of_number: '',
fase: '',
data_apontamento: new Date().toISOString().split('T')[0],
processo_id: '',
quantidade_produzida: '',
observacoes: '',
todas_disponiveis: false
});
setItemSelecionado(null);
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
setCacheValido(false);
itensCache.clear();
toast.success('Cache limpo com sucesso!');
};
const processoSelecionado = null;
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="space-y-4">
{/* Cache status */}
{cacheValido && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription className="flex items-center justify-between">
<span>Seleções anteriores foram restauradas do cache</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={limparCache}
className="ml-2"
>
<RefreshCw className="h-3 w-3 mr-1" />
Limpar
</Button>
</AlertDescription>
</Alert>
)}
{/* Campos de seleção básicos */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="of">Ordem de Fabricação *</Label>
<Select value={formData.of_number} onValueChange={handleOFChange}>
<SelectTrigger>
<SelectValue placeholder="Selecione a OF" />
</SelectTrigger>
<SelectContent>
{ofs.map((of) => (
<SelectItem key={of.id} value={of.num_of}>
{of.num_of} - {of.descritivo}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{formData.of_number && (
<div>
<Label htmlFor="fase">Fase *</Label>
<Select value={formData.fase} onValueChange={handleFaseChange}>
<SelectTrigger>
<SelectValue placeholder="Selecione a fase" />
</SelectTrigger>
<SelectContent>
{fasesDisponiveis.map((fase) => (
<SelectItem key={fase} value={fase}>
{fase}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
{formData.fase && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="data">Data do Apontamento *</Label>
<Input
id="data"
type="date"
value={formData.data_apontamento}
onChange={(e) => {
const newDate = e.target.value;
setFormData(prev => ({ ...prev, data_apontamento: newDate }));
updateCache({ data_apontamento: newDate });
}}
/>
</div>
<div>
<Label>Processo *</Label>
<Select value={formData.processo_id} onValueChange={handleProcessoChange}>
<SelectTrigger>
<SelectValue placeholder="Selecione o processo" />
</SelectTrigger>
<SelectContent>
{processos.map((processo) => (
<SelectItem key={processo.id} value={processo.id}>
{processo.ordem}. {processo.nome}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
{/* Informação sobre o processo */}
{processoSelecionado && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
{processoSelecionado.ordem === 1
? `Processo inicial: ${processoSelecionado.nome}. Todos os itens estão disponíveis.`
: `Processo ${processoSelecionado.ordem}: ${processoSelecionado.nome}. Apenas itens que passaram pelos processos anteriores estão disponíveis.`
}
</AlertDescription>
</Alert>
)}
{/* Seletor de itens otimizado com funcionalidade de lote - agora com scroll */}
{formData.processo_id && (
<div className="max-h-96 overflow-y-auto">
<SeletorItensOtimizado
pecasDisponiveis={itensDisponiveis.pecasDisponiveis}
componentesDisponiveis={itensDisponiveis.componentesDisponiveis}
itemSelecionado={itemSelecionado}
onItemSelect={handleItemSelect}
onBatchSelect={handleBatchSelect}
loading={loadingItens || isProcessingItems}
/>
</div>
)}
{/* Quantidade produzida */}
{itemSelecionado && (
<div>
<Label htmlFor="quantidade">Quantidade Produzida *</Label>
<div className="flex items-center space-x-2">
<Input
id="quantidade"
type="number"
min="1"
max={itemSelecionado.quantidade_disponivel}
placeholder="0"
value={formData.quantidade_produzida}
onChange={(e) => handleQuantidadeChange(e.target.value)}
disabled={formData.todas_disponiveis}
/>
<div className="flex items-center space-x-2">
<Checkbox
id="todas-disponiveis"
checked={formData.todas_disponiveis}
onCheckedChange={(checked) => setFormData(prev => ({
...prev,
todas_disponiveis: !!checked,
quantidade_produzida: checked ? itemSelecionado.quantidade_disponivel.toString() : ''
}))}
/>
<Label htmlFor="todas-disponiveis" className="text-sm whitespace-nowrap">
todas ({itemSelecionado.quantidade_disponivel})
</Label>
</div>
</div>
</div>
)}
<div>
<Label htmlFor="observacoes">Observações</Label>
<Textarea
id="observacoes"
placeholder="Observações sobre o apontamento..."
value={formData.observacoes}
onChange={(e) => setFormData(prev => ({ ...prev, observacoes: e.target.value }))}
rows={3}
/>
</div>
</div>
{/* Card de informações do item selecionado */}
<div className="space-y-4">
<Card className="bg-muted/50 h-fit">
<CardContent className="p-4">
<h4 className="font-medium flex items-center gap-2 mb-3">
<Package className="h-4 w-4" />
Informações do Item Selecionado
</h4>
{!itemSelecionado ? (
<div className="text-sm text-muted-foreground">
Selecione um item para ver as informações ou use os checkboxes para registro em lote
</div>
) : (
<div className="space-y-2 text-sm">
<div><strong>Tipo:</strong> {itemSelecionado.tipo === 'componente' ? 'Componente' : 'Peça'}</div>
<div><strong>Marca:</strong> {itemSelecionado.marca}</div>
<div><strong>OF:</strong> {formData.of_number}</div>
<div><strong>Fase:</strong> {formData.fase}</div>
<div><strong>Processo:</strong> {processoSelecionado?.nome || 'N/A'}</div>
<div><strong>Descrição:</strong> {itemSelecionado.descricao || 'N/A'}</div>
<div><strong>Quantidade Disponível:</strong> {itemSelecionado.quantidade_disponivel} unidades</div>
</div>
)}
</CardContent>
</Card>
{/* Botões movidos para baixo do card de informações */}
<div className="flex justify-end space-x-2">
{formData.of_number && formData.fase && formData.processo_id && (
<Button
type="button"
variant="outline"
onClick={resetFormForNewEntry}
disabled={saving}
>
Novo Item
</Button>
)}
<Button
type="submit"
disabled={saving || !itemSelecionado || !formData.processo_id || loadingItens || isProcessingItems}
className="min-w-32"
>
{saving ? 'Salvando...' : 'Registrar Apontamento'}
</Button>
</div>
</div>
</div>
</form>
);
};

View File

@@ -0,0 +1,746 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Info, Package, RefreshCw, Loader2 } from 'lucide-react';
import { SeletorPecasSimples } from './SeletorPecasSimples';
import { toast } from 'sonner';
import { useAuth } from '@/hooks/useAuth';
import { supabase } from '@/integrations/supabase/client';
interface ItemDisponivel {
id: string;
marca: string;
descricao: string;
tipo: 'peca' | 'componente';
quantidade_disponivel: number;
processo_atual_permitido: number;
}
interface ApontamentoFormCoreProps {
pecas: any[];
ofs: any[];
componentesAgrupados: any[];
processosOrdenados: any[];
onCarregarItensDisponiveis: (ofNumber: string, processoId: string, filteredPecas: any[], componentesAgrupados: any[]) => Promise<{
pecasDisponiveis: ItemDisponivel[];
componentesDisponiveis: ItemDisponivel[];
}>;
onValidarSequencia: (ofNumber: string, marca: string, processoId: string, quantidade: number, fase?: string) => Promise<{ valido: boolean; erro?: string }>;
onCriarApontamento: (data: any) => Promise<{ success: boolean; error?: any }>;
onRefetch: () => Promise<any>;
onInvalidateCache: (ofNumber: string, fase?: string) => void;
loading: boolean;
validacaoLoading: boolean;
}
const getStoredCache = () => {
try {
const stored = localStorage.getItem('apontamento_form_cache');
return stored ? JSON.parse(stored) : {
of_number: '',
fase: '',
processo_id: '',
data_apontamento: new Date().toISOString().split('T')[0]
};
} catch {
return {
of_number: '',
fase: '',
processo_id: '',
data_apontamento: new Date().toISOString().split('T')[0]
};
}
};
export const ApontamentoFormCore: React.FC<ApontamentoFormCoreProps> = ({
pecas = [],
ofs = [],
componentesAgrupados = [],
processosOrdenados = [],
onCarregarItensDisponiveis,
onValidarSequencia,
onCriarApontamento,
onRefetch,
onInvalidateCache,
loading,
validacaoLoading
}) => {
const { user } = useAuth();
const [formData, setFormData] = useState(() => ({
...getStoredCache(),
quantidade_produzida: '',
observacoes: '',
todas_disponiveis: false
}));
const [itemSelecionado, setItemSelecionado] = useState<ItemDisponivel | null>(null);
const [itensDisponiveis, setItensDisponiveis] = useState<{
pecasDisponiveis: ItemDisponivel[];
componentesDisponiveis: ItemDisponivel[];
}>({ pecasDisponiveis: [], componentesDisponiveis: [] });
const [saving, setSaving] = useState(false);
const [loadingItens, setLoadingItens] = useState(false);
const [lastApontamentoId, setLastApontamentoId] = useState<string | null>(null);
const [canUndo, setCanUndo] = useState(false);
const [undoLoading, setUndoLoading] = useState(false);
const fasesDisponiveis = useMemo(() => {
if (!pecas || !Array.isArray(pecas) || !formData.of_number) return [];
return pecas
.filter(peca => peca && peca.of_number === formData.of_number)
.map(peca => peca.etapa_fase)
.filter((fase, index, array) => fase && array.indexOf(fase) === index)
.sort();
}, [pecas, formData.of_number]);
const filteredPecas = useMemo(() => {
if (!pecas || !Array.isArray(pecas)) return [];
return pecas.filter(peca =>
peca &&
peca.of_number === formData.of_number &&
peca.etapa_fase === formData.fase
);
}, [pecas, formData.of_number, formData.fase]);
const updateCache = useCallback((updates: Partial<typeof formData>) => {
const newCache = { ...getStoredCache(), ...updates };
localStorage.setItem('apontamento_form_cache', JSON.stringify(newCache));
}, []);
const carregarItensDisponiveis = useCallback(async () => {
if (!formData.of_number || !formData.fase || !formData.processo_id) {
console.log('⚠️ Dados insuficientes para carregar itens:', {
of: formData.of_number,
fase: formData.fase,
processo: formData.processo_id
});
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
return;
}
if (!filteredPecas || (filteredPecas.length === 0 && (!componentesAgrupados || componentesAgrupados.length === 0))) {
console.log('⚠️ Sem peças ou componentes para processar');
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
return;
}
setLoadingItens(true);
console.log('🔄 Carregando itens disponíveis...', {
of: formData.of_number,
fase: formData.fase,
processo: formData.processo_id,
pecasCount: filteredPecas.length,
componentesCount: componentesAgrupados?.length || 0
});
try {
const itens = await onCarregarItensDisponiveis(
formData.of_number,
formData.processo_id,
filteredPecas || [],
componentesAgrupados || []
);
console.log('✅ Itens carregados:', {
pecasDisponiveis: itens?.pecasDisponiveis?.length || 0,
componentesDisponiveis: itens?.componentesDisponiveis?.length || 0
});
setItensDisponiveis(itens || { pecasDisponiveis: [], componentesDisponiveis: [] });
} catch (error) {
console.error('❌ Erro ao carregar itens:', error);
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
} finally {
setLoadingItens(false);
}
}, [
formData.of_number,
formData.fase,
formData.processo_id,
filteredPecas,
componentesAgrupados,
onCarregarItensDisponiveis
]);
useEffect(() => {
const timeoutId = setTimeout(() => {
carregarItensDisponiveis();
}, 300);
return () => clearTimeout(timeoutId);
}, [carregarItensDisponiveis]);
useEffect(() => {
setItemSelecionado(null);
setFormData(prev => ({
...prev,
quantidade_produzida: '',
todas_disponiveis: false
}));
}, [formData.of_number, formData.fase, formData.processo_id]);
useEffect(() => {
if (formData.todas_disponiveis && itemSelecionado) {
setFormData(prev => ({
...prev,
quantidade_produzida: itemSelecionado.quantidade_disponivel.toString()
}));
}
}, [formData.todas_disponiveis, itemSelecionado]);
const handleOFChange = (ofNumber: string) => {
const updates = {
of_number: ofNumber,
fase: '',
processo_id: '',
quantidade_produzida: '',
todas_disponiveis: false
};
setFormData(prev => ({ ...prev, ...updates }));
updateCache({ of_number: ofNumber, fase: '', processo_id: '' });
setItemSelecionado(null);
};
const handleFaseChange = (fase: string) => {
const updates = {
fase: fase,
processo_id: '',
quantidade_produzida: '',
todas_disponiveis: false
};
setFormData(prev => ({ ...prev, ...updates }));
updateCache({ fase: fase, processo_id: '' });
setItemSelecionado(null);
};
const handleProcessoChange = (processoId: string) => {
console.log('🔄 Mudança de processo detectada, limpando cache específico...');
// LIMPAR CACHE ESPECÍFICO ANTES DE ALTERAR O PROCESSO
if (formData.of_number && formData.fase) {
console.log(`🗑️ Invalidando cache para OF: ${formData.of_number}, Fase: ${formData.fase}`);
onInvalidateCache(formData.of_number, formData.fase);
}
const updates = {
processo_id: processoId,
quantidade_produzida: '',
todas_disponiveis: false
};
setFormData(prev => ({ ...prev, ...updates }));
updateCache({ processo_id: processoId });
setItemSelecionado(null);
// Limpar itens disponíveis imediatamente para forçar nova consulta
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
console.log('✅ Cache limpo, nova consulta será realizada automaticamente');
};
const handleItemSelect = useCallback((item: ItemDisponivel) => {
console.log('🎯 Item selecionado:', {
marca: item.marca,
tipo: item.tipo,
quantidade_disponivel: item.quantidade_disponivel
});
setItemSelecionado(item);
setFormData(prev => ({
...prev,
quantidade_produzida: '',
todas_disponiveis: false
}));
}, []);
const handleQuantidadeChange = (value: string) => {
const quantidade = parseInt(value);
if (itemSelecionado && quantidade > itemSelecionado.quantidade_disponivel) {
toast.error(`Quantidade não pode ser maior que ${itemSelecionado.quantidade_disponivel} unidades disponíveis`);
return;
}
setFormData(prev => ({
...prev,
quantidade_produzida: value,
todas_disponiveis: false
}));
};
const handleTodasDisponiveisChange = (checked: boolean) => {
console.log('🔄 Checkbox "Todas" alterado:', {
checked,
itemSelecionado: itemSelecionado?.marca,
quantidade_disponivel: itemSelecionado?.quantidade_disponivel
});
setFormData(prev => ({
...prev,
todas_disponiveis: checked,
quantidade_produzida: checked && itemSelecionado ? itemSelecionado.quantidade_disponivel.toString() : prev.quantidade_produzida
}));
};
const resetFormForNewEntry = async () => {
console.log('🔄 Resetando formulário para nova entrada...');
setItemSelecionado(null);
setFormData(prev => ({
...prev,
quantidade_produzida: '',
observacoes: '',
todas_disponiveis: false
}));
console.log('📋 Recarregando itens disponíveis após reset...');
await carregarItensDisponiveis();
};
const handleBatchSelect = async (items: ItemDisponivel[], tipo: 'peca' | 'componente') => {
setSaving(true);
let sucessos = 0;
let erros = 0;
try {
for (const item of items) {
try {
const validacao = await onValidarSequencia(
formData.of_number,
item.marca,
formData.processo_id,
item.quantidade_disponivel,
formData.fase
);
if (!validacao.valido) {
erros++;
continue;
}
const apontamentoData: any = {
of_number: formData.of_number,
tipo_apontamento: item.tipo,
processo_id: formData.processo_id,
quantidade_produzida: item.quantidade_disponivel,
data_apontamento: formData.data_apontamento,
observacoes: `Apontamento em lote - ${tipo}`
};
if (item.tipo === 'componente') {
apontamentoData.componente_id = item.id;
} else {
apontamentoData.peca_id = item.id;
}
const result = await onCriarApontamento(apontamentoData);
if (result.success) {
sucessos++;
} else {
erros++;
}
} catch (error) {
erros++;
}
}
if (sucessos > 0) {
toast.success(`${sucessos} apontamentos realizados com sucesso!`);
await Promise.all([
onRefetch(),
resetFormForNewEntry()
]);
}
if (erros > 0) {
toast.error(`${erros} apontamentos falharam.`);
}
} catch (error) {
toast.error('Erro ao realizar apontamento em lote');
} finally {
setSaving(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!itemSelecionado || !formData.processo_id || !formData.quantidade_produzida) {
toast.error('Preencha todos os campos obrigatórios');
return;
}
const quantidade = parseInt(formData.quantidade_produzida);
if (quantidade <= 0 || quantidade > itemSelecionado.quantidade_disponivel) {
toast.error('Quantidade inválida');
return;
}
setSaving(true);
try {
console.log('🔍 Iniciando validação de sequência:', {
of: formData.of_number,
marca: itemSelecionado.marca,
processo: formData.processo_id,
quantidade,
metodo: formData.todas_disponiveis ? 'CHECKBOX_TODAS' : 'MANUAL'
});
const validacao = await onValidarSequencia(
formData.of_number,
itemSelecionado.marca,
formData.processo_id,
quantidade,
formData.fase
);
if (!validacao.valido) {
toast.error(validacao.erro);
return;
}
console.log('✅ Validação aprovada, criando apontamento...');
const apontamentoData: any = {
of_number: formData.of_number,
tipo_apontamento: itemSelecionado.tipo,
processo_id: formData.processo_id,
quantidade_produzida: quantidade,
data_apontamento: formData.data_apontamento,
observacoes: formData.observacoes || null
};
if (itemSelecionado.tipo === 'componente') {
apontamentoData.componente_id = itemSelecionado.id;
} else {
apontamentoData.peca_id = itemSelecionado.id;
}
console.log('📝 Dados do apontamento sendo criado:', {
...apontamentoData,
metodo_utilizado: formData.todas_disponiveis ? 'CHECKBOX_TODAS' : 'DIGITACAO_MANUAL',
quantidade_original_disponivel: itemSelecionado.quantidade_disponivel,
quantidade_sendo_apontada: quantidade
});
const result = await onCriarApontamento(apontamentoData);
if (result.success) {
console.log('✅ Apontamento criado com sucesso!');
toast.success('Apontamento registrado com sucesso!');
// Atualizar estado do último apontamento
if (result.success) {
// Para desfazer, buscar o último apontamento criado
const { data: lastApontamento } = await supabase
.from('apontamentos_producao')
.select('id')
.eq('created_by', user.id)
.order('created_at', { ascending: false })
.limit(1)
.single();
if (lastApontamento) {
setLastApontamentoId(lastApontamento.id);
setCanUndo(true);
}
}
// INVALIDAR CACHE ESPECÍFICO DA OF E FASE ANTES DE RECARREGAR
console.log('🗑️ Invalidando cache após apontamento bem-sucedido...');
onInvalidateCache(formData.of_number, formData.fase);
// Aguardar atualização e reset
await Promise.all([
onRefetch(),
resetFormForNewEntry()
]);
console.log('✅ Formulário resetado e dados atualizados após apontamento');
}
} catch (error) {
console.error('❌ Erro ao registrar apontamento:', error);
toast.error('Erro ao registrar apontamento');
} finally {
setSaving(false);
}
};
const limparCacheCompleto = () => {
localStorage.removeItem('apontamento_form_cache');
setFormData({
of_number: '',
fase: '',
data_apontamento: new Date().toISOString().split('T')[0],
processo_id: '',
quantidade_produzida: '',
observacoes: '',
todas_disponiveis: false
});
setItemSelecionado(null);
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
toast.success('Cache limpo completamente!');
};
// Função para desfazer último apontamento
const handleUndo = useCallback(async () => {
if (!lastApontamentoId || !user?.id) return;
setUndoLoading(true);
try {
const { error } = await supabase
.from('apontamentos_producao')
.delete()
.eq('id', lastApontamentoId)
.eq('created_by', user.id);
if (error) {
console.error('Erro ao desfazer apontamento:', error);
toast.error('Erro ao desfazer apontamento');
return;
}
// Resetar estado do botão de desfazer
setLastApontamentoId(null);
setCanUndo(false);
// Recarregar itens disponíveis se necessário
if (formData.of_number && formData.fase && formData.processo_id) {
await carregarItensDisponiveis();
}
toast.success('Apontamento desfeito com sucesso!');
} catch (error) {
console.error('Erro inesperado ao desfazer:', error);
toast.error('Erro inesperado ao desfazer apontamento');
} finally {
setUndoLoading(false);
}
}, [lastApontamentoId, user?.id, supabase, formData.of_number, formData.fase, formData.processo_id, carregarItensDisponiveis]);
const processoSelecionado = processosOrdenados?.find(p => p.id === formData.processo_id);
if (loading) {
return (
<div className="flex items-center justify-center p-8 space-y-4">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="text-muted-foreground">Carregando dados...</span>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="space-y-4">
{/* Cache status */}
{(formData.of_number || formData.fase || formData.processo_id) && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription className="flex items-center justify-between">
<span>Formulário restaurado do cache</span>
<Button
type="button"
variant="outline"
size="sm"
onClick={limparCacheCompleto}
className="ml-2"
>
<RefreshCw className="h-3 w-3 mr-1" />
Limpar Cache
</Button>
</AlertDescription>
</Alert>
)}
{/* Campos de seleção básicos */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="of">Ordem de Fabricação *</Label>
<Select value={formData.of_number} onValueChange={handleOFChange}>
<SelectTrigger>
<SelectValue placeholder="Selecione a OF" />
</SelectTrigger>
<SelectContent>
{(ofs || []).map((of) => (
<SelectItem key={of.id} value={of.num_of}>
{of.num_of} - {of.descritivo}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{formData.of_number && (
<div>
<Label htmlFor="fase">Fase *</Label>
<Select value={formData.fase} onValueChange={handleFaseChange}>
<SelectTrigger>
<SelectValue placeholder="Selecione a fase" />
</SelectTrigger>
<SelectContent>
{fasesDisponiveis.map((fase) => (
<SelectItem key={fase} value={fase}>
{fase}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
{formData.fase && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="data">Data do Apontamento *</Label>
<Input
id="data"
type="date"
value={formData.data_apontamento}
onChange={(e) => {
const newDate = e.target.value;
setFormData(prev => ({ ...prev, data_apontamento: newDate }));
updateCache({ data_apontamento: newDate });
}}
/>
</div>
<div>
<Label>Processo *</Label>
<Select value={formData.processo_id} onValueChange={handleProcessoChange}>
<SelectTrigger>
<SelectValue placeholder="Selecione o processo" />
</SelectTrigger>
<SelectContent>
{(processosOrdenados || []).map((processo) => (
<SelectItem key={processo.id} value={processo.id}>
{processo.ordem}. {processo.nome}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
{/* Informação sobre o processo */}
{processoSelecionado && (
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
{processoSelecionado.ordem === 1
? `Processo inicial: ${processoSelecionado.nome}. Todos os itens estão disponíveis.`
: `Processo ${processoSelecionado.ordem}: ${processoSelecionado.nome}. Apenas itens que passaram pelos processos anteriores estão disponíveis.`
}
</AlertDescription>
</Alert>
)}
{/* Seletor de Itens */}
{formData.processo_id && (
<SeletorPecasSimples
pecasDisponiveis={itensDisponiveis.pecasDisponiveis || []}
componentesDisponiveis={itensDisponiveis.componentesDisponiveis || []}
onItemSelect={handleItemSelect}
onBatchSelect={handleBatchSelect}
loading={loadingItens || validacaoLoading}
onSubmit={() => handleSubmit({} as React.FormEvent)}
submitDisabled={saving || !itemSelecionado || !formData.processo_id || !formData.quantidade_produzida || loadingItens}
submitLoading={saving}
canUndo={canUndo}
onUndo={handleUndo}
undoLoading={undoLoading}
lastApontamentoId={lastApontamentoId}
/>
)}
{/* Campos de Quantidade */}
{itemSelecionado && (
<div className="space-y-4 p-4 bg-slate-800/50 rounded-lg border border-slate-700">
<h4 className="font-medium text-slate-200">Quantidade a Apontar</h4>
<div className="space-y-3">
<Input
type="number"
min="1"
max={itemSelecionado.quantidade_disponivel}
placeholder="Digite a quantidade..."
value={formData.quantidade_produzida}
onChange={(e) => handleQuantidadeChange(e.target.value)}
disabled={formData.todas_disponiveis}
className="w-full text-lg h-12 bg-slate-800 border-slate-600"
/>
<div className="flex items-center space-x-2">
<Checkbox
id="todas-disponiveis"
checked={formData.todas_disponiveis}
onCheckedChange={handleTodasDisponiveisChange}
/>
<Label htmlFor="todas-disponiveis" className="text-sm cursor-pointer">
Todas ({itemSelecionado.quantidade_disponivel})
</Label>
</div>
</div>
</div>
)}
<div>
<Label htmlFor="observacoes">Observações</Label>
<Textarea
id="observacoes"
placeholder="Observações sobre o apontamento..."
value={formData.observacoes}
onChange={(e) => setFormData(prev => ({ ...prev, observacoes: e.target.value }))}
rows={3}
/>
</div>
</div>
{/* Card de informações do item selecionado */}
<div>
<Card className="bg-muted/50 h-fit">
<CardContent className="p-4">
<h4 className="font-medium flex items-center gap-2 mb-3">
<Package className="h-4 w-4" />
Informações do Item Selecionado
</h4>
{!itemSelecionado ? (
<div className="text-sm text-muted-foreground">
Selecione um item para ver as informações
</div>
) : (
<div className="space-y-2 text-sm">
<div><strong>Tipo:</strong> {itemSelecionado.tipo === 'componente' ? 'Componente' : 'Peça'}</div>
<div><strong>Marca:</strong> {itemSelecionado.marca}</div>
<div><strong>OF:</strong> {formData.of_number}</div>
<div><strong>Fase:</strong> {formData.fase}</div>
<div><strong>Processo:</strong> {processoSelecionado?.nome || 'N/A'}</div>
<div><strong>Descrição:</strong> {itemSelecionado.descricao || 'N/A'}</div>
<div><strong>Quantidade Disponível:</strong> {itemSelecionado.quantidade_disponivel} unidades</div>
{formData.quantidade_produzida && (
<div className="pt-2 border-t">
<div><strong>Quantidade a Apontar:</strong> {formData.quantidade_produzida} unidades</div>
<div><strong>Método:</strong> {formData.todas_disponiveis ? 'Checkbox "Todas"' : 'Digitação Manual'}</div>
</div>
)}
</div>
)}
</CardContent>
</Card>
</div>
</div>
</form>
);
};

View File

@@ -0,0 +1,237 @@
import React, { useState } from 'react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Search, Package } from 'lucide-react';
import { useApontamentosProducao } from '@/hooks/useApontamentosProducao';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
export const ApontamentosList = () => {
const { apontamentos, loading } = useApontamentosProducao();
const [filtroOF, setFiltroOF] = useState('');
const [filtroProcesso, setFiltroProcesso] = useState('');
const [filtroData, setFiltroData] = useState('');
const [filtroPecaComponente, setFiltroPecaComponente] = useState('');
const apontamentosFiltrados = apontamentos.filter(apt => {
const matchOF = !filtroOF || apt.of_number.toLowerCase().includes(filtroOF.toLowerCase());
const matchProcesso = !filtroProcesso || apt.processo?.nome.toLowerCase().includes(filtroProcesso.toLowerCase());
const matchData = !filtroData || apt.data_apontamento.includes(filtroData);
// Filtro por peça/componente - busca na marca da peça ou componente
const matchPecaComponente = !filtroPecaComponente ||
getMarcaExibida(apt).toLowerCase().includes(filtroPecaComponente.toLowerCase());
return matchOF && matchProcesso && matchData && matchPecaComponente;
});
// Função para determinar a marca exibida baseada no tipo de apontamento
const getMarcaExibida = (apontamento: any) => {
if (apontamento.tipo_apontamento === 'componente' && apontamento.componente?.marca_componente) {
return apontamento.componente.marca_componente;
}
if (apontamento.tipo_apontamento === 'peca' && apontamento.peca?.marca) {
return apontamento.peca.marca;
}
return 'N/A';
};
// Função para calcular peso total baseado no tipo correto (peça ou componente)
const calcularPesoTotal = (apontamento: any) => {
let pesoUnitario = 0;
if (apontamento.tipo_apontamento === 'componente' && apontamento.componente?.peso_unitario) {
pesoUnitario = Number(apontamento.componente.peso_unitario);
} else if (apontamento.tipo_apontamento === 'peca' && apontamento.peca?.peso_unitario) {
pesoUnitario = Number(apontamento.peca.peso_unitario);
}
const quantidade = Number(apontamento.quantidade_produzida) || 0;
const pesoTotal = pesoUnitario * quantidade;
console.log(`Apontamento ${apontamento.id}: Tipo: ${apontamento.tipo_apontamento}, Peso unitário: ${pesoUnitario}, Quantidade: ${quantidade}, Peso total: ${pesoTotal}`);
return pesoTotal.toFixed(3);
};
// Função para obter a descrição correta baseada no tipo
const getDescricaoExibida = (apontamento: any) => {
if (apontamento.tipo_apontamento === 'componente' && apontamento.componente?.descricao) {
return apontamento.componente.descricao;
}
if (apontamento.tipo_apontamento === 'peca' && apontamento.peca?.descricao) {
return apontamento.peca.descricao;
}
return '';
};
// Calcular totais para debug
const pesoTotalApontamentos = apontamentosFiltrados.reduce((total, apt) => {
return total + Number(calcularPesoTotal(apt));
}, 0);
console.log('Total de peso dos apontamentos filtrados:', pesoTotalApontamentos);
if (loading) {
return <div className="text-center py-8">Carregando apontamentos...</div>;
}
return (
<div className="space-y-6">
{/* Filtros */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Search className="h-4 w-4" />
Filtros
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-5 gap-3">
<div>
<label className="text-sm font-medium mb-2 block">OF</label>
<Input
placeholder="Filtrar por OF..."
value={filtroOF}
onChange={(e) => setFiltroOF(e.target.value)}
className="h-8"
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">Processo</label>
<Input
placeholder="Filtrar por processo..."
value={filtroProcesso}
onChange={(e) => setFiltroProcesso(e.target.value)}
className="h-8"
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">Data</label>
<Input
type="date"
value={filtroData}
onChange={(e) => setFiltroData(e.target.value)}
className="h-8"
/>
</div>
<div>
<label className="text-sm font-medium mb-2 block">Peça/Componente</label>
<Input
placeholder="Filtrar por peça/componente..."
value={filtroPecaComponente}
onChange={(e) => setFiltroPecaComponente(e.target.value)}
className="h-8"
/>
</div>
<div className="flex flex-col items-start">
<div className="text-xs text-muted-foreground">
{apontamentosFiltrados.length} registros encontrados
</div>
<div className="text-xs text-muted-foreground mt-1">
Peso total: {pesoTotalApontamentos.toFixed(3)} kg
</div>
</div>
</div>
</CardContent>
</Card>
{/* Tabela de Apontamentos */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Package className="h-4 w-4" />
Histórico de Apontamentos
</div>
<Badge variant="secondary">
{apontamentosFiltrados.length} registros
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
{apontamentosFiltrados.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
Nenhum apontamento encontrado
</div>
) : (
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>OF</TableHead>
<TableHead>Peça/Componente</TableHead>
<TableHead>Tipo</TableHead>
<TableHead>Processo</TableHead>
<TableHead className="text-center">Quantidade</TableHead>
<TableHead className="text-center">Peso Unit. (kg)</TableHead>
<TableHead className="text-center">Peso Total (kg)</TableHead>
<TableHead>Data</TableHead>
<TableHead>Observações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{apontamentosFiltrados.map((apontamento) => {
const pesoUnitario = apontamento.tipo_apontamento === 'componente'
? Number(apontamento.componente?.peso_unitario || 0)
: Number(apontamento.peca?.peso_unitario || 0);
return (
<TableRow key={apontamento.id} className="h-9">
<TableCell className="font-medium py-1">
{apontamento.of_number}
</TableCell>
<TableCell className="py-1">
<div className="font-medium">
{getMarcaExibida(apontamento)}
</div>
{getDescricaoExibida(apontamento) && (
<div className="text-xs text-muted-foreground">
{getDescricaoExibida(apontamento)}
</div>
)}
</TableCell>
<TableCell className="py-1">
<Badge variant={apontamento.tipo_apontamento === 'componente' ? 'secondary' : 'default'}>
{apontamento.tipo_apontamento === 'componente' ? 'Componente' : 'Peça'}
</Badge>
</TableCell>
<TableCell className="py-1">
<Badge variant="outline">
{apontamento.processo?.nome || 'N/A'}
</Badge>
</TableCell>
<TableCell className="text-center font-medium py-1">
{apontamento.quantidade_produzida}
</TableCell>
<TableCell className="text-center py-1">
{pesoUnitario.toFixed(3)}
</TableCell>
<TableCell className="text-center font-medium py-1">
{calcularPesoTotal(apontamento)}
</TableCell>
<TableCell className="py-1">
{format(new Date(apontamento.data_apontamento), 'dd/MM/yyyy', {
locale: ptBR
})}
</TableCell>
<TableCell className="max-w-xs py-1">
<div className="text-sm text-muted-foreground truncate">
{apontamento.observacoes || '-'}
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</CardContent>
</Card>
</div>
);
};

View File

@@ -0,0 +1,119 @@
import React from 'react';
import { Package } from 'lucide-react';
import { useApontamentosProducao } from '@/hooks/useApontamentosProducao';
import { supabase } from '@/integrations/supabase/client';
import { toast } from 'sonner';
import { ApontamentosFilters } from './historico/ApontamentosFilters';
import { ApontamentosResultsList } from './historico/ApontamentosResultsList';
import { useApontamentosFilters } from './historico/useApontamentosFilters';
export const ApontamentosListOtimizado: React.FC = () => {
const { apontamentos, loading, refetch } = useApontamentosProducao();
const {
searchTerm,
setSearchTerm,
filterOF,
filterFase,
filterProcesso,
dataInicio,
dataFim,
setDataInicio,
setDataFim,
uniqueOFs,
uniqueFases,
uniqueProcessos,
filteredApontamentos,
initialized,
handleOFChange,
handleFaseChange,
handleProcessoChange,
clearFilters
} = useApontamentosFilters(apontamentos);
const handleReverterApontamento = async (apontamentoId: string) => {
try {
console.log('Iniciando reversão do apontamento:', apontamentoId);
// Buscar informações do apontamento antes de deletar para logs
const { data: apontamentoInfo } = await supabase
.from('apontamentos_producao')
.select(`
of_number,
quantidade_produzida,
peca:pecas(marca),
componente:componentes_peca(marca_componente),
processo:processos_fabricacao(nome)
`)
.eq('id', apontamentoId)
.single();
if (apontamentoInfo) {
const marca = apontamentoInfo.peca?.marca || apontamentoInfo.componente?.marca_componente || 'N/A';
console.log(`Revertendo apontamento: ${marca} - ${apontamentoInfo.quantidade_produzida} unidades - OF: ${apontamentoInfo.of_number}`);
}
// Deletar o apontamento
const { error } = await supabase
.from('apontamentos_producao')
.delete()
.eq('id', apontamentoId);
if (error) {
console.error('Erro ao deletar apontamento:', error);
throw error;
}
console.log('Apontamento deletado com sucesso, atualizando lista...');
toast.success('Apontamento revertido com sucesso!');
// Forçar atualização dos dados
await refetch();
console.log('Lista de apontamentos atualizada');
} catch (error: any) {
console.error('Erro completo ao reverter apontamento:', error);
toast.error(`Erro ao reverter apontamento: ${error.message || 'Erro desconhecido'}`);
}
};
if (loading || !initialized) {
return (
<div className="flex items-center justify-center p-8">
<div className="flex items-center gap-2">
<Package className="h-5 w-5 animate-spin" />
<span>Carregando histórico...</span>
</div>
</div>
);
}
return (
<div className="space-y-4">
<ApontamentosFilters
searchTerm={searchTerm}
setSearchTerm={setSearchTerm}
filterOF={filterOF}
filterFase={filterFase}
filterProcesso={filterProcesso}
dataInicio={dataInicio}
dataFim={dataFim}
setDataInicio={setDataInicio}
setDataFim={setDataFim}
uniqueOFs={uniqueOFs}
uniqueFases={uniqueFases}
uniqueProcessos={uniqueProcessos}
handleOFChange={handleOFChange}
handleFaseChange={handleFaseChange}
handleProcessoChange={handleProcessoChange}
clearFilters={clearFilters}
/>
<ApontamentosResultsList
filteredApontamentos={filteredApontamentos}
onRevert={handleReverterApontamento}
/>
</div>
);
};

View File

@@ -0,0 +1,258 @@
import React, { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Plus, Edit, Save, X, ChevronUp, ChevronDown } from 'lucide-react';
import { useApontamentosProducao } from '@/hooks/useApontamentosProducao';
import { toast } from 'sonner';
type SortField = 'ordem' | 'nome' | 'descricao' | 'ativo';
type SortDirection = 'asc' | 'desc';
export const ProcessosList = () => {
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [sortField, setSortField] = useState<SortField>('ordem');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const [formData, setFormData] = useState({
nome: '',
descricao: '',
ordem: 0
});
const { processos, loading, criarProcesso, atualizarProcesso } = useApontamentosProducao();
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDirection('asc');
}
};
const sortedProcessos = React.useMemo(() => {
return [...processos].sort((a, b) => {
let aValue: any;
let bValue: any;
switch (sortField) {
case 'ordem':
aValue = a.ordem;
bValue = b.ordem;
break;
case 'nome':
aValue = a.nome;
bValue = b.nome;
break;
case 'descricao':
aValue = a.descricao || '';
bValue = b.descricao || '';
break;
case 'ativo':
aValue = a.ativo;
bValue = b.ativo;
break;
default:
return 0;
}
if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1;
if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
}, [processos, sortField, sortDirection]);
const SortButton = ({ field, children }: { field: SortField; children: React.ReactNode }) => (
<button
onClick={() => handleSort(field)}
className="flex items-center gap-1 hover:text-foreground transition-colors w-full text-left"
>
{children}
{sortField === field && (
sortDirection === 'asc' ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />
)}
</button>
);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.nome.trim()) {
toast.error('Nome do processo é obrigatório');
return;
}
let result;
if (editingId) {
result = await atualizarProcesso(editingId, formData);
} else {
result = await criarProcesso({
...formData,
ativo: true
});
}
if (result.success) {
setFormData({ nome: '', descricao: '', ordem: 0 });
setShowForm(false);
setEditingId(null);
}
};
const handleEdit = (processo: any) => {
setFormData({
nome: processo.nome,
descricao: processo.descricao || '',
ordem: processo.ordem || 0
});
setEditingId(processo.id);
setShowForm(true);
};
const handleCancel = () => {
setFormData({ nome: '', descricao: '', ordem: 0 });
setShowForm(false);
setEditingId(null);
};
if (loading) {
return (
<div className="flex items-center justify-center h-32">
<div className="text-muted-foreground">Carregando processos...</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold">Processos de Fabricação</h3>
<Button
size="sm"
onClick={() => setShowForm(!showForm)}
>
<Plus className="h-4 w-4 mr-2" />
Novo Processo
</Button>
</div>
{showForm && (
<Card>
<CardHeader>
<CardTitle className="text-base">
{editingId ? 'Editar Processo' : 'Novo Processo'}
</CardTitle>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<Label htmlFor="nome">Nome *</Label>
<Input
id="nome"
value={formData.nome}
onChange={(e) => setFormData(prev => ({ ...prev, nome: e.target.value }))}
placeholder="Nome do processo"
/>
</div>
<div>
<Label htmlFor="ordem">Ordem</Label>
<Input
id="ordem"
type="number"
min="0"
value={formData.ordem}
onChange={(e) => setFormData(prev => ({ ...prev, ordem: parseInt(e.target.value) || 0 }))}
placeholder="0"
/>
</div>
<div>
<Label htmlFor="descricao">Descrição</Label>
<Textarea
id="descricao"
value={formData.descricao}
onChange={(e) => setFormData(prev => ({ ...prev, descricao: e.target.value }))}
placeholder="Descrição opcional"
rows={1}
/>
</div>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={handleCancel}>
<X className="h-4 w-4 mr-2" />
Cancelar
</Button>
<Button type="submit">
<Save className="h-4 w-4 mr-2" />
{editingId ? 'Atualizar' : 'Salvar'}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow className="h-6">
<TableHead className="py-1">
<SortButton field="ordem">Ordem</SortButton>
</TableHead>
<TableHead className="py-1">
<SortButton field="nome">Nome</SortButton>
</TableHead>
<TableHead className="py-1">
<SortButton field="descricao">Descrição</SortButton>
</TableHead>
<TableHead className="py-1">
<SortButton field="ativo">Status</SortButton>
</TableHead>
<TableHead className="py-1 w-24">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedProcessos.map((processo) => (
<TableRow key={processo.id} className="h-6">
<TableCell className="py-1 text-center text-sm">{processo.ordem}</TableCell>
<TableCell className="py-1 font-medium text-sm">{processo.nome}</TableCell>
<TableCell className="py-1 text-sm">{processo.descricao || '-'}</TableCell>
<TableCell className="py-1">
<Badge variant={processo.ativo ? 'default' : 'secondary'} className="text-xs">
{processo.ativo ? 'Ativo' : 'Inativo'}
</Badge>
</TableCell>
<TableCell className="py-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(processo)}
className="h-5 w-5 p-0"
>
<Edit className="h-3 w-3" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<Card className="mt-4">
<CardContent className="pt-6">
<div className="text-sm text-muted-foreground">
<p><strong>Informações:</strong></p>
<p>Os processos são utilizados para categorizar os apontamentos de produção.</p>
<p>A ordem define a sequência dos processos no fluxo de fabricação.</p>
</div>
</CardContent>
</Card>
</div>
);
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,412 @@
import React, { useState, useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Package, Search, CheckSquare, Loader2, Weight, Info, ArrowUpDown, Undo2 } from 'lucide-react';
import { toast } from 'sonner';
import { applyMarcaFilter } from '@/utils/rangeFilter';
import { naturalSort } from '@/utils/naturalSort';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
interface ItemDisponivel {
id: string;
marca: string;
descricao: string;
tipo: 'peca' | 'componente';
quantidade_disponivel: number;
processo_atual_permitido: number;
peso_unitario?: number;
}
interface SeletorPecasSimplesProps {
pecasDisponiveis: ItemDisponivel[];
componentesDisponiveis: ItemDisponivel[];
onItemSelect: (item: ItemDisponivel) => void;
onBatchSelect: (items: ItemDisponivel[], tipo: 'peca' | 'componente') => void;
loading: boolean;
onSubmit?: () => void;
submitDisabled?: boolean;
submitLoading?: boolean;
// Props para o botão de desfazer
canUndo?: boolean;
onUndo?: () => void;
undoLoading?: boolean;
lastApontamentoId?: string;
}
export const SeletorPecasSimples: React.FC<SeletorPecasSimplesProps> = ({
pecasDisponiveis = [],
componentesDisponiveis = [],
onItemSelect,
onBatchSelect,
loading,
onSubmit,
submitDisabled = false,
submitLoading = false,
canUndo = false,
onUndo,
undoLoading = false,
lastApontamentoId
}) => {
const [filtro, setFiltro] = useState('');
const [activeTab, setActiveTab] = useState('pecas');
const [itemSelecionado, setItemSelecionado] = useState<ItemDisponivel | null>(null);
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
// Filtrar e ordenar peças com suporte a range
const pecasFiltradas = useMemo(() => {
let pecas = pecasDisponiveis;
if (filtro) {
pecas = pecasDisponiveis.filter(peca =>
applyMarcaFilter(peca.marca, filtro) ||
peca.descricao.toLowerCase().includes(filtro.toLowerCase())
);
}
// Ordenar numericamente usando naturalSort
return [...pecas].sort((a, b) => {
const comparison = naturalSort(a.marca, b.marca);
return sortOrder === 'asc' ? comparison : -comparison;
});
}, [pecasDisponiveis, filtro, sortOrder]);
// Filtrar e ordenar componentes com suporte a range
const componentesFiltrados = useMemo(() => {
let componentes = componentesDisponiveis;
if (filtro) {
componentes = componentesDisponiveis.filter(comp =>
applyMarcaFilter(comp.marca, filtro) ||
comp.descricao.toLowerCase().includes(filtro.toLowerCase())
);
}
// Ordenar numericamente usando naturalSort
return [...componentes].sort((a, b) => {
const comparison = naturalSort(a.marca, b.marca);
return sortOrder === 'asc' ? comparison : -comparison;
});
}, [componentesDisponiveis, filtro, sortOrder]);
// Calcular peso total das peças disponíveis/filtradas
const pesoTotalPecas = useMemo(() => {
return pecasFiltradas.reduce((total, peca) => {
const pesoUnitario = peca.peso_unitario || 0;
return total + (pesoUnitario * peca.quantidade_disponivel);
}, 0);
}, [pecasFiltradas]);
// Calcular peso total dos componentes disponíveis/filtrados
const pesoTotalComponentes = useMemo(() => {
return componentesFiltrados.reduce((total, comp) => {
const pesoUnitario = comp.peso_unitario || 0;
return total + (pesoUnitario * comp.quantidade_disponivel);
}, 0);
}, [componentesFiltrados]);
const handleItemClick = (item: ItemDisponivel) => {
setItemSelecionado(item);
onItemSelect(item);
};
const handleBatchPecas = () => {
if (pecasFiltradas.length === 0) {
toast.error('Nenhuma peça disponível para apontamento em lote');
return;
}
onBatchSelect(pecasFiltradas, 'peca');
};
const handleBatchComponentes = () => {
if (componentesFiltrados.length === 0) {
toast.error('Nenhum componente disponível para apontamento em lote');
return;
}
onBatchSelect(componentesFiltrados, 'componente');
};
const formatarPeso = (peso: number) => {
return peso.toFixed(2);
};
const handleToggleSort = () => {
setSortOrder(current => current === 'asc' ? 'desc' : 'asc');
};
if (loading) {
return (
<Card className="bg-card border-border">
<CardContent className="flex items-center justify-center p-8">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">Carregando itens disponíveis...</span>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="bg-card border-border">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-card-foreground flex items-center gap-2 text-lg">
<Package className="h-5 w-5" />
Itens Disponíveis
</CardTitle>
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={handleToggleSort}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
>
<ArrowUpDown className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Ordenar {sortOrder === 'asc' ? 'crescente' : 'decrescente'}</p>
<p className="text-xs text-muted-foreground">Clique para alternar</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
{/* Botão Desfazer Último Apontamento */}
{onUndo && (
<AlertDialog>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
disabled={!canUndo || undoLoading}
className="border-orange-200 text-orange-600 hover:bg-orange-50 hover:text-orange-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{undoLoading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Undo2 className="h-4 w-4 mr-2" />
)}
{undoLoading ? 'Desfazendo...' : 'Desfazer Ult. Apont.'}
</Button>
</AlertDialogTrigger>
</TooltipTrigger>
<TooltipContent>
<p>Desfazer o último apontamento realizado</p>
<p className="text-xs text-muted-foreground">
{canUndo ? 'Clique para desfazer' : 'Nenhum apontamento para desfazer'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirmar Reversão</AlertDialogTitle>
<AlertDialogDescription>
Tem certeza que deseja desfazer o último apontamento?
<br />
<strong>Esta ação não pode ser desfeita.</strong>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={onUndo}
className="bg-orange-600 hover:bg-orange-700"
>
Confirmar Reversão
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{onSubmit && (
<Button
onClick={onSubmit}
disabled={submitDisabled || submitLoading}
className="min-w-32"
>
{submitLoading ? 'Salvando...' : 'Registrar Apontamento'}
</Button>
)}
</div>
</div>
{/* Campo de busca com tooltip */}
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Filtrar por número da peça... (ex: 32@47)"
value={filtro}
onChange={(e) => setFiltro(e.target.value)}
className="pl-10 pr-8 bg-background border-border"
/>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Info className="absolute right-3 top-3 h-4 w-4 text-muted-foreground cursor-help" />
</TooltipTrigger>
<TooltipContent>
<div className="text-sm space-y-1">
<p><strong>Filtro por range:</strong></p>
<p>Use @ para filtrar intervalos</p>
<p><strong>Exemplo:</strong> 32@47 (peças de 32 a 47)</p>
<p><strong>Busca normal:</strong> Digite parte da marca</p>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</CardHeader>
<CardContent className="space-y-4">
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="w-full bg-muted border-border">
<TabsTrigger
value="pecas"
className="flex-1 text-muted-foreground data-[state=active]:bg-background data-[state=active]:text-foreground"
>
<Package className="h-4 w-4 mr-2" />
Peças ({pecasFiltradas.length}) - {formatarPeso(pesoTotalPecas)} kg
<Weight className="h-3 w-3 ml-1 text-muted-foreground" />
</TabsTrigger>
<TabsTrigger
value="componentes"
className="flex-1 text-muted-foreground data-[state=active]:bg-background data-[state=active]:text-foreground"
>
<Package className="h-4 w-4 mr-2" />
Componentes ({componentesFiltrados.length}) - {formatarPeso(pesoTotalComponentes)} kg
<Weight className="h-3 w-3 ml-1 text-muted-foreground" />
</TabsTrigger>
</TabsList>
<TabsContent value="pecas" className="space-y-3 mt-4">
{pecasFiltradas.length > 0 && (
<Button
onClick={handleBatchPecas}
variant="outline"
size="sm"
className="w-full border-border hover:bg-muted"
>
<CheckSquare className="h-4 w-4 mr-2" />
Apontar Todas as Peças ({pecasFiltradas.length})
</Button>
)}
<div className="space-y-2 max-h-64 overflow-y-auto">
{pecasFiltradas.length === 0 ? (
<div className="text-center py-6 text-muted-foreground">
<Package className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>Nenhuma peça disponível</p>
</div>
) : (
pecasFiltradas.map((peca) => (
<div
key={peca.id}
onClick={() => handleItemClick(peca)}
className={`p-3 rounded-lg border cursor-pointer transition-all duration-200 ${
itemSelecionado?.id === peca.id
? 'bg-primary/10 border-primary text-foreground'
: 'bg-background border-border hover:bg-muted text-foreground'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Package className="h-4 w-4 text-primary" />
<div>
<div className="font-medium">{peca.marca}</div>
<div className="text-sm text-muted-foreground">{peca.descricao}</div>
{peca.peso_unitario && (
<div className="text-xs text-muted-foreground">
Peso unitário: {formatarPeso(peca.peso_unitario)} kg
</div>
)}
</div>
</div>
<Badge variant="secondary" className="bg-secondary text-secondary-foreground">
{peca.quantidade_disponivel} un.
</Badge>
</div>
</div>
))
)}
</div>
</TabsContent>
<TabsContent value="componentes" className="space-y-3 mt-4">
{componentesFiltrados.length > 0 && (
<Button
onClick={handleBatchComponentes}
variant="outline"
size="sm"
className="w-full border-border hover:bg-muted"
>
<CheckSquare className="h-4 w-4 mr-2" />
Apontar Todos os Componentes ({componentesFiltrados.length})
</Button>
)}
<div className="space-y-2 max-h-64 overflow-y-auto">
{componentesFiltrados.length === 0 ? (
<div className="text-center py-6 text-muted-foreground">
<Package className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>Nenhum componente disponível</p>
</div>
) : (
componentesFiltrados.map((comp) => (
<div
key={comp.id}
onClick={() => handleItemClick(comp)}
className={`p-3 rounded-lg border cursor-pointer transition-all duration-200 ${
itemSelecionado?.id === comp.id
? 'bg-primary/10 border-primary text-foreground'
: 'bg-background border-border hover:bg-muted text-foreground'
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Package className="h-4 w-4 text-orange-500" />
<div>
<div className="font-medium">{comp.marca}</div>
<div className="text-sm text-muted-foreground">{comp.descricao}</div>
{comp.peso_unitario && (
<div className="text-xs text-muted-foreground">
Peso unitário: {formatarPeso(comp.peso_unitario)} kg
</div>
)}
</div>
</div>
<Badge variant="secondary" className="bg-secondary text-secondary-foreground">
{comp.quantidade_disponivel} un.
</Badge>
</div>
</div>
))
)}
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,195 @@
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Calendar } from '@/components/ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Search, Calendar as CalendarIcon, Filter, RefreshCw } from 'lucide-react';
import { format } from 'date-fns';
import { cn } from '@/lib/utils';
interface ApontamentosFiltersProps {
searchTerm: string;
setSearchTerm: (value: string) => void;
filterOF: string;
filterFase: string;
filterProcesso: string;
dataInicio: Date | undefined;
dataFim: Date | undefined;
setDataInicio: (date: Date | undefined) => void;
setDataFim: (date: Date | undefined) => void;
uniqueOFs: string[];
uniqueFases: string[];
uniqueProcessos: string[];
handleOFChange: (value: string) => void;
handleFaseChange: (value: string) => void;
handleProcessoChange: (value: string) => void;
clearFilters: () => void;
}
export const ApontamentosFilters: React.FC<ApontamentosFiltersProps> = ({
searchTerm,
setSearchTerm,
filterOF,
filterFase,
filterProcesso,
dataInicio,
dataFim,
setDataInicio,
setDataFim,
uniqueOFs,
uniqueFases,
uniqueProcessos,
handleOFChange,
handleFaseChange,
handleProcessoChange,
clearFilters
}) => {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm flex items-center gap-2">
<Filter className="h-4 w-4" />
Filtros Inteligentes
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Primeira linha de filtros - mais compacta */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-2">
<div className="space-y-1">
<label className="text-xs font-medium">Buscar</label>
<div className="relative">
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<Input
placeholder="OF, Marca..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-7 h-8 text-xs"
/>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-medium">OF</label>
<Select value={filterOF || 'all'} onValueChange={handleOFChange}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas as OFs</SelectItem>
{uniqueOFs.map((of) => (
<SelectItem key={of} value={of}>{of}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<label className="text-xs font-medium">Fase</label>
<Select value={filterFase || 'all'} onValueChange={handleFaseChange}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todas as fases</SelectItem>
{uniqueFases.map((fase) => (
<SelectItem key={fase} value={fase}>{fase}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<label className="text-xs font-medium">Processo</label>
<Select value={filterProcesso || 'all'} onValueChange={handleProcessoChange}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Selecione" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Todos os processos</SelectItem>
{uniqueProcessos.map((processo) => (
<SelectItem key={processo} value={processo}>{processo}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end">
<Button variant="outline" onClick={clearFilters} className="h-8 text-xs">
<RefreshCw className="h-3 w-3 mr-1" />
Limpar
</Button>
</div>
</div>
{/* Segunda linha - filtros de data */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
<div className="space-y-1">
<label className="text-xs font-medium">Data Início</label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"h-8 justify-start text-left font-normal text-xs",
!dataInicio && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-1 h-3 w-3" />
{dataInicio ? format(dataInicio, "dd/MM/yyyy") : "Selecionar"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={dataInicio}
onSelect={setDataInicio}
initialFocus
className="pointer-events-auto"
/>
</PopoverContent>
</Popover>
</div>
<div className="space-y-1">
<label className="text-xs font-medium">Data Fim</label>
<Popover>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
"h-8 justify-start text-left font-normal text-xs",
!dataFim && "text-muted-foreground"
)}
>
<CalendarIcon className="mr-1 h-3 w-3" />
{dataFim ? format(dataFim, "dd/MM/yyyy") : "Selecionar"}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<Calendar
mode="single"
selected={dataFim}
onSelect={setDataFim}
initialFocus
className="pointer-events-auto"
/>
</PopoverContent>
</Popover>
</div>
</div>
<div className="flex justify-between items-center">
<div className="text-xs text-muted-foreground">
{filterOF || filterFase || filterProcesso || dataInicio || dataFim ?
'Filtros aplicados automaticamente baseados no último uso' :
'Use os filtros para otimizar a visualização'
}
</div>
</div>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,106 @@
import React from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
import { Package, Wrench, Undo2 } from 'lucide-react';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { ApontamentoProducao } from '@/hooks/useApontamentosProducao';
interface ApontamentosListItemProps {
apontamento: ApontamentoProducao;
onRevert: (id: string) => void;
}
export const ApontamentosListItem: React.FC<ApontamentosListItemProps> = ({
apontamento,
onRevert
}) => {
const formatDate = (dateString: string) => {
try {
const date = new Date(dateString + 'T00:00:00');
return format(date, 'dd/MM/yyyy', { locale: ptBR });
} catch (error) {
console.error('Erro ao formatar data:', error);
return dateString;
}
};
const getMarcaItem = (apontamento: ApontamentoProducao) => {
if (apontamento.tipo_apontamento === 'componente') {
return apontamento.componente?.marca_componente || 'N/A';
} else {
return apontamento.peca?.marca || 'N/A';
}
};
return (
<div className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-3">
{apontamento.tipo_apontamento === 'componente' ? (
<Wrench className="h-4 w-4 text-blue-500" />
) : (
<Package className="h-4 w-4 text-green-500" />
)}
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium">
{getMarcaItem(apontamento)}
</span>
<Badge variant="outline">
{apontamento.of_number || 'N/A'}
</Badge>
</div>
<div className="text-sm text-muted-foreground">
{apontamento.processo?.nome || 'N/A'} {formatDate(apontamento.data_apontamento)}
</div>
</div>
</div>
<div className="flex items-center gap-3">
<div className="text-right">
<div className="font-medium">
{apontamento.quantidade_produzida || 0} un.
</div>
<div className="text-xs text-muted-foreground">
{format(new Date(apontamento.created_at), 'HH:mm', { locale: ptBR })}
</div>
</div>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="outline"
size="sm"
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
>
<Undo2 className="h-3 w-3" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Reverter Apontamento</AlertDialogTitle>
<AlertDialogDescription>
Tem certeza de que deseja reverter este apontamento?
Esta ação não pode ser desfeita e irá remover permanentemente o registro de:
<br />
<strong>{getMarcaItem(apontamento)}</strong> - {apontamento.quantidade_produzida} unidades
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={() => onRevert(apontamento.id)}
className="bg-orange-600 hover:bg-orange-700"
>
Confirmar Reversão
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
);
};

View File

@@ -0,0 +1,54 @@
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Calendar as CalendarIcon } from 'lucide-react';
import { ApontamentosListItem } from './ApontamentosListItem';
import { ApontamentoProducao } from '@/hooks/useApontamentosProducao';
interface ApontamentosResultsListProps {
filteredApontamentos: ApontamentoProducao[];
onRevert: (id: string) => void;
}
export const ApontamentosResultsList: React.FC<ApontamentosResultsListProps> = ({
filteredApontamentos,
onRevert
}) => {
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CalendarIcon className="h-5 w-5" />
Histórico de Apontamentos
</div>
<Badge variant="secondary">
{filteredApontamentos.length} {filteredApontamentos.length === 1 ? 'registro' : 'registros'}
</Badge>
</CardTitle>
</CardHeader>
<CardContent>
{filteredApontamentos.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<CalendarIcon className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p>Nenhum apontamento encontrado</p>
<p className="text-sm">
Tente ajustar os filtros para ver mais resultados
</p>
</div>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
{filteredApontamentos.map((apontamento) => (
<ApontamentosListItem
key={apontamento.id}
apontamento={apontamento}
onRevert={onRevert}
/>
))}
</div>
)}
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,273 @@
import { useState, useMemo, useEffect } from 'react';
import { ApontamentoProducao } from '@/hooks/useApontamentosProducao';
// Cache para lembrar os últimos filtros utilizados
const historicCache = {
lastOF: '',
lastFase: '',
lastProcesso: ''
};
export const useApontamentosFilters = (apontamentos: ApontamentoProducao[]) => {
const [searchTerm, setSearchTerm] = useState('');
const [filterOF, setFilterOF] = useState('');
const [filterFase, setFilterFase] = useState('');
const [filterProcesso, setFilterProcesso] = useState('');
const [dataInicio, setDataInicio] = useState<Date | undefined>();
const [dataFim, setDataFim] = useState<Date | undefined>();
const [initialized, setInitialized] = useState(false);
// Logs detalhados para debug
useEffect(() => {
console.log(`🔍 DADOS RECEBIDOS NO FILTRO: ${apontamentos.length} apontamentos`);
if (apontamentos.length > 0) {
// Verificar OF B118 especificamente
const b118Records = apontamentos.filter(apt => apt.of_number === 'B118');
console.log(`🎯 OF B118 no filtro: ${b118Records.length} registros`);
// Mostrar alguns exemplos
if (b118Records.length > 0) {
console.log('🔍 Primeiros 5 registros B118:',
b118Records.slice(0, 5).map(apt => ({
id: apt.id,
of: apt.of_number,
peca: apt.peca?.marca,
processo: apt.processo?.nome,
data: apt.data_apontamento
}))
);
}
}
}, [apontamentos]);
// Carregar cache inicial apenas uma vez
useEffect(() => {
if (!initialized) {
console.log(`📊 Inicializando filtros com ${apontamentos.length} apontamentos totais`);
const savedCache = localStorage.getItem('historico_apontamentos_cache');
if (savedCache) {
try {
const parsed = JSON.parse(savedCache);
Object.assign(historicCache, parsed);
console.log('📋 Cache carregado:', historicCache);
} catch (error) {
console.log('⚠️ Erro ao carregar cache do histórico:', error);
}
}
// NÃO aplicar filtros do cache automaticamente - deixar limpo por padrão
setInitialized(true);
}
}, [apontamentos, initialized]);
// Salvar cache quando filtros mudam
const updateCache = (updates: Partial<typeof historicCache>) => {
Object.assign(historicCache, updates);
localStorage.setItem('historico_apontamentos_cache', JSON.stringify(historicCache));
};
// Obter valores únicos para filtros - SEMPRE todos os valores disponíveis
const uniqueOFs = useMemo(() => {
const ofs = apontamentos
.map(apt => apt.of_number)
.filter((of): of is string => {
return typeof of === 'string' && of.trim() !== '';
})
.filter((of, index, array) => array.indexOf(of) === index)
.sort();
console.log(`📋 OFs únicas encontradas: ${ofs.length}`, ofs.slice(0, 10), '...');
return ofs;
}, [apontamentos]);
const uniqueFases = useMemo(() => {
// Se não há OF selecionada, mostrar todas as fases
const apontamentosFiltrados = filterOF && filterOF !== ''
? apontamentos.filter(apt => apt.of_number === filterOF)
: apontamentos;
const fases = apontamentosFiltrados
.map(apt => apt.peca?.etapa_fase)
.filter((fase): fase is string => {
return typeof fase === 'string' && fase.trim() !== '';
})
.filter((fase, index, array) => array.indexOf(fase) === index)
.sort();
console.log(`📋 Fases únicas encontradas para OF "${filterOF}": ${fases.length}`, fases);
return fases;
}, [apontamentos, filterOF]);
const uniqueProcessos = useMemo(() => {
// Aplicar filtros hierárquicos apenas se existirem
let apontamentosFiltrados = apontamentos;
if (filterOF && filterOF !== '') {
apontamentosFiltrados = apontamentosFiltrados.filter(apt => apt.of_number === filterOF);
}
if (filterFase && filterFase !== '') {
apontamentosFiltrados = apontamentosFiltrados.filter(apt => apt.peca?.etapa_fase === filterFase);
}
const processos = apontamentosFiltrados
.map(apt => apt.processo?.nome)
.filter((processo): processo is string => {
return typeof processo === 'string' && processo.trim() !== '';
})
.filter((processo, index, array) => array.indexOf(processo) === index)
.sort();
console.log(`📋 Processos únicos encontrados para OF "${filterOF}" e Fase "${filterFase}": ${processos.length}`, processos);
return processos;
}, [apontamentos, filterOF, filterFase]);
// Filtrar apontamentos - LÓGICA SIMPLIFICADA E CORRIGIDA
const filteredApontamentos = useMemo(() => {
if (!initialized) {
console.log('⏳ Aguardando inicialização...');
return [];
}
console.log(`🔍 INICIANDO FILTRAGEM DE ${apontamentos.length} APONTAMENTOS`);
console.log('🔍 Filtros ativos:', {
searchTerm: searchTerm || 'VAZIO',
filterOF: filterOF || 'VAZIO',
filterFase: filterFase || 'VAZIO',
filterProcesso: filterProcesso || 'VAZIO',
dataInicio: dataInicio ? 'SIM' : 'NÃO',
dataFim: dataFim ? 'SIM' : 'NÃO'
});
let resultado = [...apontamentos]; // Começar com TODOS os apontamentos
// Aplicar filtro de busca textual APENAS se preenchido
if (searchTerm && searchTerm.trim() !== '') {
const searchLower = searchTerm.toLowerCase();
const beforeSearch = resultado.length;
resultado = resultado.filter(apt =>
(apt.of_number && apt.of_number.toLowerCase().includes(searchLower)) ||
(apt.peca?.marca && apt.peca.marca.toLowerCase().includes(searchLower)) ||
(apt.componente?.marca_componente && apt.componente.marca_componente.toLowerCase().includes(searchLower))
);
console.log(`🔎 Filtro texto "${searchTerm}": ${beforeSearch}${resultado.length} registros`);
}
// Aplicar filtro por OF APENAS se selecionado
if (filterOF && filterOF !== '' && filterOF !== 'all') {
const beforeOF = resultado.length;
resultado = resultado.filter(apt => apt.of_number === filterOF);
console.log(`🏷️ Filtro OF "${filterOF}": ${beforeOF}${resultado.length} registros`);
}
// Aplicar filtro por Fase APENAS se selecionado
if (filterFase && filterFase !== '' && filterFase !== 'all') {
const beforeFase = resultado.length;
resultado = resultado.filter(apt => apt.peca?.etapa_fase === filterFase);
console.log(`📋 Filtro Fase "${filterFase}": ${beforeFase}${resultado.length} registros`);
}
// Aplicar filtro por Processo APENAS se selecionado
if (filterProcesso && filterProcesso !== '' && filterProcesso !== 'all') {
const beforeProcesso = resultado.length;
resultado = resultado.filter(apt => apt.processo?.nome === filterProcesso);
console.log(`⚙️ Filtro Processo "${filterProcesso}": ${beforeProcesso}${resultado.length} registros`);
}
// Aplicar filtros de data APENAS se definidos
if (dataInicio || dataFim) {
const beforeData = resultado.length;
resultado = resultado.filter(apt => {
const apontamentoDate = new Date(apt.data_apontamento);
let matchesDataInicio = true;
let matchesDataFim = true;
if (dataInicio) {
matchesDataInicio = apontamentoDate >= dataInicio;
}
if (dataFim) {
matchesDataFim = apontamentoDate <= dataFim;
}
return matchesDataInicio && matchesDataFim;
});
console.log(`📅 Filtro Data: ${beforeData}${resultado.length} registros`);
}
console.log(`✅ RESULTADO FINAL DA FILTRAGEM: ${resultado.length} de ${apontamentos.length} apontamentos`);
// Log específico para B118 se estiver sendo filtrado
if (filterOF === 'B118') {
console.log(`🎯 Resultado B118: ${resultado.length} registros filtrados`);
}
return resultado;
}, [apontamentos, searchTerm, filterOF, filterFase, filterProcesso, dataInicio, dataFim, initialized]);
const clearFilters = () => {
console.log('🧹 Limpando todos os filtros');
setSearchTerm('');
setFilterOF('');
setFilterFase('');
setFilterProcesso('');
setDataInicio(undefined);
setDataFim(undefined);
// Limpar cache
Object.assign(historicCache, { lastOF: '', lastFase: '', lastProcesso: '' });
localStorage.removeItem('historico_apontamentos_cache');
};
const handleOFChange = (value: string) => {
const selectedValue = value === 'all' ? '' : value;
console.log('🔄 Mudando OF para:', selectedValue);
setFilterOF(selectedValue);
// Limpar fase e processo quando OF muda
setFilterFase('');
setFilterProcesso('');
updateCache({ lastOF: selectedValue, lastFase: '', lastProcesso: '' });
};
const handleFaseChange = (value: string) => {
const selectedValue = value === 'all' ? '' : value;
console.log('🔄 Mudando Fase para:', selectedValue);
setFilterFase(selectedValue);
// Limpar processo quando fase muda
setFilterProcesso('');
updateCache({ lastFase: selectedValue, lastProcesso: '' });
};
const handleProcessoChange = (value: string) => {
const selectedValue = value === 'all' ? '' : value;
console.log('🔄 Mudando Processo para:', selectedValue);
setFilterProcesso(selectedValue);
updateCache({ lastProcesso: selectedValue });
};
return {
searchTerm,
setSearchTerm,
filterOF,
filterFase,
filterProcesso,
dataInicio,
dataFim,
setDataInicio,
setDataFim,
uniqueOFs,
uniqueFases,
uniqueProcessos,
filteredApontamentos,
initialized,
handleOFChange,
handleFaseChange,
handleProcessoChange,
clearFilters
};
};

View File

@@ -0,0 +1,215 @@
import React, { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { useAtribuicoes } from '@/hooks/useAtribuicoes';
interface AtribuicaoEditModalProps {
atribuicao: any;
isOpen: boolean;
onClose: () => void;
}
export function AtribuicaoEditModal({ atribuicao, isOpen, onClose }: AtribuicaoEditModalProps) {
const { updateAtribuicao, updateUserAbbrev, canManage } = useAtribuicoes();
const [formData, setFormData] = useState({
user_abbrev: '',
attribution: '',
frequency: '',
method: '',
client: '',
importance: '',
duration: ''
});
useEffect(() => {
if (atribuicao) {
setFormData({
user_abbrev: atribuicao.user_abbrev || '',
attribution: atribuicao.attribution || '',
frequency: atribuicao.frequency || '',
method: atribuicao.method || '',
client: atribuicao.client || '',
importance: atribuicao.importance || '',
duration: atribuicao.duration || ''
});
}
}, [atribuicao]);
const handleSave = async () => {
if (!atribuicao) return;
// Atualizar identificação se mudou
if (formData.user_abbrev !== atribuicao.user_abbrev) {
await updateUserAbbrev(atribuicao.user_id, formData.user_abbrev);
}
// Atualizar atribuição
await updateAtribuicao(atribuicao.id, {
attribution: formData.attribution,
frequency: formData.frequency as any,
method: formData.method as any,
client: formData.client as any,
importance: formData.importance as any,
duration: formData.duration as any
});
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Editar Atribuição</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label>Usuário</Label>
<Input value={atribuicao?.user_name || ''} disabled />
</div>
<div>
<Label>Identificação (3 letras)</Label>
<Input
value={formData.user_abbrev}
onChange={(e) => setFormData({ ...formData, user_abbrev: e.target.value.toUpperCase().substring(0, 3) })}
maxLength={3}
disabled={!canManage}
/>
</div>
</div>
<div>
<Label>Atribuição</Label>
<Textarea
value={formData.attribution}
onChange={(e) => setFormData({ ...formData, attribution: e.target.value })}
maxLength={300}
disabled={!canManage}
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label>Frequência</Label>
<Select
value={formData.frequency}
onValueChange={(value) => setFormData({ ...formData, frequency: value })}
disabled={!canManage}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="horaria">Horária</SelectItem>
<SelectItem value="2xdia">2x ao dia</SelectItem>
<SelectItem value="diaria">Diária</SelectItem>
<SelectItem value="2xsemanal">2x semanal</SelectItem>
<SelectItem value="semanal">Semanal</SelectItem>
<SelectItem value="quinzenal">Quinzenal</SelectItem>
<SelectItem value="mensal">Mensal</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Método</Label>
<Select
value={formData.method}
onValueChange={(value) => setFormData({ ...formData, method: value })}
disabled={!canManage}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="impresso">Impresso</SelectItem>
<SelectItem value="sistema">Sistema</SelectItem>
<SelectItem value="sistema-impresso">Sistema/Impresso</SelectItem>
<SelectItem value="email">E-mail</SelectItem>
<SelectItem value="verbal">Verbal</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Cliente</Label>
<Select
value={formData.client}
onValueChange={(value) => setFormData({ ...formData, client: value })}
disabled={!canManage}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="interno">Interno</SelectItem>
<SelectItem value="processo">Processo</SelectItem>
<SelectItem value="obra">Obra</SelectItem>
<SelectItem value="contrato">Contrato</SelectItem>
<SelectItem value="geral">Geral</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Grau de Importância</Label>
<Select
value={formData.importance}
onValueChange={(value) => setFormData({ ...formData, importance: value })}
disabled={!canManage}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="essencial">Essencial</SelectItem>
<SelectItem value="estrategico">Estratégico</SelectItem>
<SelectItem value="suporte">Suporte</SelectItem>
<SelectItem value="informativo">Informativo</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Duração</Label>
<Select
value={formData.duration}
onValueChange={(value) => setFormData({ ...formData, duration: value })}
disabled={!canManage}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="<=1 hora"> 1 hora</SelectItem>
<SelectItem value="2 horas">2 horas</SelectItem>
<SelectItem value="4 horas">4 horas</SelectItem>
<SelectItem value="8 horas">8 horas</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="flex gap-4 justify-end pt-4">
<Button variant="outline" onClick={onClose}>
Cancelar
</Button>
{canManage && (
<Button onClick={handleSave}>
Salvar Alterações
</Button>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,322 @@
import React, { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useAtribuicoes } from '@/hooks/useAtribuicoes';
import { useMobileResponsive } from '@/hooks/useMobileResponsive';
import { Trash2 } from 'lucide-react';
interface AtribuicaoTemp {
attribution: string;
frequency: string;
method: string;
client: string;
importance: string;
duration: string;
}
export function AtribuicoesForm() {
const { users, createAtribuicao, canManage } = useAtribuicoes();
const { isMobile } = useMobileResponsive();
const [selectedUserId, setSelectedUserId] = useState('');
const [userAbbrev, setUserAbbrev] = useState('');
const [atribuicoes, setAtribuicoes] = useState<AtribuicaoTemp[]>([]);
// Form state
const [formData, setFormData] = useState({
attribution: '',
frequency: '',
method: '',
client: '',
importance: '',
duration: ''
});
const selectedUser = users.find(u => u.id === selectedUserId);
// Gerar abreviação automática quando usuário é selecionado
useEffect(() => {
if (selectedUser && !userAbbrev) {
const names = selectedUser.full_name?.split(' ') || [];
let abbrev = '';
if (names.length >= 2) {
abbrev = names[0].charAt(0) + names[1].charAt(0) + names[0].charAt(1);
} else if (names.length === 1) {
abbrev = names[0].substring(0, 3);
}
setUserAbbrev(abbrev.toUpperCase());
}
}, [selectedUser, userAbbrev]);
const handleAddAtribuicao = () => {
if (!formData.attribution || !formData.frequency || !formData.method ||
!formData.client || !formData.importance || !formData.duration) {
return;
}
setAtribuicoes([...atribuicoes, { ...formData }]);
setFormData({
attribution: '',
frequency: '',
method: '',
client: '',
importance: '',
duration: ''
});
};
const handleRemoveAtribuicao = (index: number) => {
setAtribuicoes(atribuicoes.filter((_, i) => i !== index));
};
const handleSaveAll = async () => {
if (!selectedUserId || !userAbbrev || atribuicoes.length === 0) {
return;
}
for (const attr of atribuicoes) {
await createAtribuicao({
user_id: selectedUserId,
user_abbrev: userAbbrev,
attribution: attr.attribution,
frequency: attr.frequency as any,
method: attr.method as any,
client: attr.client as any,
importance: attr.importance as any,
duration: attr.duration as any
});
}
// Reset form
setSelectedUserId('');
setUserAbbrev('');
setAtribuicoes([]);
};
if (!canManage) {
return null;
}
return (
<div className="space-y-6">
{/* Formulário de Cadastro */}
<Card>
<CardHeader>
<CardTitle>Adicionar Atribuição</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Seleção de Usuário */}
<div className="space-y-4">
<div>
<Label>Selecionar Usuário</Label>
<Select value={selectedUserId} onValueChange={(value) => {
setSelectedUserId(value);
setUserAbbrev('');
setAtribuicoes([]);
}}>
<SelectTrigger>
<SelectValue placeholder="-- Selecione um usuário --" />
</SelectTrigger>
<SelectContent>
{users.map((user) => (
<SelectItem key={user.id} value={user.id}>
<div className="flex items-center gap-2">
<Avatar className="h-6 w-6">
<AvatarImage src={user.profile_image_url} />
<AvatarFallback className="text-xs">
{user.full_name?.charAt(0) || 'U'}
</AvatarFallback>
</Avatar>
<span>{user.full_name} ({user.email})</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedUser && (
<div>
<Label>Identificação (3 letras)</Label>
<Input
value={userAbbrev}
onChange={(e) => setUserAbbrev(e.target.value.toUpperCase().substring(0, 3))}
placeholder="Ex: JOS"
maxLength={3}
/>
</div>
)}
</div>
{selectedUserId && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="col-span-full">
<Label>Atribuição</Label>
<Textarea
value={formData.attribution}
onChange={(e) => setFormData({ ...formData, attribution: e.target.value })}
placeholder="Descrição da atribuição..."
maxLength={300}
/>
</div>
<div>
<Label>Frequência</Label>
<Select value={formData.frequency} onValueChange={(value) => setFormData({ ...formData, frequency: value })}>
<SelectTrigger>
<SelectValue placeholder="Selecione..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="horaria">Horária</SelectItem>
<SelectItem value="2xdia">2x ao dia</SelectItem>
<SelectItem value="diaria">Diária</SelectItem>
<SelectItem value="2xsemanal">2x semanal</SelectItem>
<SelectItem value="semanal">Semanal</SelectItem>
<SelectItem value="quinzenal">Quinzenal</SelectItem>
<SelectItem value="mensal">Mensal</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Método</Label>
<Select value={formData.method} onValueChange={(value) => setFormData({ ...formData, method: value })}>
<SelectTrigger>
<SelectValue placeholder="Selecione..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="impresso">Impresso</SelectItem>
<SelectItem value="sistema">Sistema</SelectItem>
<SelectItem value="sistema-impresso">Sistema/Impresso</SelectItem>
<SelectItem value="email">E-mail</SelectItem>
<SelectItem value="verbal">Verbal</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Cliente</Label>
<Select value={formData.client} onValueChange={(value) => setFormData({ ...formData, client: value })}>
<SelectTrigger>
<SelectValue placeholder="Selecione..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="interno">Interno</SelectItem>
<SelectItem value="processo">Processo</SelectItem>
<SelectItem value="obra">Obra</SelectItem>
<SelectItem value="contrato">Contrato</SelectItem>
<SelectItem value="geral">Geral</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Grau de Importância</Label>
<Select value={formData.importance} onValueChange={(value) => setFormData({ ...formData, importance: value })}>
<SelectTrigger>
<SelectValue placeholder="Selecione..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="essencial">Essencial</SelectItem>
<SelectItem value="estrategico">Estratégico</SelectItem>
<SelectItem value="suporte">Suporte</SelectItem>
<SelectItem value="informativo">Informativo</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>Duração</Label>
<Select value={formData.duration} onValueChange={(value) => setFormData({ ...formData, duration: value })}>
<SelectTrigger>
<SelectValue placeholder="Selecione..." />
</SelectTrigger>
<SelectContent>
<SelectItem value="<=1 hora"> 1 hora</SelectItem>
<SelectItem value="2 horas">2 horas</SelectItem>
<SelectItem value="4 horas">4 horas</SelectItem>
<SelectItem value="8 horas">8 horas</SelectItem>
</SelectContent>
</Select>
</div>
</div>
)}
{selectedUserId && (
<div className="flex justify-end">
<Button onClick={handleAddAtribuicao} disabled={!formData.attribution || !formData.frequency}>
Adicionar Atribuição
</Button>
</div>
)}
</CardContent>
</Card>
{/* Lista de Atribuições Temporárias */}
{atribuicoes.length > 0 && (
<Card>
<CardHeader>
<div className="flex justify-between items-center">
<CardTitle>
Atribuições para {selectedUser?.full_name}
</CardTitle>
<Button onClick={handleSaveAll} variant="default">
Salvar Todas Atribuições
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{atribuicoes.map((attr, index) => (
<div key={index} className="flex justify-between items-start p-4 border rounded-lg">
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-2 text-sm">
<div>
<strong>Atribuição:</strong>
<p className="break-words">{attr.attribution}</p>
</div>
<div>
<strong>Frequência:</strong>
<p>{attr.frequency}</p>
</div>
<div>
<strong>Método:</strong>
<p>{attr.method}</p>
</div>
<div>
<strong>Cliente:</strong>
<p>{attr.client}</p>
</div>
<div>
<strong>Importância:</strong>
<p>{attr.importance}</p>
</div>
<div>
<strong>Duração:</strong>
<p>{attr.duration}</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveAtribuicao(index)}
className="text-red-600 hover:text-red-700 ml-2"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,195 @@
import React, { useState } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Printer } from 'lucide-react';
import { Atribuicao } from '@/hooks/useAtribuicoes';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
interface AtribuicoesPrintModalProps {
isOpen: boolean;
onClose: () => void;
atribuicoes: Atribuicao[];
}
export function AtribuicoesPrintModal({ isOpen, onClose, atribuicoes }: AtribuicoesPrintModalProps) {
const [selectedUserId, setSelectedUserId] = useState<string>('');
// Obter lista única de usuários
const uniqueUsers = Array.from(
new Map(atribuicoes.map(attr => [attr.user_id, { id: attr.user_id, name: attr.user_name }]))
.values()
);
const handlePrint = () => {
if (!selectedUserId) return;
// Filtrar atribuições do usuário selecionado
const userAtribuicoes = atribuicoes.filter(attr => attr.user_id === selectedUserId);
const selectedUser = uniqueUsers.find(user => user.id === selectedUserId);
if (!selectedUser || userAtribuicoes.length === 0) return;
const getImportanceBadgeStyle = (importancia: string) => {
switch (importancia) {
case 'essencial':
return 'bg-red-100 text-red-800';
case 'estrategico':
return 'bg-blue-100 text-blue-800';
case 'suporte':
return 'bg-gray-100 text-gray-800';
case 'informativo':
return 'bg-green-100 text-green-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
const printContent = `
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Relatório de Atribuições</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
body {
font-family: 'Inter', sans-serif;
}
@media print {
body {
-webkit-print-color-adjust: exact;
color-adjust: exact;
}
.print-container {
box-shadow: none;
margin: 0;
max-width: 100%;
border: 1px solid #ddd;
}
.no-print {
display: none;
}
}
</style>
</head>
<body class="bg-gray-100 p-4 sm:p-6">
<div class="print-container max-w-6xl mx-auto bg-white rounded-xl shadow-lg overflow-hidden">
<header class="bg-gray-100 text-gray-800 p-4 md:p-5 border-b border-gray-200">
<h1 class="text-xl md:text-2xl font-bold">Relatório de Atribuições de ${selectedUser.name}</h1>
<p class="text-gray-600 text-sm">Lista de atribuições para o usuário selecionado.</p>
</header>
<main class="p-4 md:p-6">
<div class="overflow-x-auto rounded-lg border border-gray-200">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">Atribuição</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">Frequência</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">Método</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">Cliente</th>
<th scope="col" class="px-4 py-3 text-center text-xs font-bold text-gray-600 uppercase tracking-wider">Importância</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">Duração</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
${userAtribuicoes.map((attr, index) => `
<tr${index % 2 === 1 ? ' class="hover:bg-gray-50"' : ''}>
<td class="px-4 py-3 align-top text-gray-700" style="white-space: normal;">${attr.attribution}</td>
<td class="px-4 py-3 align-top whitespace-nowrap text-gray-700">${attr.frequency}</td>
<td class="px-4 py-3 align-top whitespace-nowrap text-gray-700">${attr.method}</td>
<td class="px-4 py-3 align-top whitespace-nowrap text-gray-700">${attr.client}</td>
<td class="px-4 py-3 align-top whitespace-nowrap text-center">
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getImportanceBadgeStyle(attr.importance)}">
${attr.importance}
</span>
</td>
<td class="px-4 py-3 align-top whitespace-nowrap text-gray-700">${attr.duration}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</main>
<footer class="text-center text-xs text-gray-400 p-3 bg-gray-50 border-t">
<p>Relatório gerado em: ${format(new Date(), 'dd/MM/yyyy', { locale: ptBR })}</p>
</footer>
</div>
</body>
</html>
`;
const printWindow = window.open('', '_blank');
if (!printWindow) return;
printWindow.document.write(printContent);
printWindow.document.close();
printWindow.onload = () => {
setTimeout(() => {
printWindow.print();
printWindow.close();
}, 500);
};
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Printer className="h-5 w-5" />
Imprimir Atribuições
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Selecionar Usuário:</label>
<Select value={selectedUserId} onValueChange={setSelectedUserId}>
<SelectTrigger>
<SelectValue placeholder="Escolha um usuário..." />
</SelectTrigger>
<SelectContent>
{uniqueUsers.map((user) => (
<SelectItem key={user.id} value={user.id}>
{user.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button variant="outline" onClick={onClose}>
Cancelar
</Button>
<Button
onClick={handlePrint}
disabled={!selectedUserId}
className="flex items-center gap-2"
>
<Printer className="h-4 w-4" />
Imprimir
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,323 @@
import React, { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { ResponsiveTable } from '@/components/responsive/ResponsiveTable';
import { TableCell } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { useAtribuicoes } from '@/hooks/useAtribuicoes';
import { useMobileResponsive } from '@/hooks/useMobileResponsive';
import { Edit, Trash2, Search, AlertCircle, Printer } from 'lucide-react';
import { AtribuicaoEditModal } from './AtribuicaoEditModal';
import { AtribuicoesPrintModal } from './AtribuicoesPrintModal';
export function AtribuicoesTable() {
const { atribuicoes, canManage, deleteAtribuicao, loading } = useAtribuicoes();
const { isMobile } = useMobileResponsive();
const [searchTerm, setSearchTerm] = useState('');
const [editingAtribuicao, setEditingAtribuicao] = useState<any>(null);
const [showPrintModal, setShowPrintModal] = useState(false);
const filteredAtribuicoes = atribuicoes.filter(attr =>
attr.user_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
attr.attribution.toLowerCase().includes(searchTerm.toLowerCase()) ||
attr.user_abbrev.toLowerCase().includes(searchTerm.toLowerCase())
);
console.log('📊 AtribuicoesTable render:', {
atribuicoesTotal: atribuicoes.length,
filteredCount: filteredAtribuicoes.length,
loading,
searchTerm
});
const getImportanceBadgeColor = (importancia: string) => {
switch (importancia) {
case 'essencial': return 'destructive';
case 'estrategico': return 'default';
case 'suporte': return 'secondary';
case 'informativo': return 'outline';
default: return 'outline';
}
};
const headers = [
'Usuário',
'ID',
'Atribuição',
'Frequência',
'Método',
'Cliente',
'Importância',
'Duração',
...(canManage ? ['Ações'] : [])
];
const renderRow = (attr: any, index: number) => (
<>
{/* Coluna Usuário - 18% da largura */}
<TableCell className="w-[18%] min-w-[180px] p-3">
<div className="flex items-center gap-3">
<Avatar className="h-10 w-10 flex-shrink-0">
<AvatarImage src={attr.user_photo} />
<AvatarFallback className="text-sm font-medium">
{attr.user_name.charAt(0)}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<p className="font-medium text-sm truncate">{attr.user_name}</p>
<p className="text-xs text-muted-foreground truncate">{attr.user_email}</p>
</div>
</div>
</TableCell>
{/* Coluna ID - 4% da largura */}
<TableCell className="w-[4%] min-w-[50px] p-3">
<Badge variant="outline" className="text-xs font-medium">{attr.user_abbrev}</Badge>
</TableCell>
{/* Coluna Atribuição - 35% da largura com fonte menor */}
<TableCell className="w-[35%] min-w-[350px] p-3">
<div className="assignment-cell text-xs leading-relaxed max-h-[4.5rem] overflow-hidden">
<p className="break-words whitespace-normal line-clamp-4" title={attr.attribution}>
{attr.attribution}
</p>
</div>
</TableCell>
{/* Coluna Frequência - 7% da largura, centralizada */}
<TableCell className="w-[7%] min-w-[70px] p-3 text-center">
<span className="text-sm text-muted-foreground">{attr.frequency}</span>
</TableCell>
{/* Coluna Método - 7% da largura */}
<TableCell className="w-[7%] min-w-[70px] p-3">
<span className="text-sm text-muted-foreground">{attr.method}</span>
</TableCell>
{/* Coluna Cliente - 7% da largura */}
<TableCell className="w-[7%] min-w-[70px] p-3">
<span className="text-sm text-muted-foreground">{attr.client}</span>
</TableCell>
{/* Coluna Importância - 9% da largura, centralizada */}
<TableCell className="w-[9%] min-w-[90px] p-3 text-center">
<Badge
variant={getImportanceBadgeColor(attr.importance)}
className="importance-tag text-xs px-2 py-1 rounded-full whitespace-nowrap"
>
{attr.importance}
</Badge>
</TableCell>
{/* Coluna Duração - 7% da largura, centralizada */}
<TableCell className="w-[7%] min-w-[70px] p-3 text-center">
<span className="text-sm text-muted-foreground">{attr.duration}</span>
</TableCell>
{/* Coluna Ações - 6% da largura, centralizada */}
{canManage && (
<TableCell className="w-[6%] min-w-[80px] p-3 text-center">
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => setEditingAtribuicao(attr)}
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground transition-colors"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Tem certeza que deseja excluir esta atribuição?')) {
deleteAtribuicao(attr.id);
}
}}
className="h-8 w-8 p-0 text-muted-foreground hover:text-red-500 transition-colors"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
)}
</>
);
const renderMobileCard = (attr: any) => (
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Avatar className="h-8 w-8">
<AvatarImage src={attr.user_photo} />
<AvatarFallback className="text-xs">
{attr.user_name.charAt(0)}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium text-sm">{attr.user_name}</p>
<Badge variant="outline" className="text-xs">{attr.user_abbrev}</Badge>
</div>
</div>
<Badge variant={getImportanceBadgeColor(attr.importance)}>
{attr.importance}
</Badge>
</div>
<div>
<p className="text-sm font-medium">Atribuição:</p>
<p className="text-sm text-muted-foreground">{attr.attribution}</p>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="font-medium">Frequência:</span> {attr.frequency}
</div>
<div>
<span className="font-medium">Método:</span> {attr.method}
</div>
<div>
<span className="font-medium">Cliente:</span> {attr.client}
</div>
<div>
<span className="font-medium">Duração:</span> {attr.duration}
</div>
</div>
{canManage && (
<div className="flex gap-2 pt-2 border-t">
<Button
variant="ghost"
size="sm"
onClick={() => setEditingAtribuicao(attr)}
className="flex-1"
>
<Edit className="h-4 w-4 mr-1" />
Editar
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm('Tem certeza que deseja excluir esta atribuição?')) {
deleteAtribuicao(attr.id);
}
}}
className="flex-1 text-red-600 hover:text-red-700"
>
<Trash2 className="h-4 w-4 mr-1" />
Excluir
</Button>
</div>
)}
</div>
);
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle>
{canManage ? 'Todas as Atribuições' : 'Minhas Atribuições'}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center p-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
<span className="ml-2">Carregando atribuições...</span>
</div>
</CardContent>
</Card>
);
}
return (
<>
<Card>
<CardHeader>
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<CardTitle>
{canManage ? 'Todas as Atribuições' : 'Minhas Atribuições'}
{atribuicoes.length > 0 && (
<span className="text-sm font-normal text-muted-foreground ml-2">
({atribuicoes.length} {atribuicoes.length === 1 ? 'atribuição' : 'atribuições'})
</span>
)}
</CardTitle>
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
{atribuicoes.length > 0 && (
<>
<Button
onClick={() => setShowPrintModal(true)}
variant="outline"
className="flex items-center gap-2"
>
<Printer className="h-4 w-4" />
Imprimir
</Button>
<div className="relative w-full sm:w-auto">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Buscar por usuário, atribuição ou ID..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 w-full sm:w-80"
/>
</div>
</>
)}
</div>
</div>
</CardHeader>
<CardContent>
{atribuicoes.length === 0 ? (
<div className="flex flex-col items-center justify-center p-8 text-center">
<AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">Nenhuma atribuição encontrada</h3>
<p className="text-muted-foreground mb-4">
Ainda não atribuições cadastradas no sistema.
</p>
{canManage && (
<p className="text-sm text-muted-foreground">
Use a aba "Cadastro" para criar a primeira atribuição.
</p>
)}
</div>
) : (
<div className="overflow-x-auto">
<ResponsiveTable
headers={headers}
data={filteredAtribuicoes}
renderRow={renderRow}
renderMobileCard={renderMobileCard}
emptyMessage={
filteredAtribuicoes.length === 0 && searchTerm
? `Nenhuma atribuição encontrada para "${searchTerm}"`
: "Nenhuma atribuição encontrada"
}
loading={false}
/>
</div>
)}
</CardContent>
</Card>
{editingAtribuicao && (
<AtribuicaoEditModal
atribuicao={editingAtribuicao}
isOpen={!!editingAtribuicao}
onClose={() => setEditingAtribuicao(null)}
/>
)}
<AtribuicoesPrintModal
isOpen={showPrintModal}
onClose={() => setShowPrintModal(false)}
atribuicoes={filteredAtribuicoes}
/>
</>
);
}

View File

@@ -0,0 +1,283 @@
import React from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { AlertTriangle, Calendar, Hash, TrendingUp, ExternalLink } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
interface InconsistenciaDetalhesModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
inconsistencia: any;
}
export const InconsistenciaDetalhesModal: React.FC<InconsistenciaDetalhesModalProps> = ({
open,
onOpenChange,
inconsistencia
}) => {
const navigate = useNavigate();
if (!inconsistencia) return null;
const handleNavegar = (rota: string) => {
navigate(rota);
onOpenChange(false);
};
const renderDetalhes = () => {
switch (inconsistencia.tipo) {
case 'Processo Pulado':
return (
<div className="space-y-4">
<div>
<h4 className="font-medium mb-2">Processos Pulados:</h4>
<div className="space-y-1">
{inconsistencia.detalhes.processosPulados.map((processo: string, index: number) => (
<Badge key={index} variant="destructive">{processo}</Badge>
))}
</div>
</div>
{inconsistencia.detalhes.apontamentos.length > 0 && (
<div>
<h4 className="font-medium mb-2">Apontamentos Existentes:</h4>
<div className="space-y-2">
{inconsistencia.detalhes.apontamentos.map((apt: any, index: number) => (
<div key={index} className="flex items-center justify-between p-2 bg-muted rounded">
<span>{apt.processo}</span>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="h-3 w-3" />
{new Date(apt.data).toLocaleDateString()}
<Hash className="h-3 w-3" />
{apt.quantidade}
</div>
</div>
))}
</div>
</div>
)}
</div>
);
case 'Quantidade Excedente':
return (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Quantidade Cadastrada</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">
{inconsistencia.detalhes.quantidadeCadastrada}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">Quantidade Apontada</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">
{inconsistencia.detalhes.quantidadeApontada}
</div>
</CardContent>
</Card>
</div>
<div className="p-3 bg-amber-50 border border-amber-200 rounded">
<div className="flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-amber-600" />
<span className="font-medium text-amber-800">
Diferença: +{inconsistencia.detalhes.diferenca} peças
</span>
</div>
<p className="text-sm text-amber-700 mt-1">
Processo: {inconsistencia.detalhes.processo}
</p>
</div>
</div>
);
case 'Expedição sem Processos':
return (
<div className="space-y-4">
<div className="p-3 bg-red-50 border border-red-200 rounded">
<div className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-red-600" />
<span className="font-medium text-red-800">
Peça expedida sem processos de produção
</span>
</div>
</div>
{inconsistencia.detalhes.romaneios.length > 0 && (
<div>
<h4 className="font-medium mb-2">Romaneios de Expedição:</h4>
<div className="space-y-2">
{inconsistencia.detalhes.romaneios.map((romaneio: any, index: number) => (
<div key={index} className="flex items-center justify-between p-2 bg-muted rounded">
<span>Quantidade: {romaneio.quantidade_expedida}</span>
<span className="text-sm text-muted-foreground">
Peso: {romaneio.peso_total}kg
</span>
</div>
))}
</div>
</div>
)}
</div>
);
case 'Múltiplas Prioridades':
return (
<div className="space-y-4">
<div>
<h4 className="font-medium mb-2">Prioridades Encontradas:</h4>
<div className="space-y-1">
{inconsistencia.detalhes.prioridades.map((prioridade: string, index: number) => (
<Badge key={index} variant="outline">{prioridade}</Badge>
))}
</div>
</div>
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded">
<p className="text-sm text-yellow-800">
Recomenda-se manter a peça em apenas uma prioridade para evitar conflitos de programação.
</p>
</div>
</div>
);
default:
return (
<div className="text-center py-4 text-muted-foreground">
Detalhes não disponíveis para este tipo de inconsistência.
</div>
);
}
};
const getAcoesSugeridas = () => {
const acoes: Array<{ label: string; rota: string; descricao: string }> = [];
switch (inconsistencia.tipo) {
case 'Processo Pulado':
acoes.push({
label: 'Ir para Apontamento de Produção',
rota: '/apontamento-producao',
descricao: 'Adicionar apontamentos dos processos faltantes'
});
break;
case 'Quantidade Excedente':
acoes.push({
label: 'Ir para Cadastro de Peças',
rota: '/seletor-of',
descricao: 'Corrigir quantidade no cadastro da peça'
});
acoes.push({
label: 'Ver Histórico de Apontamentos',
rota: '/apontamento-producao',
descricao: 'Verificar apontamentos duplicados'
});
break;
case 'Expedição sem Processos':
acoes.push({
label: 'Ir para Apontamento de Produção',
rota: '/apontamento-producao',
descricao: 'Adicionar apontamentos de produção'
});
break;
case 'Múltiplas Prioridades':
acoes.push({
label: 'Ir para Prioridades de Fabricação',
rota: '/prioridades-fabricacao',
descricao: 'Remover peça das prioridades desnecessárias'
});
break;
}
return acoes;
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-amber-500" />
Detalhes da Inconsistência
</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* Informações Básicas */}
<Card>
<CardHeader>
<CardTitle className="text-base">Informações da Peça</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<div className="grid grid-cols-2 gap-4">
<div>
<span className="text-sm font-medium">Marca:</span>
<p className="font-mono">{inconsistencia.marca}</p>
</div>
<div>
<span className="text-sm font-medium">Tipo:</span>
<Badge variant="secondary">{inconsistencia.tipo}</Badge>
</div>
</div>
<div>
<span className="text-sm font-medium">Descrição:</span>
<p className="text-sm text-muted-foreground">{inconsistencia.descricao}</p>
</div>
</CardContent>
</Card>
{/* Detalhes Específicos */}
<Card>
<CardHeader>
<CardTitle className="text-base">Detalhes</CardTitle>
</CardHeader>
<CardContent>
{renderDetalhes()}
</CardContent>
</Card>
{/* Ações Sugeridas */}
{inconsistencia.acaoSugerida && (
<Card>
<CardHeader>
<CardTitle className="text-base">Ação Sugerida</CardTitle>
<CardDescription>{inconsistencia.acaoSugerida}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{getAcoesSugeridas().map((acao, index) => (
<Button
key={index}
variant="outline"
size="sm"
onClick={() => handleNavegar(acao.rota)}
className="w-full justify-start"
>
<ExternalLink className="h-4 w-4 mr-2" />
{acao.label}
</Button>
))}
</div>
</CardContent>
</Card>
)}
</div>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,95 @@
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Mail } from 'lucide-react';
import { usePasswordReset } from '@/hooks/usePasswordReset';
interface ForgotPasswordModalProps {
isOpen: boolean;
onClose: () => void;
}
export const ForgotPasswordModal = ({ isOpen, onClose }: ForgotPasswordModalProps) => {
const [email, setEmail] = useState('');
const { requestPasswordReset, isRequesting } = usePasswordReset();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!email) {
return;
}
// Validação básica de e-mail
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return;
}
requestPasswordReset({ email });
setEmail('');
onClose();
};
const handleClose = () => {
setEmail('');
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
Esqueci a Senha
</DialogTitle>
<DialogDescription>
Digite seu e-mail cadastrado para receber as instruções de redefinição de senha.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="reset-email">E-mail</Label>
<Input
id="reset-email"
type="email"
placeholder="seu@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
disabled={isRequesting}
/>
</div>
<div className="flex gap-2 justify-end">
<Button
type="button"
variant="outline"
onClick={handleClose}
disabled={isRequesting}
>
Cancelar
</Button>
<Button
type="submit"
disabled={isRequesting || !email}
>
{isRequesting ? 'Enviando...' : 'Enviar E-mail'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,67 @@
import { LogOut } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { useAuth } from '@/hooks/useAuth';
interface LogoutButtonProps {
variant?: 'default' | 'ghost' | 'outline' | 'secondary';
size?: 'default' | 'sm' | 'lg' | 'icon';
showText?: boolean;
className?: string;
}
export function LogoutButton({
variant = 'ghost',
size = 'default',
showText = true,
className = ''
}: LogoutButtonProps) {
const { signOut } = useAuth();
const handleLogout = async () => {
await signOut();
};
return (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant={variant}
size={size}
className={`text-red-500 hover:text-red-600 hover:bg-red-50 ${className}`}
>
<LogOut className="h-4 w-4" />
{showText && size !== 'icon' && <span className="ml-2">Sair do Sistema</span>}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirmar Saída</AlertDialogTitle>
<AlertDialogDescription>
Tem certeza que deseja sair do sistema? Você precisará fazer login novamente para acessar suas informações.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={handleLogout}
className="bg-red-500 hover:bg-red-600 text-white"
>
Sair do Sistema
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,217 @@
import { useState } from 'react';
import { useAuth } from '@/hooks/useAuth';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { toast } from 'sonner';
import { Eye, EyeOff, Lock } from 'lucide-react';
export const PasswordResetForm = () => {
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const { updatePassword } = useAuth();
const navigate = useNavigate();
// Password strength validation
const validatePassword = (password: string) => {
const minLength = password.length >= 8;
const hasUpperCase = /[A-Z]/.test(password);
const hasLowerCase = /[a-z]/.test(password);
const hasNumbers = /\d/.test(password);
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
const score = [minLength, hasUpperCase, hasLowerCase, hasNumbers, hasSpecialChar].filter(Boolean).length;
return {
score,
minLength,
hasUpperCase,
hasLowerCase,
hasNumbers,
hasSpecialChar,
isValid: score >= 4 && minLength
};
};
const passwordStrength = validatePassword(password);
const getPasswordStrengthColor = (score: number) => {
if (score <= 2) return 'bg-red-500';
if (score <= 3) return 'bg-yellow-500';
return 'bg-green-500';
};
const getPasswordStrengthText = (score: number) => {
if (score <= 2) return 'Fraca';
if (score <= 3) return 'Média';
return 'Forte';
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!password || !confirmPassword) {
toast.error('Por favor, preencha todos os campos');
return;
}
if (!passwordStrength.isValid) {
toast.error('A senha deve ter pelo menos 8 caracteres e incluir maiúsculas, minúsculas, números e símbolos');
return;
}
if (password !== confirmPassword) {
toast.error('As senhas não coincidem');
return;
}
setIsLoading(true);
try {
console.log('🔄 Iniciando redefinição de senha...');
const { error } = await updatePassword(password);
if (error) {
console.error('❌ Erro ao redefinir senha:', error);
// Tratamento específico de erros
let errorMessage = 'Erro ao redefinir senha. Tente novamente.';
if (error.message?.includes('session_not_found')) {
errorMessage = 'Sessão expirada. Solicite um novo link de redefinição.';
} else if (error.message?.includes('same_password')) {
errorMessage = 'A nova senha deve ser diferente da anterior.';
} else if (error.message?.includes('password_invalid')) {
errorMessage = 'Senha inválida. Verifique os critérios de segurança.';
} else if (error.message) {
errorMessage = `Erro: ${error.message}`;
}
toast.error(errorMessage);
} else {
console.log('✅ Senha redefinida com sucesso!');
toast.success('Senha redefinida com sucesso! Redirecionando...');
// Aguardar um pouco antes de redirecionar para mostrar a mensagem
setTimeout(() => {
navigate('/');
}, 1500);
}
} catch (error) {
console.error('❌ Erro inesperado:', error);
toast.error('Erro inesperado ao redefinir senha. Tente novamente.');
} finally {
setIsLoading(false);
}
};
return (
<Card className="shadow-xl border-0 bg-background/80 backdrop-blur-sm w-full max-w-md">
<CardHeader className="space-y-1 pb-4">
<div className="flex justify-center mb-4">
<div className="w-12 h-12 bg-gradient-to-r from-blue-500 to-purple-600 rounded-full flex items-center justify-center">
<Lock className="w-6 h-6 text-white" />
</div>
</div>
<CardTitle className="text-2xl text-center">Redefinir Senha</CardTitle>
<CardDescription className="text-center">
Digite sua nova senha abaixo
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="new-password" className="text-sm font-medium">
Nova Senha
</Label>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="new-password"
type={showPassword ? "text" : "password"}
placeholder="Mínimo 8 caracteres"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="pl-10 pr-10 h-12"
disabled={isLoading}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-3 text-muted-foreground hover:text-foreground"
>
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{/* Password Strength Indicator */}
{password && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<div className="flex-1 bg-muted rounded-full h-2">
<div
className={`h-2 rounded-full transition-all duration-300 ${getPasswordStrengthColor(passwordStrength.score)}`}
style={{ width: `${(passwordStrength.score / 5) * 100}%` }}
></div>
</div>
<span className="text-xs text-muted-foreground">
{getPasswordStrengthText(passwordStrength.score)}
</span>
</div>
<div className="text-xs space-y-1">
<div className={`flex items-center gap-1 ${passwordStrength.minLength ? 'text-green-600' : 'text-red-600'}`}>
<span className="w-2 h-2 rounded-full bg-current"></span>
Mínimo 8 caracteres
</div>
<div className={`flex items-center gap-1 ${passwordStrength.hasUpperCase ? 'text-green-600' : 'text-red-600'}`}>
<span className="w-2 h-2 rounded-full bg-current"></span>
Letra maiúscula
</div>
<div className={`flex items-center gap-1 ${passwordStrength.hasNumbers ? 'text-green-600' : 'text-red-600'}`}>
<span className="w-2 h-2 rounded-full bg-current"></span>
Número
</div>
<div className={`flex items-center gap-1 ${passwordStrength.hasSpecialChar ? 'text-green-600' : 'text-red-600'}`}>
<span className="w-2 h-2 rounded-full bg-current"></span>
Símbolo especial
</div>
</div>
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirm-password" className="text-sm font-medium">
Confirmar Nova Senha
</Label>
<div className="relative">
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
id="confirm-password"
type={showPassword ? "text" : "password"}
placeholder="Confirme sua nova senha"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="pl-10 h-12"
disabled={isLoading}
/>
</div>
</div>
<Button
type="submit"
className="w-full h-12 bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-medium text-sm"
disabled={isLoading}
>
{isLoading ? 'Redefinindo...' : 'Redefinir Senha'}
</Button>
</form>
</CardContent>
</Card>
);
};

View File

@@ -0,0 +1,350 @@
import React, { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Badge } from '@/components/ui/badge';
import { X, Sparkles, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Catalogo } from '@/hooks/useCatalogos';
import { FileUploadSection } from './FileUploadSection';
interface CatalogoModalProps {
catalogo?: Catalogo | null;
onSave: (data: any) => Promise<void>;
onClose: () => void;
}
const categorias = [
'Aço',
'Adesivo',
'Tintas',
'Parafusos',
'Soldas',
'Estruturas',
'Materiais'
];
const disciplinas = [
'Engenharia Civil',
'Engenharia Mecânica',
'Arquitetura',
'Construção',
'Fabricação',
'Montagem'
];
export function CatalogoModal({ catalogo, onSave, onClose }: CatalogoModalProps) {
const [formData, setFormData] = useState({
titulo: '',
categoria: '',
disciplina: '',
conteudo: '',
numero_paginas: '',
palavras_chave: [] as string[],
arquivo_urls: [] as string[]
});
const [newKeyword, setNewKeyword] = useState('');
const [isAnalyzing, setIsAnalyzing] = useState(false);
useEffect(() => {
if (catalogo) {
setFormData({
titulo: catalogo.titulo,
categoria: catalogo.categoria,
disciplina: catalogo.disciplina,
conteudo: catalogo.conteudo,
numero_paginas: catalogo.numero_paginas?.toString() || '',
palavras_chave: catalogo.palavras_chave || [],
arquivo_urls: (catalogo as any).arquivo_urls || []
});
}
}, [catalogo]);
const handleInputChange = (field: string, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const addKeyword = () => {
if (newKeyword.trim() && !formData.palavras_chave.includes(newKeyword.trim())) {
setFormData(prev => ({
...prev,
palavras_chave: [...prev.palavras_chave, newKeyword.trim()]
}));
setNewKeyword('');
}
};
const removeKeyword = (keyword: string) => {
setFormData(prev => ({
...prev,
palavras_chave: prev.palavras_chave.filter(k => k !== keyword)
}));
};
const handleFilesAnalyzed = (analysisResult: any) => {
setFormData(prev => ({
...prev,
titulo: analysisResult.titulo || prev.titulo,
categoria: analysisResult.categoria || prev.categoria,
disciplina: analysisResult.disciplina || prev.disciplina,
conteudo: analysisResult.conteudo || prev.conteudo,
palavras_chave: [...new Set([...prev.palavras_chave, ...(analysisResult.palavras_chave || [])])]
}));
};
const handleFilesUploaded = (urls: string[]) => {
setFormData(prev => ({
...prev,
arquivo_urls: [...new Set([...prev.arquivo_urls, ...urls])]
}));
};
const analyzeWithAI = async () => {
if (!formData.conteudo.trim()) {
toast.error('Por favor, cole o conteúdo do documento antes de analisar');
return;
}
setIsAnalyzing(true);
try {
// Simulação da análise com IA (seria integração real com Gemini)
await new Promise(resolve => setTimeout(resolve, 2000));
// Análise simulada baseada no conteúdo
const content = formData.conteudo.toLowerCase();
let suggestedCategoria = '';
let suggestedDisciplina = '';
let suggestedTitulo = '';
let suggestedKeywords: string[] = [];
// Lógica simples de categorização baseada em palavras-chave
if (content.includes('aço') || content.includes('steel')) {
suggestedCategoria = 'Aço';
suggestedKeywords.push('aço', 'steel');
} else if (content.includes('tinta') || content.includes('pintura')) {
suggestedCategoria = 'Tintas';
suggestedKeywords.push('tinta', 'pintura');
} else if (content.includes('parafuso') || content.includes('fixação')) {
suggestedCategoria = 'Parafusos';
suggestedKeywords.push('parafuso', 'fixação');
} else {
suggestedCategoria = 'Materiais';
}
if (content.includes('estrutura') || content.includes('construção')) {
suggestedDisciplina = 'Engenharia Civil';
suggestedKeywords.push('estrutura', 'construção');
} else if (content.includes('mecânica') || content.includes('fabricação')) {
suggestedDisciplina = 'Engenharia Mecânica';
suggestedKeywords.push('mecânica', 'fabricação');
} else {
suggestedDisciplina = 'Construção';
}
// Extrair título das primeiras linhas
const firstLine = formData.conteudo.split('\n')[0];
if (firstLine && firstLine.length > 10 && firstLine.length < 100) {
suggestedTitulo = firstLine.trim();
}
// Adicionar palavras-chave técnicas comuns
if (content.includes('resistência')) suggestedKeywords.push('resistência');
if (content.includes('qualidade')) suggestedKeywords.push('qualidade');
if (content.includes('especificação')) suggestedKeywords.push('especificação');
if (content.includes('norma')) suggestedKeywords.push('norma');
setFormData(prev => ({
...prev,
titulo: suggestedTitulo || prev.titulo,
categoria: suggestedCategoria || prev.categoria,
disciplina: suggestedDisciplina || prev.disciplina,
palavras_chave: [...new Set([...prev.palavras_chave, ...suggestedKeywords])]
}));
toast.success('Análise concluída! Os campos foram preenchidos automaticamente.');
} catch (error) {
console.error('Erro na análise:', error);
toast.error('Erro ao analisar documento com IA');
} finally {
setIsAnalyzing(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.titulo || !formData.categoria || !formData.disciplina || !formData.conteudo) {
toast.error('Por favor, preencha todos os campos obrigatórios');
return;
}
const submitData = {
...formData,
numero_paginas: formData.numero_paginas ? parseInt(formData.numero_paginas) : null
};
await onSave(submitData);
};
return (
<Dialog open={true} onOpenChange={onClose}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{catalogo ? 'Editar Documento' : 'Novo Documento'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Seção de Upload de Arquivos - apenas para novos documentos */}
{!catalogo && (
<div className="p-4 bg-slate-50 rounded-lg border">
<FileUploadSection
onFilesAnalyzed={handleFilesAnalyzed}
onFilesUploaded={handleFilesUploaded}
/>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="titulo">Título *</Label>
<Input
id="titulo"
value={formData.titulo}
onChange={(e) => handleInputChange('titulo', e.target.value)}
placeholder="Digite o título do documento"
/>
</div>
<div className="space-y-2">
<Label htmlFor="numero_paginas">Número de Páginas</Label>
<Input
id="numero_paginas"
type="number"
value={formData.numero_paginas}
onChange={(e) => handleInputChange('numero_paginas', e.target.value)}
placeholder="Ex: 25"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="categoria">Categoria *</Label>
<Select
value={formData.categoria}
onValueChange={(value) => handleInputChange('categoria', value)}
>
<SelectTrigger>
<SelectValue placeholder="Selecione uma categoria" />
</SelectTrigger>
<SelectContent>
{categorias.map((categoria) => (
<SelectItem key={categoria} value={categoria}>
{categoria}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="disciplina">Disciplina *</Label>
<Select
value={formData.disciplina}
onValueChange={(value) => handleInputChange('disciplina', value)}
>
<SelectTrigger>
<SelectValue placeholder="Selecione uma disciplina" />
</SelectTrigger>
<SelectContent>
{disciplinas.map((disciplina) => (
<SelectItem key={disciplina} value={disciplina}>
{disciplina}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="conteudo">Conteúdo do Documento *</Label>
<Textarea
id="conteudo"
value={formData.conteudo}
onChange={(e) => handleInputChange('conteudo', e.target.value)}
placeholder="Cole o conteúdo do documento aqui..."
className="min-h-[200px]"
/>
{!catalogo && (
<Button
type="button"
onClick={analyzeWithAI}
disabled={isAnalyzing || !formData.conteudo.trim()}
className="mt-2"
variant="outline"
>
{isAnalyzing ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Sparkles className="w-4 h-4 mr-2" />
)}
{isAnalyzing ? 'Analisando...' : 'Analisar com IA'}
</Button>
)}
</div>
<div className="space-y-2">
<Label>Palavras-chave</Label>
<div className="flex gap-2">
<Input
value={newKeyword}
onChange={(e) => setNewKeyword(e.target.value)}
placeholder="Adicionar palavra-chave"
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addKeyword())}
/>
<Button type="button" onClick={addKeyword} variant="outline">
Adicionar
</Button>
</div>
<div className="flex flex-wrap gap-2 mt-2">
{formData.palavras_chave.map((keyword, index) => (
<Badge key={index} variant="secondary" className="flex items-center gap-1">
{keyword}
<button
type="button"
onClick={() => removeKeyword(keyword)}
className="ml-1 hover:text-red-400"
>
<X className="w-3 h-3" />
</button>
</Badge>
))}
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={onClose}>
Cancelar
</Button>
<Button type="submit" className="bg-green-600 hover:bg-green-700">
{catalogo ? 'Atualizar' : 'Salvar'} Documento
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,168 @@
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Edit, Trash2, FileText, Calendar, Hash, Eye } from 'lucide-react';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { Catalogo } from '@/hooks/useCatalogos';
interface CatalogoPreviewsProps {
catalogos: Catalogo[];
onEdit: (catalogo: Catalogo) => void;
onDelete: (id: string) => void;
onView: (catalogo: Catalogo) => void;
canModify: boolean;
}
export function CatalogoPreviews({ catalogos, onEdit, onDelete, onView, canModify }: CatalogoPreviewsProps) {
if (catalogos.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
<FileText className="mx-auto h-12 w-12 mb-4 opacity-50" />
<p>Nenhum documento encontrado</p>
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{catalogos.map((catalogo) => (
<Card
key={catalogo.id}
className="bg-white border-slate-300 shadow-sm hover:shadow-md transition-colors cursor-pointer
dark:bg-slate-800/30 dark:border-slate-700 dark:hover:bg-slate-800/50"
onClick={() => onView(catalogo)}
>
<CardHeader className="pb-3">
<div className="flex justify-between items-start gap-2">
<CardTitle className="text-slate-900 dark:text-white text-lg line-clamp-2" title={catalogo.titulo}>
<div className="flex items-center gap-2">
<FileText className="h-5 w-5 flex-shrink-0" />
{catalogo.titulo}
</div>
</CardTitle>
{canModify && (
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onView(catalogo);
}}
className="h-8 w-8 p-0 hover:bg-green-100 dark:hover:bg-green-600/20"
title="Visualizar documento"
>
<Eye className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onEdit(catalogo);
}}
className="h-8 w-8 p-0 hover:bg-blue-100 dark:hover:bg-blue-600/20"
>
<Edit className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={(e) => e.stopPropagation()}
className="h-8 w-8 p-0 hover:bg-red-100 dark:hover:bg-red-600/20"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirmar exclusão</AlertDialogTitle>
<AlertDialogDescription>
Tem certeza que deseja excluir o documento "{catalogo.titulo}"?
Esta ação não pode ser desfeita.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={() => onDelete(catalogo.id)}
className="bg-red-600 hover:bg-red-700"
>
Excluir
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)}
</div>
<div className="flex gap-2 flex-wrap">
<Badge variant="secondary" className="bg-blue-50 text-blue-700 border border-blue-200 dark:bg-blue-900 dark:text-blue-100 dark:border-transparent">
{catalogo.categoria}
</Badge>
<Badge variant="outline" className="border-slate-300 text-slate-700 dark:border-slate-600 dark:text-slate-300">
{catalogo.disciplina}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-slate-600 dark:text-slate-400 text-sm line-clamp-3">
{catalogo.conteudo.substring(0, 150)}...
</p>
<div className="flex items-center gap-4 text-sm text-slate-500 dark:text-slate-400">
<div className="flex items-center gap-1">
<Hash className="h-4 w-4" />
<span>{catalogo.numero_paginas || 'N/A'} pág.</span>
</div>
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
<span>{format(new Date(catalogo.created_at), 'dd/MM/yy', { locale: ptBR })}</span>
</div>
</div>
{catalogo.palavras_chave && catalogo.palavras_chave.length > 0 && (
<div className="flex flex-wrap gap-1">
{catalogo.palavras_chave.slice(0, 4).map((palavra, index) => (
<Badge key={index} variant="outline" className="text-xs border-slate-300 text-slate-600 dark:border-slate-600 dark:text-slate-400">
{palavra}
</Badge>
))}
{catalogo.palavras_chave.length > 4 && (
<Badge variant="outline" className="text-xs border-slate-300 text-slate-600 dark:border-slate-600 dark:text-slate-400">
+{catalogo.palavras_chave.length - 4}
</Badge>
)}
</div>
)}
{catalogo.arquivo_urls && catalogo.arquivo_urls.length > 0 && (
<div className="flex items-center gap-2 text-xs text-green-600 dark:text-green-400">
<Eye className="h-3 w-3" />
<span>{catalogo.arquivo_urls.length} documento(s) disponível(eis)</span>
</div>
)}
</CardContent>
</Card>
))}
</div>
);
}

View File

@@ -0,0 +1,126 @@
import React from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Search, X, Grid, List } from 'lucide-react';
interface CatalogosFiltersProps {
searchTerm: string;
setSearchTerm: (value: string) => void;
categoriaFilter: string;
setCategoriaFilter: (value: string) => void;
disciplinaFilter: string;
setDisciplinaFilter: (value: string) => void;
onClearFilters: () => void;
viewMode: 'table' | 'preview';
onViewModeChange: (mode: 'table' | 'preview') => void;
}
const categorias = [
'Aço',
'Adesivo',
'Tintas',
'Parafusos',
'Soldas',
'Estruturas',
'Materiais'
];
const disciplinas = [
'Engenharia Civil',
'Engenharia Mecânica',
'Arquitetura',
'Construção',
'Fabricação',
'Montagem'
];
export function CatalogosFilters({
searchTerm,
setSearchTerm,
categoriaFilter,
setCategoriaFilter,
disciplinaFilter,
setDisciplinaFilter,
onClearFilters,
viewMode,
onViewModeChange
}: CatalogosFiltersProps) {
const hasActiveFilters = searchTerm || (categoriaFilter && categoriaFilter !== 'all') || (disciplinaFilter && disciplinaFilter !== 'all');
return (
<div className="space-y-4">
<div className="flex flex-col md:flex-row gap-4 items-start md:items-center">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-slate-400" />
<Input
placeholder="Buscar por título, conteúdo, palavras-chave..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 h-10 bg-white border-slate-300 text-slate-900 dark:bg-slate-700 dark:border-slate-600 dark:text-white"
/>
</div>
<Select value={categoriaFilter} onValueChange={setCategoriaFilter}>
<SelectTrigger className="w-full md:w-[180px] h-10 bg-white border-slate-300 text-slate-900 dark:bg-slate-700 dark:border-slate-600 dark:text-white">
<SelectValue placeholder="Categoria" />
</SelectTrigger>
<SelectContent className="bg-white border-slate-300 dark:bg-slate-700 dark:border-slate-600">
<SelectItem value="all" className="text-slate-900 dark:text-white hover:bg-slate-100 dark:hover:bg-slate-600">Todas as Categorias</SelectItem>
{categorias.map((categoria) => (
<SelectItem key={categoria} value={categoria} className="text-slate-900 dark:text-white hover:bg-slate-100 dark:hover:bg-slate-600">
{categoria}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={disciplinaFilter} onValueChange={setDisciplinaFilter}>
<SelectTrigger className="w-full md:w-[180px] h-10 bg-white border-slate-300 text-slate-900 dark:bg-slate-700 dark:border-slate-600 dark:text-white">
<SelectValue placeholder="Disciplina" />
</SelectTrigger>
<SelectContent className="bg-white border-slate-300 dark:bg-slate-700 dark:border-slate-600">
<SelectItem value="all" className="text-slate-900 dark:text-white hover:bg-slate-100 dark:hover:bg-slate-600">Todas as Disciplinas</SelectItem>
{disciplinas.map((disciplina) => (
<SelectItem key={disciplina} value={disciplina} className="text-slate-900 dark:text-white hover:bg-slate-100 dark:hover:bg-slate-600">
{disciplina}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex gap-2">
<Button
variant={viewMode === 'table' ? 'default' : 'outline'}
size="sm"
onClick={() => onViewModeChange('table')}
className={viewMode === 'table' ? '' : 'bg-slate-50 border-slate-300 text-slate-900 hover:bg-slate-100 dark:bg-transparent dark:border-slate-600 dark:text-white dark:hover:bg-slate-700'}
>
<List className="h-4 w-4" />
</Button>
<Button
variant={viewMode === 'preview' ? 'default' : 'outline'}
size="sm"
onClick={() => onViewModeChange('preview')}
className={viewMode === 'preview' ? '' : 'bg-slate-50 border-slate-300 text-slate-900 hover:bg-slate-100 dark:bg-transparent dark:border-slate-600 dark:text-white dark:hover:bg-slate-700'}
>
<Grid className="h-4 w-4" />
</Button>
</div>
{hasActiveFilters && (
<Button
variant="outline"
onClick={onClearFilters}
size="sm"
className="h-10 bg-white border-slate-300 text-slate-700 hover:bg-slate-100 dark:bg-slate-700 dark:border-slate-600 dark:text-white dark:hover:bg-slate-600"
>
<X className="h-4 w-4 mr-2" />
Limpar
</Button>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,160 @@
import React from 'react';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Edit, Trash2, Eye, FileText } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { Catalogo } from '@/hooks/useCatalogos';
interface CatalogosTableProps {
catalogos: Catalogo[];
onEdit: (catalogo: Catalogo) => void;
onDelete: (id: string) => void;
onView: (catalogo: Catalogo) => void;
canModify: boolean;
}
export function CatalogosTable({ catalogos, onEdit, onDelete, onView, canModify }: CatalogosTableProps) {
if (catalogos.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
<Eye className="mx-auto h-12 w-12 mb-4 opacity-50" />
<p>Nenhum documento encontrado</p>
</div>
);
}
return (
<div className="w-full overflow-x-auto">
<Table disableOverflow>
<TableHeader>
<TableRow className="bg-slate-50 dark:bg-transparent">
<TableHead className="text-slate-700 dark:text-white">Título</TableHead>
<TableHead className="text-slate-700 dark:text-white">Categoria</TableHead>
<TableHead className="text-slate-700 dark:text-white">Disciplina</TableHead>
<TableHead className="text-slate-700 dark:text-white">Páginas</TableHead>
<TableHead className="text-slate-700 dark:text-white">Data de Inclusão</TableHead>
<TableHead className="text-slate-700 dark:text-white">Palavras-chave</TableHead>
<TableHead className="text-slate-700 dark:text-white">Ações</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{catalogos.map((catalogo) => (
<TableRow key={catalogo.id} className="hover:bg-slate-50 dark:hover:bg-slate-700/50">
<TableCell className="text-slate-900 dark:text-white font-medium max-w-[200px]">
<div
className="truncate cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
title={catalogo.titulo}
onClick={() => onView(catalogo)}
>
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 flex-shrink-0" />
{catalogo.titulo}
</div>
</div>
</TableCell>
<TableCell>
<Badge variant="secondary" className="bg-blue-50 text-blue-700 border border-blue-200 dark:bg-blue-900 dark:text-blue-100 dark:border-transparent">
{catalogo.categoria}
</Badge>
</TableCell>
<TableCell>
<Badge variant="outline" className="border-slate-300 text-slate-700 dark:border-slate-600 dark:text-slate-300">
{catalogo.disciplina}
</Badge>
</TableCell>
<TableCell className="text-slate-700 dark:text-slate-300">
{catalogo.numero_paginas || 'N/A'}
</TableCell>
<TableCell className="text-slate-700 dark:text-slate-300">
{format(new Date(catalogo.created_at), 'dd/MM/yyyy', { locale: ptBR })}
</TableCell>
<TableCell className="max-w-[200px]">
<div className="flex flex-wrap gap-1">
{catalogo.palavras_chave?.slice(0, 3).map((palavra, index) => (
<Badge key={index} variant="outline" className="text-xs border-slate-300 text-slate-600 dark:border-slate-600 dark:text-slate-400">
{palavra}
</Badge>
))}
{catalogo.palavras_chave && catalogo.palavras_chave.length > 3 && (
<Badge variant="outline" className="text-xs border-slate-300 text-slate-600 dark:border-slate-600 dark:text-slate-400">
+{catalogo.palavras_chave.length - 3}
</Badge>
)}
</div>
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => onView(catalogo)}
className="h-8 w-8 p-0 hover:bg-green-100 dark:hover:bg-green-600/20"
title="Visualizar documento"
>
<Eye className="h-4 w-4" />
</Button>
{canModify && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => onEdit(catalogo)}
className="h-8 w-8 p-0 hover:bg-blue-100 dark:hover:bg-blue-600/20"
>
<Edit className="h-4 w-4" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 hover:bg-red-100 dark:hover:bg-red-600/20"
>
<Trash2 className="h-4 w-4" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Confirmar exclusão</AlertDialogTitle>
<AlertDialogDescription>
Tem certeza que deseja excluir o documento "{catalogo.titulo}"?
Esta ação não pode ser desfeita.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancelar</AlertDialogCancel>
<AlertDialogAction
onClick={() => onDelete(catalogo.id)}
className="bg-red-600 hover:bg-red-700"
>
Excluir
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}

View File

@@ -0,0 +1,276 @@
// Top-level imports
import React, { useState, useRef, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { X, Download, ZoomIn, ZoomOut, ChevronLeft, ChevronRight, FileText, Image, ExternalLink, Maximize2, Minimize2 } from 'lucide-react';
import { format } from 'date-fns';
import { ptBR } from 'date-fns/locale';
import { Catalogo } from '@/hooks/useCatalogos';
interface DocumentViewerProps {
catalogo: Catalogo | null;
isOpen: boolean;
onClose: () => void;
}
// DocumentViewer component
export function DocumentViewer({ catalogo, isOpen, onClose }: DocumentViewerProps) {
// TODOS OS HOOKS DEVEM ESTAR NO TOPO - ANTES DE QUALQUER RETURN CONDICIONAL
const [currentDocIndex, setCurrentDocIndex] = useState(0);
const [zoomLevel, setZoomLevel] = useState(1);
const viewerRef = useRef<HTMLDivElement>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
// useEffect também deve estar no topo
useEffect(() => {
const onFullScreenChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onFullScreenChange);
return () => document.removeEventListener('fullscreenchange', onFullScreenChange);
}, []);
// Funções de handler
const handlePrevious = () => {
if (currentDocIndex > 0) {
setCurrentDocIndex(currentDocIndex - 1);
setZoomLevel(1);
}
};
const handleNext = () => {
if (catalogo?.arquivo_urls && currentDocIndex < catalogo.arquivo_urls.length - 1) {
setCurrentDocIndex(currentDocIndex + 1);
setZoomLevel(1);
}
};
const handleZoomIn = () => {
if (zoomLevel < 3) {
setZoomLevel(zoomLevel + 0.25);
}
};
const handleZoomOut = () => {
if (zoomLevel > 0.5) {
setZoomLevel(zoomLevel - 0.25);
}
};
const handleDownload = () => {
const currentDoc = catalogo?.arquivo_urls?.[currentDocIndex];
if (currentDoc) {
const link = document.createElement('a');
link.href = currentDoc;
link.download = '';
link.target = '_blank';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
};
const handleOpenInNewTab = () => {
const currentDoc = catalogo?.arquivo_urls?.[currentDocIndex];
if (currentDoc) {
window.open(currentDoc, '_blank', 'noopener,noreferrer');
}
};
const handleToggleFullscreen = async () => {
try {
if (!document.fullscreenElement) {
await viewerRef.current?.requestFullscreen?.();
setIsFullscreen(true);
} else {
await document.exitFullscreen?.();
setIsFullscreen(false);
}
} catch (e) {
console.error('Erro ao alternar tela cheia:', e);
}
};
const resetAndClose = () => {
setCurrentDocIndex(0);
setZoomLevel(1);
onClose();
};
// AGORA SIM podemos fazer o return condicional - APÓS todos os hooks
if (!catalogo || !catalogo.arquivo_urls || catalogo.arquivo_urls.length === 0) {
return null;
}
const currentDoc = catalogo.arquivo_urls[currentDocIndex];
const isImage = currentDoc?.includes('image') || /\.(jpg|jpeg|png|gif|webp|bmp|tiff)$/i.test(currentDoc || '');
const isPdf = currentDoc?.includes('pdf') || /\.pdf$/i.test(currentDoc || '');
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) resetAndClose();
}}
>
<DialogContent
className="
w-[95vw] max-w-[95vw] h-[90vh]
bg-white text-slate-900 border-slate-300
dark:bg-slate-900 dark:text-white dark:border-slate-700
"
>
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4 border-b border-slate-200 dark:border-slate-700">
<div className="flex-1">
<DialogTitle className="text-xl mb-2">{catalogo.titulo}</DialogTitle>
<div className="flex items-center gap-4 text-sm text-slate-600 dark:text-slate-400">
<div className="flex items-center gap-2">
<Badge className="bg-slate-100 text-slate-700 border border-slate-300 dark:bg-blue-900 dark:text-blue-100 dark:border-slate-700">
{catalogo.categoria}
</Badge>
<Badge variant="outline" className="border-slate-300 text-slate-700 dark:border-slate-600 dark:text-slate-300">
{catalogo.disciplina}
</Badge>
</div>
<span>{format(new Date(catalogo.created_at), 'dd/MM/yyyy', { locale: ptBR })}</span>
{catalogo.numero_paginas && <span>{catalogo.numero_paginas} páginas</span>}
</div>
</div>
<div className="flex items-center gap-1">
{catalogo.arquivo_urls.length > 1 && (
<div className="mr-2 text-sm text-slate-600 dark:text-slate-400">
{currentDocIndex + 1} de {catalogo.arquivo_urls.length}
</div>
)}
{isImage && (
<>
<Button variant="ghost" size="sm" onClick={handleZoomOut} disabled={zoomLevel <= 0.5} className="h-8 w-8 p-0">
<ZoomOut className="h-4 w-4" />
</Button>
<span className="text-xs text-slate-600 dark:text-slate-400 px-2">{Math.round(zoomLevel * 100)}%</span>
<Button variant="ghost" size="sm" onClick={handleZoomIn} disabled={zoomLevel >= 3} className="h-8 w-8 p-0">
<ZoomIn className="h-4 w-4" />
</Button>
</>
)}
<Button variant="ghost" size="sm" onClick={handleOpenInNewTab} className="h-8 w-8 p-0" title="Abrir em nova aba">
<ExternalLink className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={handleToggleFullscreen} className="h-8 w-8 p-0" title="Tela cheia">
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</Button>
<Button variant="ghost" size="sm" onClick={handleDownload} className="h-8 w-8 p-0" title="Baixar">
<Download className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={resetAndClose} className="h-8 w-8 p-0" title="Fechar">
<X className="h-4 w-4" />
</Button>
</div>
</DialogHeader>
<div className="flex-1 flex overflow-hidden pt-3">
{catalogo.arquivo_urls.length > 1 && (
<div className="flex items-center">
<Button variant="ghost" size="sm" onClick={handlePrevious} disabled={currentDocIndex === 0} className="h-10 w-10 p-0">
<ChevronLeft className="h-6 w-6" />
</Button>
</div>
)}
<div
ref={viewerRef}
className="
flex-1 flex items-center justify-center overflow-auto
bg-slate-50 border border-slate-200/80 rounded-lg mx-2
dark:bg-slate-800/30 dark:border-slate-700
"
>
{isPdf ? (
<iframe
src={currentDoc}
className="w-full h-full min-h-[65vh] rounded-lg"
title={`Documento ${currentDocIndex + 1}`}
/>
) : isImage ? (
<img
src={currentDoc}
alt={`Documento ${currentDocIndex + 1}`}
className="max-w-full max-h-full object-contain rounded-lg transition-transform duration-200"
style={{ transform: `scale(${zoomLevel})` }}
/>
) : (
<div className="flex flex-col items-center justify-center text-slate-500 dark:text-slate-400 gap-4">
<FileText className="h-12 w-12" />
<p>Tipo de documento não suportado para visualização</p>
<Button onClick={handleDownload} className="bg-blue-600 hover:bg-blue-700">
<Download className="h-4 w-4 mr-2" />
Baixar Documento
</Button>
</div>
)}
</div>
{catalogo.arquivo_urls.length > 1 && (
<div className="flex items-center">
<Button variant="ghost" size="sm" onClick={handleNext} disabled={currentDocIndex === catalogo.arquivo_urls.length - 1} className="h-10 w-10 p-0">
<ChevronRight className="h-6 w-6" />
</Button>
</div>
)}
</div>
{catalogo.arquivo_urls.length > 1 && (
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
<div className="flex gap-2 overflow-x-auto pb-2">
{catalogo.arquivo_urls.map((url, index) => {
const isCurrentImage = url?.includes('image') || /\.(jpg|jpeg|png|gif|webp|bmp|tiff)$/i.test(url || '');
const isCurrentPdf = url?.includes('pdf') || /\.pdf$/i.test(url || '');
return (
<button
key={index}
onClick={() => { setCurrentDocIndex(index); setZoomLevel(1); }}
className={`flex-shrink-0 w-16 h-16 rounded-lg border-2 overflow-hidden transition-colors ${
index === currentDocIndex ? 'border-blue-500' : 'border-slate-300 hover:border-slate-400 dark:border-slate-600 dark:hover:border-slate-500'
}`}
>
{isCurrentImage ? (
<img src={url} alt={`Miniatura ${index + 1}`} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
{isCurrentPdf ? (
<FileText className="h-6 w-6 text-slate-500 dark:text-slate-400" />
) : (
<Image className="h-6 w-6 text-slate-500 dark:text-slate-400" />
)}
</div>
)}
</button>
);
})}
</div>
</div>
)}
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
<div className="space-y-2">
<p className="text-slate-700 dark:text-slate-300 text-sm line-clamp-3">{catalogo.conteudo}</p>
{catalogo.palavras_chave && catalogo.palavras_chave.length > 0 && (
<div className="flex flex-wrap gap-1">
{catalogo.palavras_chave.map((palavra, index) => (
<Badge key={index} variant="outline" className="text-xs border-slate-300 text-slate-700 dark:border-slate-600 dark:text-slate-300">
{palavra}
</Badge>
))}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,299 @@
import React, { useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Upload, X, FileText, Image, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { supabase } from '@/integrations/supabase/client';
interface FileUploadSectionProps {
onFilesAnalyzed: (analysisResult: {
titulo?: string;
categoria?: string;
disciplina?: string;
conteudo?: string;
palavras_chave?: string[];
}) => void;
onFilesUploaded: (urls: string[]) => void;
}
interface UploadedFile {
file: File;
url?: string;
uploading: boolean;
error?: string;
}
export function FileUploadSection({ onFilesAnalyzed, onFilesUploaded }: FileUploadSectionProps) {
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const handleFileSelect = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
// Validar tipos de arquivo
const allowedTypes = [
'application/pdf',
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/tiff',
'image/bmp'
];
const validFiles = files.filter(file => {
if (!allowedTypes.includes(file.type)) {
toast.error(`Tipo de arquivo não permitido: ${file.name}`);
return false;
}
if (file.size > 10 * 1024 * 1024) { // 10MB
toast.error(`Arquivo muito grande: ${file.name} (máximo 10MB)`);
return false;
}
return true;
});
if (validFiles.length > 0) {
const newFiles = validFiles.map(file => ({
file,
uploading: false,
error: undefined
}));
setUploadedFiles(prev => [...prev, ...newFiles]);
}
// Limpar o input
event.target.value = '';
}, []);
const uploadFile = async (fileData: UploadedFile, index: number) => {
try {
setUploadedFiles(prev => prev.map((f, i) =>
i === index ? { ...f, uploading: true, error: undefined } : f
));
const fileName = `${Date.now()}-${fileData.file.name}`;
const { data, error } = await supabase.storage
.from('catalogo-documents')
.upload(fileName, fileData.file);
if (error) throw error;
const { data: { publicUrl } } = supabase.storage
.from('catalogo-documents')
.getPublicUrl(fileName);
setUploadedFiles(prev => prev.map((f, i) =>
i === index ? { ...f, uploading: false, url: publicUrl } : f
));
return publicUrl;
} catch (error) {
console.error('Erro no upload:', error);
setUploadedFiles(prev => prev.map((f, i) =>
i === index ? { ...f, uploading: false, error: 'Erro no upload' } : f
));
toast.error('Erro no upload do arquivo');
return null;
}
};
const uploadAllFiles = async () => {
const uploadPromises = uploadedFiles.map((fileData, index) => {
if (!fileData.url && !fileData.uploading) {
return uploadFile(fileData, index);
}
return Promise.resolve(fileData.url);
});
const urls = await Promise.all(uploadPromises);
const validUrls = urls.filter(url => url !== null) as string[];
onFilesUploaded(validUrls);
return validUrls;
};
const removeFile = (index: number) => {
setUploadedFiles(prev => prev.filter((_, i) => i !== index));
};
const analyzeFiles = async () => {
if (uploadedFiles.length === 0) {
toast.error('Adicione pelo menos um arquivo para analisar');
return;
}
setIsAnalyzing(true);
try {
// Primeiro fazer upload de todos os arquivos
const urls = await uploadAllFiles();
if (urls.length === 0) {
toast.error('Nenhum arquivo foi carregado com sucesso');
return;
}
// Simular análise com IA baseada nos arquivos
await new Promise(resolve => setTimeout(resolve, 2000));
// Análise baseada nos nomes e tipos de arquivo
const fileNames = uploadedFiles.map(f => f.file.name.toLowerCase());
const hasImages = uploadedFiles.some(f => f.file.type.startsWith('image/'));
const hasPdf = uploadedFiles.some(f => f.file.type === 'application/pdf');
let suggestedCategoria = '';
let suggestedDisciplina = '';
let suggestedTitulo = '';
let suggestedKeywords: string[] = [];
let suggestedContent = '';
// Lógica de categorização baseada em nomes de arquivo
const content = fileNames.join(' ');
if (content.includes('aço') || content.includes('steel')) {
suggestedCategoria = 'Aço';
suggestedKeywords.push('aço', 'steel');
} else if (content.includes('tinta') || content.includes('pintura')) {
suggestedCategoria = 'Tintas';
suggestedKeywords.push('tinta', 'pintura');
} else if (content.includes('parafuso') || content.includes('fixação')) {
suggestedCategoria = 'Parafusos';
suggestedKeywords.push('parafuso', 'fixação');
} else {
suggestedCategoria = 'Materiais';
}
if (content.includes('estrutura') || content.includes('construção')) {
suggestedDisciplina = 'Engenharia Civil';
suggestedKeywords.push('estrutura', 'construção');
} else if (content.includes('mecânica') || content.includes('fabricação')) {
suggestedDisciplina = 'Engenharia Mecânica';
suggestedKeywords.push('mecânica', 'fabricação');
} else {
suggestedDisciplina = 'Construção';
}
// Gerar título baseado no primeiro arquivo
if (uploadedFiles.length > 0) {
const firstFileName = uploadedFiles[0].file.name.replace(/\.[^/.]+$/, '');
suggestedTitulo = firstFileName.replace(/[-_]/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
}
// Gerar conteúdo baseado nos tipos de arquivo
suggestedContent = `Documento ${hasPdf ? 'PDF' : 'de imagens'} contendo informações técnicas sobre ${suggestedCategoria.toLowerCase()}.`;
if (hasImages) {
suggestedContent += ` Inclui ${uploadedFiles.filter(f => f.file.type.startsWith('image/')).length} imagem(ns) técnica(s).`;
}
if (hasPdf) {
suggestedContent += ` Documento PDF com especificações e detalhes técnicos.`;
}
// Adicionar palavras-chave relacionadas ao tipo de arquivo
if (hasPdf) suggestedKeywords.push('pdf', 'documento');
if (hasImages) suggestedKeywords.push('imagem', 'visual');
onFilesAnalyzed({
titulo: suggestedTitulo,
categoria: suggestedCategoria,
disciplina: suggestedDisciplina,
conteudo: suggestedContent,
palavras_chave: [...new Set(suggestedKeywords)]
});
toast.success('Análise concluída! Os campos foram preenchidos automaticamente.');
} catch (error) {
console.error('Erro na análise:', error);
toast.error('Erro ao analisar documentos');
} finally {
setIsAnalyzing(false);
}
};
const getFileIcon = (file: File) => {
return file.type === 'application/pdf' ? FileText : Image;
};
return (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="file-upload">Upload de Documentos</Label>
<div className="flex items-center gap-2">
<Input
id="file-upload"
type="file"
multiple
accept=".pdf,.jpg,.jpeg,.png,.gif,.webp,.tiff,.bmp"
onChange={handleFileSelect}
className="flex-1"
/>
<Button
type="button"
onClick={analyzeFiles}
disabled={isAnalyzing || uploadedFiles.length === 0}
className="bg-blue-600 hover:bg-blue-700"
>
{isAnalyzing ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Upload className="w-4 h-4 mr-2" />
)}
{isAnalyzing ? 'Analisando...' : 'Analisar com IA'}
</Button>
</div>
<p className="text-sm text-muted-foreground">
Tipos aceitos: PDF, JPG, PNG, GIF, WebP, TIFF, BMP (máximo 10MB cada)
</p>
</div>
{uploadedFiles.length > 0 && (
<div className="space-y-2">
<Label>Arquivos Selecionados</Label>
<div className="space-y-2 max-h-40 overflow-y-auto">
{uploadedFiles.map((fileData, index) => {
const Icon = getFileIcon(fileData.file);
return (
<div
key={index}
className="flex items-center justify-between p-2 bg-slate-50 rounded border"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<Icon className="w-4 h-4 text-slate-600 flex-shrink-0" />
<span className="text-sm truncate">{fileData.file.name}</span>
<span className="text-xs text-slate-500 flex-shrink-0">
({(fileData.file.size / (1024 * 1024)).toFixed(1)} MB)
</span>
</div>
<div className="flex items-center gap-2">
{fileData.uploading && (
<Loader2 className="w-4 h-4 animate-spin text-blue-600" />
)}
{fileData.url && (
<span className="text-xs text-green-600"></span>
)}
{fileData.error && (
<span className="text-xs text-red-600"></span>
)}
<button
type="button"
onClick={() => removeFile(index)}
className="text-red-500 hover:text-red-700"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,138 @@
import React, { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { usePrioridades, PrioridadeConfig } from '@/hooks/usePrioridades';
import { Palette, Save } from 'lucide-react';
export const PrioridadesConfig = () => {
const { prioridades, loading, updatePrioridade } = usePrioridades();
const [editingPrioridades, setEditingPrioridades] = useState<Record<string, Partial<PrioridadeConfig>>>({});
const [saving, setSaving] = useState<string | null>(null);
const handleInputChange = (prioridadeId: string, field: string, value: string) => {
setEditingPrioridades(prev => ({
...prev,
[prioridadeId]: {
...prev[prioridadeId],
[field]: value
}
}));
};
const handleSave = async (prioridade: PrioridadeConfig) => {
const updates = editingPrioridades[prioridade.id];
if (!updates || Object.keys(updates).length === 0) return;
setSaving(prioridade.id);
const success = await updatePrioridade(prioridade.id, updates);
if (success) {
setEditingPrioridades(prev => {
const newState = { ...prev };
delete newState[prioridade.id];
return newState;
});
}
setSaving(null);
};
const getCurrentValue = (prioridade: PrioridadeConfig, field: keyof PrioridadeConfig) => {
return editingPrioridades[prioridade.id]?.[field] ?? prioridade[field];
};
const hasChanges = (prioridadeId: string) => {
return editingPrioridades[prioridadeId] && Object.keys(editingPrioridades[prioridadeId]).length > 0;
};
if (loading) {
return (
<Card className="bg-white border-slate-300 shadow-sm dark:bg-slate-800/50 dark:border-slate-700">
<CardContent className="p-6">
<div className="text-muted-foreground">Carregando configurações de prioridade...</div>
</CardContent>
</Card>
);
}
return (
<Card className="bg-white border-slate-300 shadow-sm dark:bg-slate-800/50 dark:border-slate-700">
<CardHeader>
<CardTitle className="text-foreground flex items-center gap-2">
<Palette className="h-5 w-5" />
Configuração de Prioridades
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-muted-foreground text-sm">
Configure os nomes e cores das prioridades das peças
</p>
<div className="space-y-4">
{prioridades.map((prioridade) => (
<div
key={prioridade.id}
className="flex items-center gap-4 p-4 rounded-lg border bg-slate-50 border-slate-200 dark:bg-slate-700/50 dark:border-slate-600"
>
<div className="flex-shrink-0">
<Badge
className="text-white font-medium"
style={{ backgroundColor: getCurrentValue(prioridade, 'cor') as string }}
>
{prioridade.codigo}
</Badge>
</div>
<div className="flex-1 grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-foreground text-sm">Nome</Label>
<Input
value={getCurrentValue(prioridade, 'nome') as string}
onChange={(e) => handleInputChange(prioridade.id, 'nome', e.target.value)}
className="bg-white border-slate-300 text-slate-900 dark:bg-slate-600 dark:border-slate-500 dark:text-white"
placeholder="Nome da prioridade"
/>
</div>
<div className="space-y-2">
<Label className="text-foreground text-sm">Cor</Label>
<div className="flex gap-2">
<Input
type="color"
value={getCurrentValue(prioridade, 'cor') as string}
onChange={(e) => handleInputChange(prioridade.id, 'cor', e.target.value)}
className="w-16 h-10 bg-white border-slate-300 cursor-pointer dark:bg-slate-600 dark:border-slate-500"
/>
<Input
value={getCurrentValue(prioridade, 'cor') as string}
onChange={(e) => handleInputChange(prioridade.id, 'cor', e.target.value)}
className="bg-white border-slate-300 text-slate-900 dark:bg-slate-600 dark:border-slate-500 dark:text-white"
placeholder="#000000"
/>
</div>
</div>
</div>
<div className="flex-shrink-0">
{hasChanges(prioridade.id) && (
<Button
onClick={() => handleSave(prioridade)}
disabled={saving === prioridade.id}
size="sm"
className="bg-primary hover:bg-primary/90 text-primary-foreground"
>
<Save className="h-4 w-4 mr-1" />
{saving === prioridade.id ? 'Salvando...' : 'Salvar'}
</Button>
)}
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
};

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