🚀 Initial commit: Versão atual do TrackSteel APP
This commit is contained in:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ref: refs/heads/master
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Unnamed repository; edit this file 'description' to name the repository.
|
||||||
@@ -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+"$@"}
|
||||||
|
:
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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+"$@"}
|
||||||
|
:
|
||||||
@@ -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 --
|
||||||
@@ -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"
|
||||||
|
:
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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]
|
||||||
|
# *~
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,5 @@
|
|||||||
|
xu�ANÃ0EYç#u›tS±@
|
||||||
|
R’F"‰¨I*±²&鈘&¶±�Hì8àÜ 7á$8¨@eë?ž÷þÔ�¬a±8?™Á5ß½ŽÄ
|
||||||
|
l¾æ$,Áûó”›ma‰:•òfKŠȸõãÀG
|
||||||
|
5‚Ob¥‰DÓ"4²Cƒ�;©Ém–P
|
||||||
|
k4ä¹=érzQZ>�ýIàÔý–Âjt!õ�Oyc¡ k¹¸7p ažžyë´LXQåa ËW·×I\²t8$û\ÚXÆ7�áD‹[�°Ø½ Þã¡Ëÿ':î—Šã֞̉*ÊÒâ*Œ²„Ý$w��¡�‚¹áo�j•«ý«�›
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
x=‘ANÄ0EYÏ)¬®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�uL`ßŲ.•å‰2BpS£� _eþ1«È’@˜A“—¢ÕøÆÏ6es‘¥@ŠùÓ¶ºÜI(ÿýoôùÎNEña�Î)8!È1ç{Þ2Ú‹œ"ŒóÎá…]´j%Wš0&wïXÍ’è7/¿1ê�ŒOŸx×Þ›ãü…’l
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
x]P½NΔ0fΎ§¨"FΤΣMHΌ{’Ί4ΰ&Uμ�Cθήύμ&a`³Ώ?²Ημ‡—ησΓοiΜ#…Vg.ƒY�7ΊX»Η‘7…4†ΌΪΚ��”“yRρ‚κ'�έ�\ΡBA°Ω!Α΅bΊΚΞeo«‹ψΣ$��•��Σ?4‡'η™�HΥ©€J°β…먀ΖηΑ[Ζ\:†Ξ�θέ•θ<‚Ϊ{ρl樽�‘�ΫQΤa”$ώ[·� I
|
||||||
+48
@@ -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
@@ -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;"]
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+7240
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 316 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
User-agent: Googlebot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Bingbot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: Twitterbot
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: facebookexternalhit
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
Generated
+164
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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}</>;
|
||||||
|
};
|
||||||
@@ -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}</>;
|
||||||
|
};
|
||||||
@@ -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}</>;
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 há 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user