🚀 Initial commit: Versão atual do TrackSteel APP
This commit is contained in:
30
.dockerignore
Normal file
30
.dockerignore
Normal file
@@ -0,0 +1,30 @@
|
||||
# Dependências locais (serão reinstaladas no container)
|
||||
node_modules
|
||||
|
||||
# Build local
|
||||
dist
|
||||
|
||||
# Controle de versão
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# IDEs e ferramentas
|
||||
.vscode
|
||||
.idea
|
||||
.trae
|
||||
.vercel
|
||||
|
||||
# Variáveis de ambiente locais (injetadas pelo Coolify)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Documentação e scripts desnecessários no container
|
||||
README.md
|
||||
scripts/
|
||||
*.md
|
||||
|
||||
# Arquivos temporários
|
||||
*.log
|
||||
*.tmp
|
||||
*.temp
|
||||
11
.env.example
Normal file
11
.env.example
Normal file
@@ -0,0 +1,11 @@
|
||||
# Variáveis de Ambiente — TrackSteel App
|
||||
# Copie este arquivo para .env e preencha com seus valores do Supabase
|
||||
|
||||
# ID do projeto Supabase (encontrado em Project Settings > API)
|
||||
VITE_SUPABASE_PROJECT_ID=seu_project_id_aqui
|
||||
|
||||
# Chave anônima do Supabase (encontrada em Project Settings > API > Project API keys)
|
||||
VITE_SUPABASE_PUBLISHABLE_KEY=sua_anon_key_aqui
|
||||
|
||||
# URL do projeto Supabase
|
||||
VITE_SUPABASE_URL=https://seu_project_id_aqui.supabase.co
|
||||
1
.git_backup/HEAD
Normal file
1
.git_backup/HEAD
Normal file
@@ -0,0 +1 @@
|
||||
ref: refs/heads/master
|
||||
12
.git_backup/config
Normal file
12
.git_backup/config
Normal file
@@ -0,0 +1,12 @@
|
||||
[core]
|
||||
repositoryformatversion = 0
|
||||
filemode = false
|
||||
bare = false
|
||||
logallrefupdates = true
|
||||
symlinks = false
|
||||
[remote "origin"]
|
||||
url = https://admtracksteel:7383a8fc883c44a7f0e9f06cc26233d47f903565@git.reifonas.cloud/admtracksteel/tracksteelapp.git
|
||||
fetch = +refs/heads/*:refs/remotes/origin/*
|
||||
[user]
|
||||
email = m.reifonas@gmail.com
|
||||
name = Marcos Reifonas
|
||||
1
.git_backup/description
Normal file
1
.git_backup/description
Normal file
@@ -0,0 +1 @@
|
||||
Unnamed repository; edit this file 'description' to name the repository.
|
||||
15
.git_backup/hooks/applypatch-msg.sample
Normal file
15
.git_backup/hooks/applypatch-msg.sample
Normal file
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# An example hook script to check the commit log message taken by
|
||||
# applypatch from an e-mail message.
|
||||
#
|
||||
# The hook should exit with non-zero status after issuing an
|
||||
# appropriate message if it wants to stop the commit. The hook is
|
||||
# allowed to edit the commit message file.
|
||||
#
|
||||
# To enable this hook, rename this file to "applypatch-msg".
|
||||
|
||||
. git-sh-setup
|
||||
commitmsg="$(git rev-parse --git-path hooks/commit-msg)"
|
||||
test -x "$commitmsg" && exec "$commitmsg" ${1+"$@"}
|
||||
:
|
||||
24
.git_backup/hooks/commit-msg.sample
Normal file
24
.git_backup/hooks/commit-msg.sample
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# An example hook script to check the commit log message.
|
||||
# Called by "git commit" with one argument, the name of the file
|
||||
# that has the commit message. The hook should exit with non-zero
|
||||
# status after issuing an appropriate message if it wants to stop the
|
||||
# commit. The hook is allowed to edit the commit message file.
|
||||
#
|
||||
# To enable this hook, rename this file to "commit-msg".
|
||||
|
||||
# Uncomment the below to add a Signed-off-by line to the message.
|
||||
# Doing this in a hook is a bad idea in general, but the prepare-commit-msg
|
||||
# hook is more suited to it.
|
||||
#
|
||||
# SOB=$(git var GIT_AUTHOR_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
|
||||
# grep -qs "^$SOB" "$1" || echo "$SOB" >> "$1"
|
||||
|
||||
# This example catches duplicate Signed-off-by lines.
|
||||
|
||||
test "" = "$(grep '^Signed-off-by: ' "$1" |
|
||||
sort | uniq -c | sed -e '/^[ ]*1[ ]/d')" || {
|
||||
echo >&2 Duplicate Signed-off-by lines.
|
||||
exit 1
|
||||
}
|
||||
174
.git_backup/hooks/fsmonitor-watchman.sample
Normal file
174
.git_backup/hooks/fsmonitor-watchman.sample
Normal file
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/perl
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
use IPC::Open2;
|
||||
|
||||
# An example hook script to integrate Watchman
|
||||
# (https://facebook.github.io/watchman/) with git to speed up detecting
|
||||
# new and modified files.
|
||||
#
|
||||
# The hook is passed a version (currently 2) and last update token
|
||||
# formatted as a string and outputs to stdout a new update token and
|
||||
# all files that have been modified since the update token. Paths must
|
||||
# be relative to the root of the working tree and separated by a single NUL.
|
||||
#
|
||||
# To enable this hook, rename this file to "query-watchman" and set
|
||||
# 'git config core.fsmonitor .git/hooks/query-watchman'
|
||||
#
|
||||
my ($version, $last_update_token) = @ARGV;
|
||||
|
||||
# Uncomment for debugging
|
||||
# print STDERR "$0 $version $last_update_token\n";
|
||||
|
||||
# Check the hook interface version
|
||||
if ($version ne 2) {
|
||||
die "Unsupported query-fsmonitor hook version '$version'.\n" .
|
||||
"Falling back to scanning...\n";
|
||||
}
|
||||
|
||||
my $git_work_tree = get_working_dir();
|
||||
|
||||
my $retry = 1;
|
||||
|
||||
my $json_pkg;
|
||||
eval {
|
||||
require JSON::XS;
|
||||
$json_pkg = "JSON::XS";
|
||||
1;
|
||||
} or do {
|
||||
require JSON::PP;
|
||||
$json_pkg = "JSON::PP";
|
||||
};
|
||||
|
||||
launch_watchman();
|
||||
|
||||
sub launch_watchman {
|
||||
my $o = watchman_query();
|
||||
if (is_work_tree_watched($o)) {
|
||||
output_result($o->{clock}, @{$o->{files}});
|
||||
}
|
||||
}
|
||||
|
||||
sub output_result {
|
||||
my ($clockid, @files) = @_;
|
||||
|
||||
# Uncomment for debugging watchman output
|
||||
# open (my $fh, ">", ".git/watchman-output.out");
|
||||
# binmode $fh, ":utf8";
|
||||
# print $fh "$clockid\n@files\n";
|
||||
# close $fh;
|
||||
|
||||
binmode STDOUT, ":utf8";
|
||||
print $clockid;
|
||||
print "\0";
|
||||
local $, = "\0";
|
||||
print @files;
|
||||
}
|
||||
|
||||
sub watchman_clock {
|
||||
my $response = qx/watchman clock "$git_work_tree"/;
|
||||
die "Failed to get clock id on '$git_work_tree'.\n" .
|
||||
"Falling back to scanning...\n" if $? != 0;
|
||||
|
||||
return $json_pkg->new->utf8->decode($response);
|
||||
}
|
||||
|
||||
sub watchman_query {
|
||||
my $pid = open2(\*CHLD_OUT, \*CHLD_IN, 'watchman -j --no-pretty')
|
||||
or die "open2() failed: $!\n" .
|
||||
"Falling back to scanning...\n";
|
||||
|
||||
# In the query expression below we're asking for names of files that
|
||||
# changed since $last_update_token but not from the .git folder.
|
||||
#
|
||||
# To accomplish this, we're using the "since" generator to use the
|
||||
# recency index to select candidate nodes and "fields" to limit the
|
||||
# output to file names only. Then we're using the "expression" term to
|
||||
# further constrain the results.
|
||||
my $last_update_line = "";
|
||||
if (substr($last_update_token, 0, 1) eq "c") {
|
||||
$last_update_token = "\"$last_update_token\"";
|
||||
$last_update_line = qq[\n"since": $last_update_token,];
|
||||
}
|
||||
my $query = <<" END";
|
||||
["query", "$git_work_tree", {$last_update_line
|
||||
"fields": ["name"],
|
||||
"expression": ["not", ["dirname", ".git"]]
|
||||
}]
|
||||
END
|
||||
|
||||
# Uncomment for debugging the watchman query
|
||||
# open (my $fh, ">", ".git/watchman-query.json");
|
||||
# print $fh $query;
|
||||
# close $fh;
|
||||
|
||||
print CHLD_IN $query;
|
||||
close CHLD_IN;
|
||||
my $response = do {local $/; <CHLD_OUT>};
|
||||
|
||||
# Uncomment for debugging the watch response
|
||||
# open ($fh, ">", ".git/watchman-response.json");
|
||||
# print $fh $response;
|
||||
# close $fh;
|
||||
|
||||
die "Watchman: command returned no output.\n" .
|
||||
"Falling back to scanning...\n" if $response eq "";
|
||||
die "Watchman: command returned invalid output: $response\n" .
|
||||
"Falling back to scanning...\n" unless $response =~ /^\{/;
|
||||
|
||||
return $json_pkg->new->utf8->decode($response);
|
||||
}
|
||||
|
||||
sub is_work_tree_watched {
|
||||
my ($output) = @_;
|
||||
my $error = $output->{error};
|
||||
if ($retry > 0 and $error and $error =~ m/unable to resolve root .* directory (.*) is not watched/) {
|
||||
$retry--;
|
||||
my $response = qx/watchman watch "$git_work_tree"/;
|
||||
die "Failed to make watchman watch '$git_work_tree'.\n" .
|
||||
"Falling back to scanning...\n" if $? != 0;
|
||||
$output = $json_pkg->new->utf8->decode($response);
|
||||
$error = $output->{error};
|
||||
die "Watchman: $error.\n" .
|
||||
"Falling back to scanning...\n" if $error;
|
||||
|
||||
# Uncomment for debugging watchman output
|
||||
# open (my $fh, ">", ".git/watchman-output.out");
|
||||
# close $fh;
|
||||
|
||||
# Watchman will always return all files on the first query so
|
||||
# return the fast "everything is dirty" flag to git and do the
|
||||
# Watchman query just to get it over with now so we won't pay
|
||||
# the cost in git to look up each individual file.
|
||||
my $o = watchman_clock();
|
||||
$error = $output->{error};
|
||||
|
||||
die "Watchman: $error.\n" .
|
||||
"Falling back to scanning...\n" if $error;
|
||||
|
||||
output_result($o->{clock}, ("/"));
|
||||
$last_update_token = $o->{clock};
|
||||
|
||||
eval { launch_watchman() };
|
||||
return 0;
|
||||
}
|
||||
|
||||
die "Watchman: $error.\n" .
|
||||
"Falling back to scanning...\n" if $error;
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
sub get_working_dir {
|
||||
my $working_dir;
|
||||
if ($^O =~ 'msys' || $^O =~ 'cygwin') {
|
||||
$working_dir = Win32::GetCwd();
|
||||
$working_dir =~ tr/\\/\//;
|
||||
} else {
|
||||
require Cwd;
|
||||
$working_dir = Cwd::cwd();
|
||||
}
|
||||
|
||||
return $working_dir;
|
||||
}
|
||||
8
.git_backup/hooks/post-update.sample
Normal file
8
.git_backup/hooks/post-update.sample
Normal file
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# An example hook script to prepare a packed repository for use over
|
||||
# dumb transports.
|
||||
#
|
||||
# To enable this hook, rename this file to "post-update".
|
||||
|
||||
exec git update-server-info
|
||||
14
.git_backup/hooks/pre-applypatch.sample
Normal file
14
.git_backup/hooks/pre-applypatch.sample
Normal file
@@ -0,0 +1,14 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# An example hook script to verify what is about to be committed
|
||||
# by applypatch from an e-mail message.
|
||||
#
|
||||
# The hook should exit with non-zero status after issuing an
|
||||
# appropriate message if it wants to stop the commit.
|
||||
#
|
||||
# To enable this hook, rename this file to "pre-applypatch".
|
||||
|
||||
. git-sh-setup
|
||||
precommit="$(git rev-parse --git-path hooks/pre-commit)"
|
||||
test -x "$precommit" && exec "$precommit" ${1+"$@"}
|
||||
:
|
||||
49
.git_backup/hooks/pre-commit.sample
Normal file
49
.git_backup/hooks/pre-commit.sample
Normal file
@@ -0,0 +1,49 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# An example hook script to verify what is about to be committed.
|
||||
# Called by "git commit" with no arguments. The hook should
|
||||
# exit with non-zero status after issuing an appropriate message if
|
||||
# it wants to stop the commit.
|
||||
#
|
||||
# To enable this hook, rename this file to "pre-commit".
|
||||
|
||||
if git rev-parse --verify HEAD >/dev/null 2>&1
|
||||
then
|
||||
against=HEAD
|
||||
else
|
||||
# Initial commit: diff against an empty tree object
|
||||
against=$(git hash-object -t tree /dev/null)
|
||||
fi
|
||||
|
||||
# If you want to allow non-ASCII filenames set this variable to true.
|
||||
allownonascii=$(git config --type=bool hooks.allownonascii)
|
||||
|
||||
# Redirect output to stderr.
|
||||
exec 1>&2
|
||||
|
||||
# Cross platform projects tend to avoid non-ASCII filenames; prevent
|
||||
# them from being added to the repository. We exploit the fact that the
|
||||
# printable range starts at the space character and ends with tilde.
|
||||
if [ "$allownonascii" != "true" ] &&
|
||||
# Note that the use of brackets around a tr range is ok here, (it's
|
||||
# even required, for portability to Solaris 10's /usr/bin/tr), since
|
||||
# the square bracket bytes happen to fall in the designated range.
|
||||
test $(git diff --cached --name-only --diff-filter=A -z $against |
|
||||
LC_ALL=C tr -d '[ -~]\0' | wc -c) != 0
|
||||
then
|
||||
cat <<\EOF
|
||||
Error: Attempt to add a non-ASCII file name.
|
||||
|
||||
This can cause problems if you want to work with people on other platforms.
|
||||
|
||||
To be portable it is advisable to rename the file.
|
||||
|
||||
If you know what you are doing you can disable this check using:
|
||||
|
||||
git config hooks.allownonascii true
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# If there are whitespace errors, print the offending file names and fail.
|
||||
exec git diff-index --check --cached $against --
|
||||
13
.git_backup/hooks/pre-merge-commit.sample
Normal file
13
.git_backup/hooks/pre-merge-commit.sample
Normal file
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# An example hook script to verify what is about to be committed.
|
||||
# Called by "git merge" with no arguments. The hook should
|
||||
# exit with non-zero status after issuing an appropriate message to
|
||||
# stderr if it wants to stop the merge commit.
|
||||
#
|
||||
# To enable this hook, rename this file to "pre-merge-commit".
|
||||
|
||||
. git-sh-setup
|
||||
test -x "$GIT_DIR/hooks/pre-commit" &&
|
||||
exec "$GIT_DIR/hooks/pre-commit"
|
||||
:
|
||||
53
.git_backup/hooks/pre-push.sample
Normal file
53
.git_backup/hooks/pre-push.sample
Normal file
@@ -0,0 +1,53 @@
|
||||
#!/bin/sh
|
||||
|
||||
# An example hook script to verify what is about to be pushed. Called by "git
|
||||
# push" after it has checked the remote status, but before anything has been
|
||||
# pushed. If this script exits with a non-zero status nothing will be pushed.
|
||||
#
|
||||
# This hook is called with the following parameters:
|
||||
#
|
||||
# $1 -- Name of the remote to which the push is being done
|
||||
# $2 -- URL to which the push is being done
|
||||
#
|
||||
# If pushing without using a named remote those arguments will be equal.
|
||||
#
|
||||
# Information about the commits which are being pushed is supplied as lines to
|
||||
# the standard input in the form:
|
||||
#
|
||||
# <local ref> <local oid> <remote ref> <remote oid>
|
||||
#
|
||||
# This sample shows how to prevent push of commits where the log message starts
|
||||
# with "WIP" (work in progress).
|
||||
|
||||
remote="$1"
|
||||
url="$2"
|
||||
|
||||
zero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0')
|
||||
|
||||
while read local_ref local_oid remote_ref remote_oid
|
||||
do
|
||||
if test "$local_oid" = "$zero"
|
||||
then
|
||||
# Handle delete
|
||||
:
|
||||
else
|
||||
if test "$remote_oid" = "$zero"
|
||||
then
|
||||
# New branch, examine all commits
|
||||
range="$local_oid"
|
||||
else
|
||||
# Update to existing branch, examine new commits
|
||||
range="$remote_oid..$local_oid"
|
||||
fi
|
||||
|
||||
# Check for WIP commit
|
||||
commit=$(git rev-list -n 1 --grep '^WIP' "$range")
|
||||
if test -n "$commit"
|
||||
then
|
||||
echo >&2 "Found WIP commit in $local_ref, not pushing"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
exit 0
|
||||
169
.git_backup/hooks/pre-rebase.sample
Normal file
169
.git_backup/hooks/pre-rebase.sample
Normal file
@@ -0,0 +1,169 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# Copyright (c) 2006, 2008 Junio C Hamano
|
||||
#
|
||||
# The "pre-rebase" hook is run just before "git rebase" starts doing
|
||||
# its job, and can prevent the command from running by exiting with
|
||||
# non-zero status.
|
||||
#
|
||||
# The hook is called with the following parameters:
|
||||
#
|
||||
# $1 -- the upstream the series was forked from.
|
||||
# $2 -- the branch being rebased (or empty when rebasing the current branch).
|
||||
#
|
||||
# This sample shows how to prevent topic branches that are already
|
||||
# merged to 'next' branch from getting rebased, because allowing it
|
||||
# would result in rebasing already published history.
|
||||
|
||||
publish=next
|
||||
basebranch="$1"
|
||||
if test "$#" = 2
|
||||
then
|
||||
topic="refs/heads/$2"
|
||||
else
|
||||
topic=`git symbolic-ref HEAD` ||
|
||||
exit 0 ;# we do not interrupt rebasing detached HEAD
|
||||
fi
|
||||
|
||||
case "$topic" in
|
||||
refs/heads/??/*)
|
||||
;;
|
||||
*)
|
||||
exit 0 ;# we do not interrupt others.
|
||||
;;
|
||||
esac
|
||||
|
||||
# Now we are dealing with a topic branch being rebased
|
||||
# on top of master. Is it OK to rebase it?
|
||||
|
||||
# Does the topic really exist?
|
||||
git show-ref -q "$topic" || {
|
||||
echo >&2 "No such branch $topic"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Is topic fully merged to master?
|
||||
not_in_master=`git rev-list --pretty=oneline ^master "$topic"`
|
||||
if test -z "$not_in_master"
|
||||
then
|
||||
echo >&2 "$topic is fully merged to master; better remove it."
|
||||
exit 1 ;# we could allow it, but there is no point.
|
||||
fi
|
||||
|
||||
# Is topic ever merged to next? If so you should not be rebasing it.
|
||||
only_next_1=`git rev-list ^master "^$topic" ${publish} | sort`
|
||||
only_next_2=`git rev-list ^master ${publish} | sort`
|
||||
if test "$only_next_1" = "$only_next_2"
|
||||
then
|
||||
not_in_topic=`git rev-list "^$topic" master`
|
||||
if test -z "$not_in_topic"
|
||||
then
|
||||
echo >&2 "$topic is already up to date with master"
|
||||
exit 1 ;# we could allow it, but there is no point.
|
||||
else
|
||||
exit 0
|
||||
fi
|
||||
else
|
||||
not_in_next=`git rev-list --pretty=oneline ^${publish} "$topic"`
|
||||
/usr/bin/perl -e '
|
||||
my $topic = $ARGV[0];
|
||||
my $msg = "* $topic has commits already merged to public branch:\n";
|
||||
my (%not_in_next) = map {
|
||||
/^([0-9a-f]+) /;
|
||||
($1 => 1);
|
||||
} split(/\n/, $ARGV[1]);
|
||||
for my $elem (map {
|
||||
/^([0-9a-f]+) (.*)$/;
|
||||
[$1 => $2];
|
||||
} split(/\n/, $ARGV[2])) {
|
||||
if (!exists $not_in_next{$elem->[0]}) {
|
||||
if ($msg) {
|
||||
print STDERR $msg;
|
||||
undef $msg;
|
||||
}
|
||||
print STDERR " $elem->[1]\n";
|
||||
}
|
||||
}
|
||||
' "$topic" "$not_in_next" "$not_in_master"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
<<\DOC_END
|
||||
|
||||
This sample hook safeguards topic branches that have been
|
||||
published from being rewound.
|
||||
|
||||
The workflow assumed here is:
|
||||
|
||||
* Once a topic branch forks from "master", "master" is never
|
||||
merged into it again (either directly or indirectly).
|
||||
|
||||
* Once a topic branch is fully cooked and merged into "master",
|
||||
it is deleted. If you need to build on top of it to correct
|
||||
earlier mistakes, a new topic branch is created by forking at
|
||||
the tip of the "master". This is not strictly necessary, but
|
||||
it makes it easier to keep your history simple.
|
||||
|
||||
* Whenever you need to test or publish your changes to topic
|
||||
branches, merge them into "next" branch.
|
||||
|
||||
The script, being an example, hardcodes the publish branch name
|
||||
to be "next", but it is trivial to make it configurable via
|
||||
$GIT_DIR/config mechanism.
|
||||
|
||||
With this workflow, you would want to know:
|
||||
|
||||
(1) ... if a topic branch has ever been merged to "next". Young
|
||||
topic branches can have stupid mistakes you would rather
|
||||
clean up before publishing, and things that have not been
|
||||
merged into other branches can be easily rebased without
|
||||
affecting other people. But once it is published, you would
|
||||
not want to rewind it.
|
||||
|
||||
(2) ... if a topic branch has been fully merged to "master".
|
||||
Then you can delete it. More importantly, you should not
|
||||
build on top of it -- other people may already want to
|
||||
change things related to the topic as patches against your
|
||||
"master", so if you need further changes, it is better to
|
||||
fork the topic (perhaps with the same name) afresh from the
|
||||
tip of "master".
|
||||
|
||||
Let's look at this example:
|
||||
|
||||
o---o---o---o---o---o---o---o---o---o "next"
|
||||
/ / / /
|
||||
/ a---a---b A / /
|
||||
/ / / /
|
||||
/ / c---c---c---c B /
|
||||
/ / / \ /
|
||||
/ / / b---b C \ /
|
||||
/ / / / \ /
|
||||
---o---o---o---o---o---o---o---o---o---o---o "master"
|
||||
|
||||
|
||||
A, B and C are topic branches.
|
||||
|
||||
* A has one fix since it was merged up to "next".
|
||||
|
||||
* B has finished. It has been fully merged up to "master" and "next",
|
||||
and is ready to be deleted.
|
||||
|
||||
* C has not merged to "next" at all.
|
||||
|
||||
We would want to allow C to be rebased, refuse A, and encourage
|
||||
B to be deleted.
|
||||
|
||||
To compute (1):
|
||||
|
||||
git rev-list ^master ^topic next
|
||||
git rev-list ^master next
|
||||
|
||||
if these match, topic has not merged in next at all.
|
||||
|
||||
To compute (2):
|
||||
|
||||
git rev-list master..topic
|
||||
|
||||
if this is empty, it is fully merged to "master".
|
||||
|
||||
DOC_END
|
||||
24
.git_backup/hooks/pre-receive.sample
Normal file
24
.git_backup/hooks/pre-receive.sample
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# An example hook script to make use of push options.
|
||||
# The example simply echoes all push options that start with 'echoback='
|
||||
# and rejects all pushes when the "reject" push option is used.
|
||||
#
|
||||
# To enable this hook, rename this file to "pre-receive".
|
||||
|
||||
if test -n "$GIT_PUSH_OPTION_COUNT"
|
||||
then
|
||||
i=0
|
||||
while test "$i" -lt "$GIT_PUSH_OPTION_COUNT"
|
||||
do
|
||||
eval "value=\$GIT_PUSH_OPTION_$i"
|
||||
case "$value" in
|
||||
echoback=*)
|
||||
echo "echo from the pre-receive-hook: ${value#*=}" >&2
|
||||
;;
|
||||
reject)
|
||||
exit 1
|
||||
esac
|
||||
i=$((i + 1))
|
||||
done
|
||||
fi
|
||||
42
.git_backup/hooks/prepare-commit-msg.sample
Normal file
42
.git_backup/hooks/prepare-commit-msg.sample
Normal file
@@ -0,0 +1,42 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# An example hook script to prepare the commit log message.
|
||||
# Called by "git commit" with the name of the file that has the
|
||||
# commit message, followed by the description of the commit
|
||||
# message's source. The hook's purpose is to edit the commit
|
||||
# message file. If the hook fails with a non-zero status,
|
||||
# the commit is aborted.
|
||||
#
|
||||
# To enable this hook, rename this file to "prepare-commit-msg".
|
||||
|
||||
# This hook includes three examples. The first one removes the
|
||||
# "# Please enter the commit message..." help message.
|
||||
#
|
||||
# The second includes the output of "git diff --name-status -r"
|
||||
# into the message, just before the "git status" output. It is
|
||||
# commented because it doesn't cope with --amend or with squashed
|
||||
# commits.
|
||||
#
|
||||
# The third example adds a Signed-off-by line to the message, that can
|
||||
# still be edited. This is rarely a good idea.
|
||||
|
||||
COMMIT_MSG_FILE=$1
|
||||
COMMIT_SOURCE=$2
|
||||
SHA1=$3
|
||||
|
||||
/usr/bin/perl -i.bak -ne 'print unless(m/^. Please enter the commit message/..m/^#$/)' "$COMMIT_MSG_FILE"
|
||||
|
||||
# case "$COMMIT_SOURCE,$SHA1" in
|
||||
# ,|template,)
|
||||
# /usr/bin/perl -i.bak -pe '
|
||||
# print "\n" . `git diff --cached --name-status -r`
|
||||
# if /^#/ && $first++ == 0' "$COMMIT_MSG_FILE" ;;
|
||||
# *) ;;
|
||||
# esac
|
||||
|
||||
# SOB=$(git var GIT_COMMITTER_IDENT | sed -n 's/^\(.*>\).*$/Signed-off-by: \1/p')
|
||||
# git interpret-trailers --in-place --trailer "$SOB" "$COMMIT_MSG_FILE"
|
||||
# if test -z "$COMMIT_SOURCE"
|
||||
# then
|
||||
# /usr/bin/perl -i.bak -pe 'print "\n" if !$first_line++' "$COMMIT_MSG_FILE"
|
||||
# fi
|
||||
78
.git_backup/hooks/push-to-checkout.sample
Normal file
78
.git_backup/hooks/push-to-checkout.sample
Normal file
@@ -0,0 +1,78 @@
|
||||
#!/bin/sh
|
||||
|
||||
# An example hook script to update a checked-out tree on a git push.
|
||||
#
|
||||
# This hook is invoked by git-receive-pack(1) when it reacts to git
|
||||
# push and updates reference(s) in its repository, and when the push
|
||||
# tries to update the branch that is currently checked out and the
|
||||
# receive.denyCurrentBranch configuration variable is set to
|
||||
# updateInstead.
|
||||
#
|
||||
# By default, such a push is refused if the working tree and the index
|
||||
# of the remote repository has any difference from the currently
|
||||
# checked out commit; when both the working tree and the index match
|
||||
# the current commit, they are updated to match the newly pushed tip
|
||||
# of the branch. This hook is to be used to override the default
|
||||
# behaviour; however the code below reimplements the default behaviour
|
||||
# as a starting point for convenient modification.
|
||||
#
|
||||
# The hook receives the commit with which the tip of the current
|
||||
# branch is going to be updated:
|
||||
commit=$1
|
||||
|
||||
# It can exit with a non-zero status to refuse the push (when it does
|
||||
# so, it must not modify the index or the working tree).
|
||||
die () {
|
||||
echo >&2 "$*"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Or it can make any necessary changes to the working tree and to the
|
||||
# index to bring them to the desired state when the tip of the current
|
||||
# branch is updated to the new commit, and exit with a zero status.
|
||||
#
|
||||
# For example, the hook can simply run git read-tree -u -m HEAD "$1"
|
||||
# in order to emulate git fetch that is run in the reverse direction
|
||||
# with git push, as the two-tree form of git read-tree -u -m is
|
||||
# essentially the same as git switch or git checkout that switches
|
||||
# branches while keeping the local changes in the working tree that do
|
||||
# not interfere with the difference between the branches.
|
||||
|
||||
# The below is a more-or-less exact translation to shell of the C code
|
||||
# for the default behaviour for git's push-to-checkout hook defined in
|
||||
# the push_to_deploy() function in builtin/receive-pack.c.
|
||||
#
|
||||
# Note that the hook will be executed from the repository directory,
|
||||
# not from the working tree, so if you want to perform operations on
|
||||
# the working tree, you will have to adapt your code accordingly, e.g.
|
||||
# by adding "cd .." or using relative paths.
|
||||
|
||||
if ! git update-index -q --ignore-submodules --refresh
|
||||
then
|
||||
die "Up-to-date check failed"
|
||||
fi
|
||||
|
||||
if ! git diff-files --quiet --ignore-submodules --
|
||||
then
|
||||
die "Working directory has unstaged changes"
|
||||
fi
|
||||
|
||||
# This is a rough translation of:
|
||||
#
|
||||
# head_has_history() ? "HEAD" : EMPTY_TREE_SHA1_HEX
|
||||
if git cat-file -e HEAD 2>/dev/null
|
||||
then
|
||||
head=HEAD
|
||||
else
|
||||
head=$(git hash-object -t tree --stdin </dev/null)
|
||||
fi
|
||||
|
||||
if ! git diff-index --quiet --cached --ignore-submodules $head --
|
||||
then
|
||||
die "Working directory has staged changes"
|
||||
fi
|
||||
|
||||
if ! git read-tree -u -m "$commit"
|
||||
then
|
||||
die "Could not update working tree to new HEAD"
|
||||
fi
|
||||
77
.git_backup/hooks/sendemail-validate.sample
Normal file
77
.git_backup/hooks/sendemail-validate.sample
Normal file
@@ -0,0 +1,77 @@
|
||||
#!/bin/sh
|
||||
|
||||
# An example hook script to validate a patch (and/or patch series) before
|
||||
# sending it via email.
|
||||
#
|
||||
# The hook should exit with non-zero status after issuing an appropriate
|
||||
# message if it wants to prevent the email(s) from being sent.
|
||||
#
|
||||
# To enable this hook, rename this file to "sendemail-validate".
|
||||
#
|
||||
# By default, it will only check that the patch(es) can be applied on top of
|
||||
# the default upstream branch without conflicts in a secondary worktree. After
|
||||
# validation (successful or not) of the last patch of a series, the worktree
|
||||
# will be deleted.
|
||||
#
|
||||
# The following config variables can be set to change the default remote and
|
||||
# remote ref that are used to apply the patches against:
|
||||
#
|
||||
# sendemail.validateRemote (default: origin)
|
||||
# sendemail.validateRemoteRef (default: HEAD)
|
||||
#
|
||||
# Replace the TODO placeholders with appropriate checks according to your
|
||||
# needs.
|
||||
|
||||
validate_cover_letter () {
|
||||
file="$1"
|
||||
# TODO: Replace with appropriate checks (e.g. spell checking).
|
||||
true
|
||||
}
|
||||
|
||||
validate_patch () {
|
||||
file="$1"
|
||||
# Ensure that the patch applies without conflicts.
|
||||
git am -3 "$file" || return
|
||||
# TODO: Replace with appropriate checks for this patch
|
||||
# (e.g. checkpatch.pl).
|
||||
true
|
||||
}
|
||||
|
||||
validate_series () {
|
||||
# TODO: Replace with appropriate checks for the whole series
|
||||
# (e.g. quick build, coding style checks, etc.).
|
||||
true
|
||||
}
|
||||
|
||||
# main -------------------------------------------------------------------------
|
||||
|
||||
if test "$GIT_SENDEMAIL_FILE_COUNTER" = 1
|
||||
then
|
||||
remote=$(git config --default origin --get sendemail.validateRemote) &&
|
||||
ref=$(git config --default HEAD --get sendemail.validateRemoteRef) &&
|
||||
worktree=$(mktemp --tmpdir -d sendemail-validate.XXXXXXX) &&
|
||||
git worktree add -fd --checkout "$worktree" "refs/remotes/$remote/$ref" &&
|
||||
git config --replace-all sendemail.validateWorktree "$worktree"
|
||||
else
|
||||
worktree=$(git config --get sendemail.validateWorktree)
|
||||
fi || {
|
||||
echo "sendemail-validate: error: failed to prepare worktree" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
unset GIT_DIR GIT_WORK_TREE
|
||||
cd "$worktree" &&
|
||||
|
||||
if grep -q "^diff --git " "$1"
|
||||
then
|
||||
validate_patch "$1"
|
||||
else
|
||||
validate_cover_letter "$1"
|
||||
fi &&
|
||||
|
||||
if test "$GIT_SENDEMAIL_FILE_COUNTER" = "$GIT_SENDEMAIL_FILE_TOTAL"
|
||||
then
|
||||
git config --unset-all sendemail.validateWorktree &&
|
||||
trap 'git worktree remove -ff "$worktree"' EXIT &&
|
||||
validate_series
|
||||
fi
|
||||
128
.git_backup/hooks/update.sample
Normal file
128
.git_backup/hooks/update.sample
Normal file
@@ -0,0 +1,128 @@
|
||||
#!/bin/sh
|
||||
#
|
||||
# An example hook script to block unannotated tags from entering.
|
||||
# Called by "git receive-pack" with arguments: refname sha1-old sha1-new
|
||||
#
|
||||
# To enable this hook, rename this file to "update".
|
||||
#
|
||||
# Config
|
||||
# ------
|
||||
# hooks.allowunannotated
|
||||
# This boolean sets whether unannotated tags will be allowed into the
|
||||
# repository. By default they won't be.
|
||||
# hooks.allowdeletetag
|
||||
# This boolean sets whether deleting tags will be allowed in the
|
||||
# repository. By default they won't be.
|
||||
# hooks.allowmodifytag
|
||||
# This boolean sets whether a tag may be modified after creation. By default
|
||||
# it won't be.
|
||||
# hooks.allowdeletebranch
|
||||
# This boolean sets whether deleting branches will be allowed in the
|
||||
# repository. By default they won't be.
|
||||
# hooks.denycreatebranch
|
||||
# This boolean sets whether remotely creating branches will be denied
|
||||
# in the repository. By default this is allowed.
|
||||
#
|
||||
|
||||
# --- Command line
|
||||
refname="$1"
|
||||
oldrev="$2"
|
||||
newrev="$3"
|
||||
|
||||
# --- Safety check
|
||||
if [ -z "$GIT_DIR" ]; then
|
||||
echo "Don't run this script from the command line." >&2
|
||||
echo " (if you want, you could supply GIT_DIR then run" >&2
|
||||
echo " $0 <ref> <oldrev> <newrev>)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$refname" -o -z "$oldrev" -o -z "$newrev" ]; then
|
||||
echo "usage: $0 <ref> <oldrev> <newrev>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- Config
|
||||
allowunannotated=$(git config --type=bool hooks.allowunannotated)
|
||||
allowdeletebranch=$(git config --type=bool hooks.allowdeletebranch)
|
||||
denycreatebranch=$(git config --type=bool hooks.denycreatebranch)
|
||||
allowdeletetag=$(git config --type=bool hooks.allowdeletetag)
|
||||
allowmodifytag=$(git config --type=bool hooks.allowmodifytag)
|
||||
|
||||
# check for no description
|
||||
projectdesc=$(sed -e '1q' "$GIT_DIR/description")
|
||||
case "$projectdesc" in
|
||||
"Unnamed repository"* | "")
|
||||
echo "*** Project description file hasn't been set" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# --- Check types
|
||||
# if $newrev is 0000...0000, it's a commit to delete a ref.
|
||||
zero=$(git hash-object --stdin </dev/null | tr '[0-9a-f]' '0')
|
||||
if [ "$newrev" = "$zero" ]; then
|
||||
newrev_type=delete
|
||||
else
|
||||
newrev_type=$(git cat-file -t $newrev)
|
||||
fi
|
||||
|
||||
case "$refname","$newrev_type" in
|
||||
refs/tags/*,commit)
|
||||
# un-annotated tag
|
||||
short_refname=${refname##refs/tags/}
|
||||
if [ "$allowunannotated" != "true" ]; then
|
||||
echo "*** The un-annotated tag, $short_refname, is not allowed in this repository" >&2
|
||||
echo "*** Use 'git tag [ -a | -s ]' for tags you want to propagate." >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
refs/tags/*,delete)
|
||||
# delete tag
|
||||
if [ "$allowdeletetag" != "true" ]; then
|
||||
echo "*** Deleting a tag is not allowed in this repository" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
refs/tags/*,tag)
|
||||
# annotated tag
|
||||
if [ "$allowmodifytag" != "true" ] && git rev-parse $refname > /dev/null 2>&1
|
||||
then
|
||||
echo "*** Tag '$refname' already exists." >&2
|
||||
echo "*** Modifying a tag is not allowed in this repository." >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
refs/heads/*,commit)
|
||||
# branch
|
||||
if [ "$oldrev" = "$zero" -a "$denycreatebranch" = "true" ]; then
|
||||
echo "*** Creating a branch is not allowed in this repository" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
refs/heads/*,delete)
|
||||
# delete branch
|
||||
if [ "$allowdeletebranch" != "true" ]; then
|
||||
echo "*** Deleting a branch is not allowed in this repository" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
refs/remotes/*,commit)
|
||||
# tracking branch
|
||||
;;
|
||||
refs/remotes/*,delete)
|
||||
# delete tracking branch
|
||||
if [ "$allowdeletebranch" != "true" ]; then
|
||||
echo "*** Deleting a tracking branch is not allowed in this repository" >&2
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
# Anything else (is there anything else?)
|
||||
echo "*** Update hook: unknown type of update to ref $refname of type $newrev_type" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# --- Finished
|
||||
exit 0
|
||||
6
.git_backup/info/exclude
Normal file
6
.git_backup/info/exclude
Normal file
@@ -0,0 +1,6 @@
|
||||
# git ls-files --others --exclude-from=.git/info/exclude
|
||||
# Lines that start with '#' are comments.
|
||||
# For a project mostly in C, the following would be a good set of
|
||||
# exclude patterns (uncomment them if you want to use them):
|
||||
# *.[oa]
|
||||
# *~
|
||||
BIN
.git_backup/objects/06/2e42af78e98184c84ceee014021367c1c6dcd0
Normal file
BIN
.git_backup/objects/06/2e42af78e98184c84ceee014021367c1c6dcd0
Normal file
Binary file not shown.
BIN
.git_backup/objects/1c/c18c6e963293a2dcd9e24e02f790feef197cf0
Normal file
BIN
.git_backup/objects/1c/c18c6e963293a2dcd9e24e02f790feef197cf0
Normal file
Binary file not shown.
BIN
.git_backup/objects/2d/d3a98c2b2e5c2aa16050173244b8b79b5b5b97
Normal file
BIN
.git_backup/objects/2d/d3a98c2b2e5c2aa16050173244b8b79b5b5b97
Normal file
Binary file not shown.
BIN
.git_backup/objects/2e/7af2b7f1a6f391da1631d93968a9d487ba977d
Normal file
BIN
.git_backup/objects/2e/7af2b7f1a6f391da1631d93968a9d487ba977d
Normal file
Binary file not shown.
BIN
.git_backup/objects/38/6cd354505b8fc74c0d54638d9502e9f9e09e88
Normal file
BIN
.git_backup/objects/38/6cd354505b8fc74c0d54638d9502e9f9e09e88
Normal file
Binary file not shown.
BIN
.git_backup/objects/5a/ce2ba16d06d24e68f90ec91dd69f4a9ff5b5e3
Normal file
BIN
.git_backup/objects/5a/ce2ba16d06d24e68f90ec91dd69f4a9ff5b5e3
Normal file
Binary file not shown.
BIN
.git_backup/objects/60/18e701fc7dd0317cda9eceea390524322e8a05
Normal file
BIN
.git_backup/objects/60/18e701fc7dd0317cda9eceea390524322e8a05
Normal file
Binary file not shown.
BIN
.git_backup/objects/77/5e685a40548ca6c3e72888756d7ebac523e1a3
Normal file
BIN
.git_backup/objects/77/5e685a40548ca6c3e72888756d7ebac523e1a3
Normal file
Binary file not shown.
@@ -0,0 +1,5 @@
|
||||
xu<>ANÃ0EYç#u›tS±@
|
||||
R’F"‰¨I*±²&鈘&¶±<C2B6>Hì8àÜ 7á$8¨@eë?ž÷þÔ<C3BE>¬a±8?™Á5ß½ŽÄ
|
||||
l¾æ$,Áûó”›ma‰:•òfKŠȸõãÀG
|
||||
5‚Ob¥‰DÓ"4²Cƒ<43>;©Ém–P
|
||||
k4ä¹=érzQZ><3E>ýIàÔý–Âjt!õ<>Oyc¡ k¹¸7p ažžyë´LXQåa ËW·×I\²t8$û\ÚXÆ7<0C>áD‹[<1C>°Ø½ Þã¡Ëÿ':î—Šã֞̉*ÊÒâ*Œ²„Ý$w<><19>¡<EFBFBD>‚¹áo<C3A1>j•«ý«<C3BD>›
|
||||
BIN
.git_backup/objects/86/c00d8d826752dceab1828c339005331a5e1fa9
Normal file
BIN
.git_backup/objects/86/c00d8d826752dceab1828c339005331a5e1fa9
Normal file
Binary file not shown.
BIN
.git_backup/objects/89/20235c81c69c7eb8dc8a4caa83854bc4a0b39b
Normal file
BIN
.git_backup/objects/89/20235c81c69c7eb8dc8a4caa83854bc4a0b39b
Normal file
Binary file not shown.
BIN
.git_backup/objects/8d/3f4d64fe7595a14d7e343abc2b45aa776658b5
Normal file
BIN
.git_backup/objects/8d/3f4d64fe7595a14d7e343abc2b45aa776658b5
Normal file
Binary file not shown.
BIN
.git_backup/objects/a3/1cdf6bed1d62fc24565e51e2ec24f199e0af64
Normal file
BIN
.git_backup/objects/a3/1cdf6bed1d62fc24565e51e2ec24f199e0af64
Normal file
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<E28093>uL`ßŲ.•å‰2BpS£<53> _eþ1«È’@˜A“—¢ÕøÆÏ6es‘¥@ŠùÓ¶ºÜI(ÿýoôùÎNEña<C3B1>Î)8!È1ç{Þ2Ú‹œ"ŒóÎá…]´j%Wš0&wïXÍ’è7/¿1ê<31>ŒOŸx×Þ›ãü…’l
|
||||
BIN
.git_backup/objects/b9/d355df2a5956b526c004531b7b0ffe412461e0
Normal file
BIN
.git_backup/objects/b9/d355df2a5956b526c004531b7b0ffe412461e0
Normal file
Binary file not shown.
BIN
.git_backup/objects/bd/ac90ec07e84bc8978a29606cb9d93fc37e95c1
Normal file
BIN
.git_backup/objects/bd/ac90ec07e84bc8978a29606cb9d93fc37e95c1
Normal file
Binary file not shown.
BIN
.git_backup/objects/c5/6b036eb6b19033612961b34a20264915fe3c42
Normal file
BIN
.git_backup/objects/c5/6b036eb6b19033612961b34a20264915fe3c42
Normal file
Binary file not shown.
BIN
.git_backup/objects/cb/4c622691409ee21bd6490f04a5c7919cf86495
Normal file
BIN
.git_backup/objects/cb/4c622691409ee21bd6490f04a5c7919cf86495
Normal file
Binary file not shown.
BIN
.git_backup/objects/cd/353c06d2833a05138a2b4972ac6b236539ce2c
Normal file
BIN
.git_backup/objects/cd/353c06d2833a05138a2b4972ac6b236539ce2c
Normal file
Binary file not shown.
BIN
.git_backup/objects/e0/98c12c872f938af048bec133bb66fa42bef6d6
Normal file
BIN
.git_backup/objects/e0/98c12c872f938af048bec133bb66fa42bef6d6
Normal file
Binary file not shown.
BIN
.git_backup/objects/e2/635672613b653e462be685291dcbb882b35458
Normal file
BIN
.git_backup/objects/e2/635672613b653e462be685291dcbb882b35458
Normal file
Binary file not shown.
BIN
.git_backup/objects/e6/7846f70fbbe40407fc84875913595ab31c4a47
Normal file
BIN
.git_backup/objects/e6/7846f70fbbe40407fc84875913595ab31c4a47
Normal file
Binary file not shown.
BIN
.git_backup/objects/e9/b307e9c14f6ade86bbc16dfcf07d0a690930cd
Normal file
BIN
.git_backup/objects/e9/b307e9c14f6ade86bbc16dfcf07d0a690930cd
Normal file
Binary file not shown.
@@ -0,0 +1 @@
|
||||
x]P½NΔ0fΎ§¨"FΤΣMHΌ{’Ί4ΰ&Uμ<55>Cθήύμ&a`³Ώ?²Ημ‡—ησΓοiΜ#…Vg.ƒY<C692>7ΊX»Η‘7…4†ΌΪΚ<CEAA><CE9A>”“yRρ‚κ'<27>έ<EFBFBD>\ΡBA°Ω!Α΅bΊΚΞeo«‹ψΣ$<24><>•<EFBFBD><E280A2>Σ?4‡'η™<>HΥ©€J°β…먀ΖηΑ[Ζ\:†Ξ<E280A0>θέ•θ<‚Ϊ{ρl樽<C2A8>‘<EFBFBD>ΫQΤa”$ώ[·<> I
|
||||
48
.gitignore
vendored
Normal file
48
.gitignore
vendored
Normal file
@@ -0,0 +1,48 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor e IDEs
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Ferramentas Low-Code (Lovable/Trae)
|
||||
.trae/
|
||||
|
||||
# Deploy platforms não utilizadas
|
||||
.vercel/
|
||||
.netlify/
|
||||
|
||||
# Scripts locais de desenvolvimento
|
||||
scripts/auto-sync.ps1
|
||||
scripts/auto-synchome.ps1
|
||||
|
||||
# Variáveis de ambiente sensíveis
|
||||
.env
|
||||
.env.local
|
||||
.env.production.local
|
||||
.env.development.local
|
||||
|
||||
# Arquivos de build Docker locais
|
||||
.dockerignore.local
|
||||
|
||||
# Arquivos temporários
|
||||
*.tmp
|
||||
*.temp
|
||||
48
Dockerfile
Normal file
48
Dockerfile
Normal file
@@ -0,0 +1,48 @@
|
||||
# ============================================================
|
||||
# TrackSteel App — Dockerfile Multi-Stage
|
||||
# Stage 1: Build (Node.js) → Stage 2: Serve (Nginx Alpine)
|
||||
# ============================================================
|
||||
|
||||
# --- STAGE 1: BUILD ---
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copiar apenas package files primeiro (para cache de dependências)
|
||||
COPY package.json package-lock.json ./
|
||||
|
||||
# Instalar dependências (usando legacy-peer-deps para resolver conflitos de versões de peer dependencies)
|
||||
RUN npm install --legacy-peer-deps
|
||||
|
||||
# Copiar código-fonte
|
||||
COPY . .
|
||||
|
||||
# Variáveis de ambiente para o build (injetadas pelo Coolify)
|
||||
ARG VITE_SUPABASE_URL
|
||||
ARG VITE_SUPABASE_PUBLISHABLE_KEY
|
||||
ARG VITE_SUPABASE_PROJECT_ID
|
||||
|
||||
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
|
||||
ENV VITE_SUPABASE_PUBLISHABLE_KEY=$VITE_SUPABASE_PUBLISHABLE_KEY
|
||||
ENV VITE_SUPABASE_PROJECT_ID=$VITE_SUPABASE_PROJECT_ID
|
||||
|
||||
# Build de produção
|
||||
RUN npm run build
|
||||
|
||||
# --- STAGE 2: SERVE ---
|
||||
FROM nginx:alpine AS production
|
||||
|
||||
# Remover config padrão do Nginx
|
||||
RUN rm /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copiar configuração customizada
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copiar build estático do stage anterior
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
# Expor porta 80
|
||||
EXPOSE 80
|
||||
|
||||
# Iniciar Nginx em foreground
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
85
README.md
Normal file
85
README.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# TrackSteel — Plataforma de Gestão Industrial
|
||||
|
||||
Sistema de gestão industrial para fabricação de estruturas metálicas. Controle completo de Ordens de Fabricação (OFs), peças, estoque, produção, expedição e muito mais.
|
||||
|
||||
## Stack Tecnológica
|
||||
|
||||
- **Frontend:** React 18 + TypeScript + Vite
|
||||
- **UI:** shadcn/ui + TailwindCSS
|
||||
- **Backend:** Supabase (PostgreSQL + Auth + Storage)
|
||||
- **Estado:** TanStack React Query v5
|
||||
- **Roteamento:** React Router DOM v6
|
||||
- **PDF:** jsPDF + html2canvas
|
||||
- **Gráficos:** Recharts
|
||||
- **Drag & Drop:** react-beautiful-dnd
|
||||
|
||||
## Módulos do Sistema
|
||||
|
||||
- **Dashboard** — Visão geral e KPIs
|
||||
- **Cadastro de OFs** — Ordens de Fabricação com peças e processos
|
||||
- **Cadastro de Peças** — Biblioteca de peças e componentes
|
||||
- **Estoque** — Controle de materiais e movimentações
|
||||
- **Produção** — Apontamentos, diário e painel industrial
|
||||
- **Expedição** — Romaneios e entregas
|
||||
- **Obras** — Gestão de projetos e instalações
|
||||
- **Tarefas** — Gestão de tarefas e atribuições
|
||||
- **Biblioteca** — Normas, catálogos e referências técnicas
|
||||
- **Admin** — Gestão de usuários e permissões
|
||||
|
||||
## Configuração Local
|
||||
|
||||
### Pré-requisitos
|
||||
|
||||
- Node.js >= 20
|
||||
- npm >= 10
|
||||
|
||||
### Instalação
|
||||
|
||||
```sh
|
||||
# 1. Clone o repositório
|
||||
git clone <URL_DO_REPO>
|
||||
cd tracksteel-app
|
||||
|
||||
# 2. Instale as dependências
|
||||
npm install
|
||||
|
||||
# 3. Configure as variáveis de ambiente
|
||||
cp .env.example .env
|
||||
# Edite .env com suas credenciais do Supabase
|
||||
|
||||
# 4. Inicie o servidor de desenvolvimento
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Variáveis de Ambiente Obrigatórias
|
||||
|
||||
```env
|
||||
VITE_SUPABASE_PROJECT_ID=seu_project_id
|
||||
VITE_SUPABASE_PUBLISHABLE_KEY=sua_anon_key
|
||||
VITE_SUPABASE_URL=https://seu_project_id.supabase.co
|
||||
```
|
||||
|
||||
## Deploy (Coolify + VPS)
|
||||
|
||||
O projeto utiliza Docker para deploy via Coolify:
|
||||
|
||||
```sh
|
||||
# Build da imagem
|
||||
docker build -t tracksteel-app .
|
||||
|
||||
# Executar localmente
|
||||
docker run -p 80:80 \
|
||||
-e VITE_SUPABASE_URL=... \
|
||||
-e VITE_SUPABASE_PUBLISHABLE_KEY=... \
|
||||
tracksteel-app
|
||||
```
|
||||
|
||||
## Scripts Disponíveis
|
||||
|
||||
```sh
|
||||
npm run dev # Servidor de desenvolvimento
|
||||
npm run build # Build de produção
|
||||
npm run lint # Verificação de código
|
||||
npm run preview # Preview do build
|
||||
```
|
||||
- Último teste de deploy em: Wed Mar 18 17:28:14 UTC 2026
|
||||
20
components.json
Normal file
20
components.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/index.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
29
eslint.config.js
Normal file
29
eslint.config.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
},
|
||||
}
|
||||
);
|
||||
25
index.html
Normal file
25
index.html
Normal file
@@ -0,0 +1,25 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" href="/favicon.png" />
|
||||
<title>TrackSteel — Gestão Industrial de Estruturas Metálicas</title>
|
||||
<meta name="description"
|
||||
content="TrackSteel: plataforma de gestão industrial para fabricação de estruturas metálicas. Controle de OFs, peças, estoque, produção e expedição." />
|
||||
<meta name="author" content="TrackSteel" />
|
||||
|
||||
<meta property="og:title" content="TrackSteel" />
|
||||
<meta property="og:description" content="Plataforma de gestão industrial para fabricação de estruturas metálicas." />
|
||||
<meta property="og:type" content="website" />
|
||||
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
67
nginx.conf
Normal file
67
nginx.conf
Normal file
@@ -0,0 +1,67 @@
|
||||
# ============================================================
|
||||
# TrackSteel App — Nginx Configuration
|
||||
# SPA routing + performance + segurança
|
||||
# ============================================================
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# ---- Compressão Gzip ----
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_min_length 256;
|
||||
gzip_types
|
||||
text/plain
|
||||
text/css
|
||||
text/javascript
|
||||
application/javascript
|
||||
application/json
|
||||
application/xml
|
||||
image/svg+xml
|
||||
font/woff2;
|
||||
|
||||
# ---- Headers de Segurança ----
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# ---- Cache de Assets Estáticos ----
|
||||
# Arquivos com hash no nome (JS, CSS gerados pelo Vite) — cache longo
|
||||
location ~* \.(?:js|css)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Fontes e imagens — cache médio
|
||||
location ~* \.(?:woff2?|ttf|eot|otf|ico|png|jpg|jpeg|gif|svg|webp)$ {
|
||||
expires 6M;
|
||||
add_header Cache-Control "public";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# ---- SPA Routing ----
|
||||
# Qualquer rota que não corresponda a um arquivo redireciona pro index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# ---- Bloquear acesso a arquivos sensíveis ----
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
|
||||
# ---- Health Check ----
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "OK";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
}
|
||||
7240
package-lock.json
generated
Normal file
7240
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
84
package.json
Normal file
84
package.json
Normal file
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"name": "tracksteel-app",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"build:dev": "vite build --mode development",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@radix-ui/react-accordion": "^1.2.12",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@supabase/supabase-js": "^2.98.0",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"@xyflow/react": "^12.10.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"date-fns-tz": "^3.2.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"jspdf": "^4.2.0",
|
||||
"jspdf-autotable": "^5.0.7",
|
||||
"lucide-react": "^0.577.0",
|
||||
"react": "^18.3.1",
|
||||
"react-beautiful-dnd": "^13.1.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-dropzone": "^15.0.0",
|
||||
"react-hook-form": "^7.71.2",
|
||||
"react-is": "^19.2.4",
|
||||
"react-router-dom": "^6.26.2",
|
||||
"recharts": "^3.8.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.25.76"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/node": "^25.3.5",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-beautiful-dnd": "^13.1.8",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react-swc": "^4.2.3",
|
||||
"autoprefixer": "^10.4.27",
|
||||
"caniuse-lite": "^1.0.30001777",
|
||||
"eslint": "^10.0.3",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.4.0",
|
||||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.56.1",
|
||||
"vite": "^6.4.1"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 316 KiB |
14
public/robots.txt
Normal file
14
public/robots.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
User-agent: Googlebot
|
||||
Allow: /
|
||||
|
||||
User-agent: Bingbot
|
||||
Allow: /
|
||||
|
||||
User-agent: Twitterbot
|
||||
Allow: /
|
||||
|
||||
User-agent: facebookexternalhit
|
||||
Allow: /
|
||||
|
||||
User-agent: *
|
||||
Allow: /
|
||||
164
scripts/package-lock.json
generated
Normal file
164
scripts/package-lock.json
generated
Normal file
@@ -0,0 +1,164 @@
|
||||
{
|
||||
"name": "database-scripts",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "database-scripts",
|
||||
"version": "1.0.0",
|
||||
"hasInstallScript": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.39.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/auth-js": {
|
||||
"version": "2.71.1",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.71.1.tgz",
|
||||
"integrity": "sha512-mMIQHBRc+SKpZFRB2qtupuzulaUhFYupNyxqDj5Jp/LyPvcWvjaJzZzObv6URtL/O6lPxkanASnotGtNpS3H2Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/node-fetch": "^2.6.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/functions-js": {
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.4.5.tgz",
|
||||
"integrity": "sha512-v5GSqb9zbosquTo6gBwIiq7W9eQ7rE5QazsK/ezNiQXdCbY+bH8D9qEaBIkhVvX4ZRW5rP03gEfw5yw9tiq4EQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/node-fetch": "^2.6.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/node-fetch": {
|
||||
"version": "2.6.15",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/node-fetch/-/node-fetch-2.6.15.tgz",
|
||||
"integrity": "sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/postgrest-js": {
|
||||
"version": "1.21.3",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-1.21.3.tgz",
|
||||
"integrity": "sha512-rg3DmmZQKEVCreXq6Am29hMVe1CzemXyIWVYyyua69y6XubfP+DzGfLxME/1uvdgwqdoaPbtjBDpEBhqxq1ZwA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/node-fetch": "^2.6.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/realtime-js": {
|
||||
"version": "2.15.5",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.15.5.tgz",
|
||||
"integrity": "sha512-/Rs5Vqu9jejRD8ZeuaWXebdkH+J7V6VySbCZ/zQM93Ta5y3mAmocjioa/nzlB6qvFmyylUgKVS1KpE212t30OA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/node-fetch": "^2.6.13",
|
||||
"@types/phoenix": "^1.6.6",
|
||||
"@types/ws": "^8.18.1",
|
||||
"ws": "^8.18.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/storage-js": {
|
||||
"version": "2.12.0",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.12.0.tgz",
|
||||
"integrity": "sha512-HdKq8jAARnZ/OokE0wml/KzLwJ1X/iX7GtfLvve1HHxxsB3Y0juk0+3dMKr0mKRpjiGzzgvHhF2hxt9ui17OUQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/node-fetch": "^2.6.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/supabase-js": {
|
||||
"version": "2.57.3",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.57.3.tgz",
|
||||
"integrity": "sha512-gROsjAJ9ckeBpsLyMwK9plaZjw1uhlGgKp2EEQRJryPmI0jpKoGc07rvZ7KF8nk7H8UCwvUeYl68Fiw6M13tNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/auth-js": "2.71.1",
|
||||
"@supabase/functions-js": "2.4.5",
|
||||
"@supabase/node-fetch": "2.6.15",
|
||||
"@supabase/postgrest-js": "1.21.3",
|
||||
"@supabase/realtime-js": "2.15.5",
|
||||
"@supabase/storage-js": "^2.10.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz",
|
||||
"integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/phoenix": {
|
||||
"version": "1.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.6.tgz",
|
||||
"integrity": "sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/tr46": {
|
||||
"version": "0.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.10.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz",
|
||||
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/webidl-conversions": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/whatwg-url": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
scripts/package.json
Normal file
21
scripts/package.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "database-scripts",
|
||||
"version": "1.0.0",
|
||||
"description": "Scripts administrativos para operações no banco de dados",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"update-b101-fase9": "node update_apontamentos_b101_fase9.js",
|
||||
"install": "npm install"
|
||||
},
|
||||
"dependencies": {
|
||||
"@supabase/supabase-js": "^2.39.0"
|
||||
},
|
||||
"keywords": [
|
||||
"supabase",
|
||||
"database",
|
||||
"scripts",
|
||||
"admin"
|
||||
],
|
||||
"author": "Sistema de Produção",
|
||||
"license": "ISC"
|
||||
}
|
||||
209
scripts/update_apontamentos_b101_fase9.js
Normal file
209
scripts/update_apontamentos_b101_fase9.js
Normal file
@@ -0,0 +1,209 @@
|
||||
const { createClient } = require('@supabase/supabase-js');
|
||||
const readline = require('readline');
|
||||
|
||||
// Configuração do Supabase
|
||||
const supabaseUrl = 'https://lwjppiicofojfcdfjsto.supabase.co';
|
||||
const supabaseServiceKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imx3anBwaWljb2ZvamZjZGZqc3RvIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc1MDQ2MzA1MywiZXhwIjoyMDY2MDM5MDUzfQ.t9vlXHQH4ou2S-CKSeDYSnAeMDYpmkklqlwyGDvpocI';
|
||||
|
||||
// Inicializar cliente Supabase com service role para operações administrativas
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Interface para entrada do usuário
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
// Função para solicitar confirmação do usuário
|
||||
function askConfirmation(question) {
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes' || answer.toLowerCase() === 's' || answer.toLowerCase() === 'sim');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Função principal
|
||||
async function updateApontamentosB101Fase9() {
|
||||
console.log('🔧 Script de Atualização de Apontamentos - OF B101 Fase 9');
|
||||
console.log('=' .repeat(60));
|
||||
console.log('Objetivo: Alterar data de 08/09/2025 para 10/09/2025');
|
||||
console.log('Filtro: Apenas peças com marca entre 10 e 70');
|
||||
console.log('');
|
||||
|
||||
try {
|
||||
// Passo 1: Buscar registros que serão afetados
|
||||
console.log('📋 Passo 1: Identificando registros a serem atualizados...');
|
||||
|
||||
const { data: registrosParaAtualizar, error: selectError } = await supabase
|
||||
.from('apontamentos_producao')
|
||||
.select(`
|
||||
id,
|
||||
of_number,
|
||||
data_apontamento,
|
||||
quantidade_produzida,
|
||||
observacoes,
|
||||
pecas!inner(
|
||||
id,
|
||||
of_number,
|
||||
etapa_fase,
|
||||
marca,
|
||||
descricao
|
||||
)
|
||||
`)
|
||||
.eq('pecas.of_number', 'B101')
|
||||
.eq('pecas.etapa_fase', '9')
|
||||
.gte('pecas.marca', '10')
|
||||
.lte('pecas.marca', '70')
|
||||
.eq('data_apontamento', '2025-09-08');
|
||||
|
||||
if (selectError) {
|
||||
console.error('❌ Erro ao buscar registros:', selectError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!registrosParaAtualizar || registrosParaAtualizar.length === 0) {
|
||||
console.log('ℹ️ Nenhum registro encontrado com os critérios especificados.');
|
||||
console.log(' - OF: B101');
|
||||
console.log(' - Fase: 9');
|
||||
console.log(' - Marca: entre 10 e 70');
|
||||
console.log(' - Data atual: 08/09/2025');
|
||||
return;
|
||||
}
|
||||
|
||||
// Mostrar registros encontrados
|
||||
console.log(`✅ Encontrados ${registrosParaAtualizar.length} registro(s) para atualização:`);
|
||||
console.log('');
|
||||
|
||||
registrosParaAtualizar.forEach((registro, index) => {
|
||||
console.log(`📦 Registro ${index + 1}:`);
|
||||
console.log(` ID: ${registro.id}`);
|
||||
console.log(` OF: ${registro.of_number}`);
|
||||
console.log(` Peça: ${registro.pecas.marca} - ${registro.pecas.descricao}`);
|
||||
console.log(` Fase: ${registro.pecas.etapa_fase}`);
|
||||
console.log(` Data atual: ${registro.data_apontamento}`);
|
||||
console.log(` Quantidade: ${registro.quantidade_produzida}`);
|
||||
if (registro.observacoes) {
|
||||
console.log(` Observações: ${registro.observacoes}`);
|
||||
}
|
||||
console.log('');
|
||||
});
|
||||
|
||||
// Passo 2: Solicitar confirmação
|
||||
console.log('⚠️ ATENÇÃO: Esta operação irá alterar a data dos registros acima.');
|
||||
console.log(' Data atual: 08/09/2025');
|
||||
console.log(' Nova data: 10/09/2025');
|
||||
console.log('');
|
||||
|
||||
const confirmacao = await askConfirmation('Deseja continuar com a atualização? (s/n): ');
|
||||
|
||||
if (!confirmacao) {
|
||||
console.log('❌ Operação cancelada pelo usuário.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Passo 3: Executar a atualização
|
||||
console.log('');
|
||||
console.log('🔄 Passo 2: Executando atualização...');
|
||||
|
||||
// Extrair IDs dos registros para atualização
|
||||
const idsParaAtualizar = registrosParaAtualizar.map(r => r.id);
|
||||
|
||||
const { data: registrosAtualizados, error: updateError } = await supabase
|
||||
.from('apontamentos_producao')
|
||||
.update({
|
||||
data_apontamento: '2025-09-10',
|
||||
updated_at: new Date().toISOString()
|
||||
})
|
||||
.in('id', idsParaAtualizar)
|
||||
.select(`
|
||||
id,
|
||||
of_number,
|
||||
data_apontamento,
|
||||
quantidade_produzida,
|
||||
updated_at,
|
||||
pecas!inner(
|
||||
of_number,
|
||||
etapa_fase,
|
||||
marca
|
||||
)
|
||||
`);
|
||||
|
||||
if (updateError) {
|
||||
console.error('❌ Erro ao atualizar registros:', updateError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Passo 4: Confirmar atualização
|
||||
console.log(`✅ Atualização concluída com sucesso!`);
|
||||
console.log(`📊 ${registrosAtualizados?.length || 0} registro(s) atualizado(s).`);
|
||||
console.log('');
|
||||
|
||||
if (registrosAtualizados && registrosAtualizados.length > 0) {
|
||||
console.log('📋 Registros atualizados:');
|
||||
registrosAtualizados.forEach((registro, index) => {
|
||||
console.log(` ${index + 1}. ID: ${registro.id} | Nova data: ${registro.data_apontamento} | Atualizado em: ${new Date(registro.updated_at).toLocaleString('pt-BR')}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Passo 5: Verificação final
|
||||
console.log('');
|
||||
console.log('🔍 Passo 3: Verificação final...');
|
||||
|
||||
const { data: verificacao, error: verifyError } = await supabase
|
||||
.from('apontamentos_producao')
|
||||
.select(`
|
||||
id,
|
||||
of_number,
|
||||
data_apontamento,
|
||||
pecas!inner(
|
||||
of_number,
|
||||
etapa_fase,
|
||||
marca
|
||||
)
|
||||
`)
|
||||
.eq('pecas.of_number', 'B101')
|
||||
.eq('pecas.etapa_fase', '9')
|
||||
.gte('pecas.marca', '10')
|
||||
.lte('pecas.marca', '70')
|
||||
.eq('data_apontamento', '2025-09-10');
|
||||
|
||||
if (verifyError) {
|
||||
console.error('❌ Erro na verificação final:', verifyError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`✅ Verificação concluída: ${verificacao?.length || 0} registro(s) com a nova data (10/09/2025).`);
|
||||
|
||||
// Verificar se ainda existem registros com a data antiga
|
||||
const { data: registrosAntigos, error: oldRecordsError } = await supabase
|
||||
.from('apontamentos_producao')
|
||||
.select('id, pecas!inner(of_number, etapa_fase, marca)')
|
||||
.eq('pecas.of_number', 'B101')
|
||||
.eq('pecas.etapa_fase', '9')
|
||||
.gte('pecas.marca', '10')
|
||||
.lte('pecas.marca', '70')
|
||||
.eq('data_apontamento', '2025-09-08');
|
||||
|
||||
if (!oldRecordsError && registrosAntigos && registrosAntigos.length > 0) {
|
||||
console.log(`⚠️ Atenção: Ainda existem ${registrosAntigos.length} registro(s) com a data antiga (08/09/2025).`);
|
||||
} else {
|
||||
console.log('✅ Nenhum registro restante com a data antiga.');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erro inesperado:', error.message);
|
||||
console.error('Stack trace:', error.stack);
|
||||
} finally {
|
||||
rl.close();
|
||||
console.log('');
|
||||
console.log('🏁 Script finalizado.');
|
||||
}
|
||||
}
|
||||
|
||||
// Executar o script
|
||||
if (require.main === module) {
|
||||
updateApontamentosB101Fase9();
|
||||
}
|
||||
|
||||
module.exports = { updateApontamentosB101Fase9 };
|
||||
71
scripts/update_apontamentos_b101_fase9_auto.js
Normal file
71
scripts/update_apontamentos_b101_fase9_auto.js
Normal file
@@ -0,0 +1,71 @@
|
||||
const { createClient } = require('@supabase/supabase-js');
|
||||
|
||||
// Configuração do Supabase
|
||||
const supabaseUrl = 'https://lwjppiicofojfcdfjsto.supabase.co';
|
||||
const supabaseServiceKey = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imx3anBwaWljb2ZvamZjZGZqc3RvIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc1MDQ2MzA1MywiZXhwIjoyMDY2MDM5MDUzfQ.t9vlXHQH4ou2S-CKSeDYSnAeMDYpmkklqlwyGDvpocI';
|
||||
|
||||
// Inicializar cliente Supabase com service role para operações administrativas
|
||||
const supabase = createClient(supabaseUrl, supabaseServiceKey);
|
||||
|
||||
// Função principal
|
||||
async function updateApontamentosB101Fase9Auto() {
|
||||
console.log('🔧 Script de Atualização Automática - OF B101 Fase 9');
|
||||
console.log('=' .repeat(60));
|
||||
console.log('Objetivo: Alterar data de 08/09/2025 para 10/09/2025');
|
||||
console.log('Filtro: Apenas peças com marca entre 10 e 70');
|
||||
console.log('');
|
||||
|
||||
try {
|
||||
// Passo 1: Buscar registros que serão afetados
|
||||
console.log('📋 Passo 1: Identificando registros a serem atualizados...');
|
||||
|
||||
const { data: registrosParaAtualizar, error: selectError } = await supabase
|
||||
.from('apontamentos_producao')
|
||||
.select(`
|
||||
id,
|
||||
of_number,
|
||||
data_apontamento,
|
||||
quantidade_produzida,
|
||||
observacoes,
|
||||
pecas!inner(
|
||||
id,
|
||||
of_number,
|
||||
etapa_fase,
|
||||
marca,
|
||||
descricao
|
||||
)
|
||||
`)
|
||||
.eq('pecas.of_number', 'B101')
|
||||
.eq('pecas.etapa_fase', '9')
|
||||
.gte('pecas.marca', '10')
|
||||
.lte('pecas.marca', '70')
|
||||
.eq('data_apontamento', '2025-09-08');
|
||||
|
||||
if (selectError) {
|
||||
console.error('❌ Erro ao buscar registros:', selectError.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!registrosParaAtualizar || registrosParaAtualizar.length === 0) {
|
||||
console.log('ℹ️ Nenhum registro encontrado com os critérios especificados.');
|
||||
console.log(' - OF: B101');
|
||||
console.log(' - Fase: 9');
|
||||
console.log(' - Marca: entre 10 e 70');
|
||||
console.log(' - Data atual: 08/09/2025');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`✅ Encontrados ${registrosParaAtualizar.length} registro(s) para atualização.`);
|
||||
console.log('');
|
||||
|
||||
// Passo 2: Executar a atualização automaticamente
|
||||
console.log('🔄 Passo 2: Executando atualização automaticamente...');
|
||||
|
||||
// Extrair IDs dos registros para atualização
|
||||
const idsParaAtualizar = registrosParaAtualizar.map(r => r.id);
|
||||
|
||||
const { data: registrosAtualizados, error: updateError } = await supabase
|
||||
.from('apontamentos_producao')
|
||||
.update({
|
||||
data_apontamento: '2025-09-10',
|
||||
updated_at: new Date().toISOString(
|
||||
42
src/App.css
Normal file
42
src/App.css
Normal file
@@ -0,0 +1,42 @@
|
||||
#root {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.react:hover {
|
||||
filter: drop-shadow(0 0 2em #61dafbaa);
|
||||
}
|
||||
|
||||
@keyframes logo-spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
a:nth-of-type(2) .logo {
|
||||
animation: logo-spin infinite 20s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 2em;
|
||||
}
|
||||
|
||||
.read-the-docs {
|
||||
color: #888;
|
||||
}
|
||||
185
src/components/AppSidebar.tsx
Normal file
185
src/components/AppSidebar.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
|
||||
import React, { useState, useMemo } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Sidebar, SidebarContent, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarMenu } from "@/components/ui/sidebar";
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { useUserRole } from "@/hooks/useUserRole";
|
||||
import { useIconStyle } from "@/hooks/useIconStyle";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import { useUserPermissions } from "@/hooks/useUserPermissions";
|
||||
import { usePermissionControl } from "@/hooks/usePermissionControl";
|
||||
import { menuGroups } from "./sidebar/menuConfig";
|
||||
import { AppSidebarMenuItem } from "./sidebar/SidebarMenuItem";
|
||||
|
||||
export function AppSidebar() {
|
||||
const location = useLocation();
|
||||
const { setOpenMobile } = useSidebar();
|
||||
const { isAdmin, loading: roleLoading } = useUserRole();
|
||||
const { iconStyle } = useIconStyle();
|
||||
const { hasAccess, loading: permissionsLoading } = useUserPermissions();
|
||||
const { canAccessTools, canInteractWithSpecialMenus } = usePermissionControl();
|
||||
const isMobile = useIsMobile();
|
||||
const [openGroups, setOpenGroups] = useState<{ [key: string]: boolean }>({
|
||||
'producao': true
|
||||
});
|
||||
|
||||
// Handle mobile menu item click
|
||||
const handleMenuItemClick = (hasSubItems: boolean = false) => {
|
||||
if (isMobile && !hasSubItems) {
|
||||
setOpenMobile(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle submenu toggle
|
||||
const handleSubmenuToggle = (itemKey: string, currentState: boolean) => {
|
||||
setOpenGroups(prev => ({
|
||||
...prev,
|
||||
[itemKey]: !currentState
|
||||
}));
|
||||
};
|
||||
|
||||
const isActive = (url: string) => {
|
||||
if (url === "/dashboard" && location.pathname === "/") {
|
||||
return true;
|
||||
}
|
||||
return location.pathname === url;
|
||||
};
|
||||
|
||||
const getIconProps = (itemKey?: string) => {
|
||||
const baseProps = { className: "mr-2 h-4 w-4" };
|
||||
|
||||
// Mapeamento de cores específicas para cada ícone
|
||||
const iconColors: { [key: string]: string } = {
|
||||
'dashboard': '#3b82f6', // azul
|
||||
'cadastro': '#10b981', // verde
|
||||
'ferramentas': '#f59e0b', // laranja
|
||||
'estoque': '#8b5cf6', // roxo
|
||||
'ofs': '#eab308', // amarelo
|
||||
'producao': '#ef4444', // vermelho
|
||||
'painel-industrial': '#06b6d4', // ciano
|
||||
'expedicao': '#ec4899', // rosa
|
||||
'obra': '#a3a3a3', // marrom
|
||||
'tarefas': '#059669', // verde escuro
|
||||
'biblioteca': '#1d4ed8', // azul escuro
|
||||
'sistema': '#6b7280', // cinza
|
||||
'sugestoes': '#7c3aed', // violeta
|
||||
'atribuicoes': '#14b8a6', // teal
|
||||
'mapa-interativo': '#6366f1', // indigo
|
||||
'configuracoes': '#475569', // slate
|
||||
'admin': '#dc2626', // vermelho escuro
|
||||
'gerenciar-usuarios': '#e11d48' // vermelho médio
|
||||
};
|
||||
|
||||
// Se temos uma cor específica para este ícone, aplicá-la
|
||||
if (itemKey && iconColors[itemKey]) {
|
||||
return {
|
||||
...baseProps,
|
||||
style: { color: iconColors[itemKey] }
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback para o comportamento original baseado no iconStyle
|
||||
switch (iconStyle) {
|
||||
case 'white':
|
||||
return { ...baseProps, className: `${baseProps.className} text-white` };
|
||||
case 'themed':
|
||||
return { ...baseProps, className: `${baseProps.className} text-primary` };
|
||||
case 'colorful':
|
||||
return { ...baseProps, style: { color: 'inherit' } };
|
||||
default:
|
||||
return baseProps;
|
||||
}
|
||||
};
|
||||
|
||||
// Function to check if user can access an item
|
||||
const canAccessItem = (itemKey: string, requiresSpecialPermission?: boolean): boolean => {
|
||||
try {
|
||||
// Admin can always access everything
|
||||
if (isAdmin) return true;
|
||||
|
||||
// Se o item requer permissão especial, verificar permissões específicas
|
||||
if (requiresSpecialPermission) {
|
||||
if (itemKey === 'ferramentas') {
|
||||
return canAccessTools();
|
||||
}
|
||||
if (itemKey === 'tarefas' || itemKey === 'sistema' || itemKey === 'sugestoes') {
|
||||
return canInteractWithSpecialMenus();
|
||||
}
|
||||
}
|
||||
|
||||
// Users with any functional permission can see most menus
|
||||
// Restriction will be applied in the pages/components themselves
|
||||
return hasAccess();
|
||||
} catch (error) {
|
||||
console.warn('Error checking item access:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// Wait for permissions loading
|
||||
if (permissionsLoading || roleLoading) {
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarContent>
|
||||
<div className="p-4 text-center text-slate-400">
|
||||
Carregando menu...
|
||||
</div>
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
console.log('🖥️ Rendering sidebar with:', {
|
||||
isAdmin,
|
||||
hasBasicAccess: hasAccess(),
|
||||
canAccessTools: canAccessTools(),
|
||||
canInteractWithSpecialMenus: canInteractWithSpecialMenus()
|
||||
});
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
<SidebarContent>
|
||||
{menuGroups.map(group => {
|
||||
// Filter admin groups for non-admin users
|
||||
if (!isAdmin && group.name === 'Administração') {
|
||||
console.log('🚫 Hiding admin group for non-admin user');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip groups with no items
|
||||
if (!group.items || group.items.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarGroup key={group.id}>
|
||||
<SidebarGroupLabel
|
||||
style={{ color: group.color }}
|
||||
className="text-base font-semibold uppercase tracking-wide"
|
||||
>
|
||||
{group.name}
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{group.items.map((item) => (
|
||||
<AppSidebarMenuItem
|
||||
key={item.key || item.title}
|
||||
item={item}
|
||||
isActive={isActive}
|
||||
canAccessItem={(itemKey) => canAccessItem(itemKey, item.requiresSpecialPermission)}
|
||||
isAdmin={isAdmin}
|
||||
openGroups={openGroups}
|
||||
onSubmenuToggle={handleSubmenuToggle}
|
||||
onMenuItemClick={handleMenuItemClick}
|
||||
getIconProps={(itemKey) => getIconProps(itemKey)}
|
||||
/>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
})}
|
||||
</SidebarContent>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
207
src/components/ErrorBoundary.tsx
Normal file
207
src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { RefreshCw, AlertTriangle, Shield } from 'lucide-react';
|
||||
import { logger } from '@/utils/logger';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
onError?: (error: Error, errorInfo: ErrorInfo) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
errorInfo?: ErrorInfo;
|
||||
isPermissionError: boolean;
|
||||
}
|
||||
|
||||
// Função auxiliar para verificar erro de permissão (fora da classe)
|
||||
const isPermissionRelatedError = (error: Error): boolean => {
|
||||
const errorMessage = error.message?.toLowerCase() || '';
|
||||
const errorStack = error.stack?.toLowerCase() || '';
|
||||
|
||||
// Palavras-chave que indicam erro de permissão
|
||||
const permissionKeywords = [
|
||||
'permission', 'permissao', 'permissão',
|
||||
'access', 'acesso',
|
||||
'unauthorized', 'não autorizado', 'nao autorizado',
|
||||
'forbidden', 'proibido',
|
||||
'privilege', 'privilegio', 'privilégio',
|
||||
'role', 'papel', 'função', 'funcao',
|
||||
'restricted', 'restrito', 'restricao', 'restrição'
|
||||
];
|
||||
|
||||
return permissionKeywords.some(keyword =>
|
||||
errorMessage.includes(keyword) || errorStack.includes(keyword)
|
||||
);
|
||||
};
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
hasError: false,
|
||||
isPermissionError: false
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): Partial<State> {
|
||||
// Verificar se é erro relacionado a permissões usando a função auxiliar
|
||||
const isPermissionError = isPermissionRelatedError(error);
|
||||
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
isPermissionError
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
logger.error('Error caught by boundary', { error, errorInfo });
|
||||
|
||||
const isPermissionError = isPermissionRelatedError(error);
|
||||
|
||||
this.setState({
|
||||
error,
|
||||
errorInfo,
|
||||
isPermissionError
|
||||
});
|
||||
|
||||
this.props.onError?.(error, errorInfo);
|
||||
}
|
||||
|
||||
handleReload = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: undefined,
|
||||
errorInfo: undefined,
|
||||
isPermissionError: false
|
||||
});
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
handleRetry = () => {
|
||||
this.setState({
|
||||
hasError: false,
|
||||
error: undefined,
|
||||
errorInfo: undefined,
|
||||
isPermissionError: false
|
||||
});
|
||||
};
|
||||
|
||||
handleGoBack = () => {
|
||||
window.history.back();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
// Renderizar erro específico de permissão
|
||||
if (this.state.isPermissionError) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 w-12 h-12 bg-orange-100 rounded-full flex items-center justify-center">
|
||||
<Shield className="w-6 h-6 text-orange-600" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Acesso Restrito</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Você não tem permissão para acessar esta funcionalidade do sistema.
|
||||
Entre em contato com o administrador para solicitar as permissões necessárias.
|
||||
</p>
|
||||
|
||||
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3">
|
||||
<p className="text-sm text-orange-800">
|
||||
<strong>Possíveis soluções:</strong>
|
||||
</p>
|
||||
<ul className="text-sm text-orange-700 mt-1 space-y-1">
|
||||
<li>• Solicite acesso ao administrador do sistema</li>
|
||||
<li>• Verifique se você está logado com a conta correta</li>
|
||||
<li>• Aguarde a aprovação das suas permissões</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-center">
|
||||
<Button variant="outline" onClick={this.handleGoBack}>
|
||||
Voltar
|
||||
</Button>
|
||||
<Button onClick={this.handleReload} className="gap-2">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Tentar novamente
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Renderizar erro genérico
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 w-12 h-12 bg-red-100 rounded-full flex items-center justify-center">
|
||||
<AlertTriangle className="w-6 h-6 text-red-600" />
|
||||
</div>
|
||||
<CardTitle className="text-xl">Algo deu errado</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Ocorreu um erro inesperado. Tente novamente ou recarregue a página.
|
||||
</p>
|
||||
|
||||
{import.meta.env.DEV && this.state.error && (
|
||||
<details className="mt-4 p-3 bg-red-50 rounded border text-sm">
|
||||
<summary className="cursor-pointer font-medium text-red-800">
|
||||
Detalhes do erro (desenvolvimento)
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs text-red-700 whitespace-pre-wrap">
|
||||
{this.state.error.message}
|
||||
{this.state.errorInfo?.componentStack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 justify-center">
|
||||
<Button variant="outline" onClick={this.handleRetry}>
|
||||
Tentar novamente
|
||||
</Button>
|
||||
<Button onClick={this.handleReload} className="gap-2">
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Recarregar página
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
// Hook para usar com componentes funcionais
|
||||
export const withErrorBoundary = <P extends object>(
|
||||
Component: React.ComponentType<P>,
|
||||
fallback?: ReactNode
|
||||
) => {
|
||||
const WrappedComponent = (props: P) => (
|
||||
<ErrorBoundary fallback={fallback}>
|
||||
<Component {...props} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
WrappedComponent.displayName = `withErrorBoundary(${Component.displayName || Component.name})`;
|
||||
return WrappedComponent;
|
||||
};
|
||||
38
src/components/Layout.tsx
Normal file
38
src/components/Layout.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
|
||||
import React from 'react';
|
||||
import { SidebarProvider } from '@/components/ui/sidebar';
|
||||
import { AppSidebar } from '@/components/AppSidebar';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { ApontamentoAutomaticoListener } from '@/components/expedicao/ApontamentoAutomaticoListener';
|
||||
import { ThemeToggle } from '@/components/ThemeToggle';
|
||||
import { SidebarTrigger } from '@/components/ui/sidebar';
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const Layout = ({ children }: LayoutProps) => {
|
||||
return (
|
||||
<SidebarProvider>
|
||||
<div className="min-h-screen flex w-full">
|
||||
<AppSidebar />
|
||||
<main className="flex-1 overflow-hidden flex flex-col">
|
||||
{/* Header fixo com SidebarTrigger e ThemeToggle */}
|
||||
<header className="h-14 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 flex items-center justify-between px-4 shrink-0">
|
||||
<div className="flex items-center">
|
||||
<SidebarTrigger />
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
{/* Conteúdo principal */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
<Toaster />
|
||||
{/* Listener global para apontamento automático */}
|
||||
<ApontamentoAutomaticoListener />
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
);
|
||||
};
|
||||
28
src/components/ProtectedAdminRoute.tsx
Normal file
28
src/components/ProtectedAdminRoute.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useUserRole } from '@/hooks/useUserRole';
|
||||
|
||||
interface ProtectedAdminRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ProtectedAdminRoute: React.FC<ProtectedAdminRouteProps> = ({ children }) => {
|
||||
const { user, loading } = useAuth();
|
||||
const { isAdmin, loading: roleLoading } = useUserRole();
|
||||
|
||||
if (loading || roleLoading) {
|
||||
return <div>Carregando...</div>;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/auth" replace />;
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return <Navigate to="/dashboard" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
100
src/components/ProtectedRoute.tsx
Normal file
100
src/components/ProtectedRoute.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
// Buscar o perfil do usuário para verificar o status
|
||||
const { data: profile, isLoading: profileLoading, error } = useQuery({
|
||||
queryKey: ['user-profile', user?.id],
|
||||
queryFn: async () => {
|
||||
if (!user?.id) return null;
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('status')
|
||||
.eq('id', user.id)
|
||||
.single();
|
||||
|
||||
if (error) {
|
||||
console.error('Erro ao verificar perfil do usuário:', error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
enabled: !!user?.id,
|
||||
retry: 1, // Limitar tentativas de retry
|
||||
staleTime: 30000, // Cache por 30 segundos
|
||||
});
|
||||
|
||||
// Mostrar loading enquanto carrega autenticação ou perfil
|
||||
if (loading || (user && profileLoading)) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
<div className="text-muted-foreground">Carregando...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Se não há usuário, redirecionar para auth
|
||||
if (!user) {
|
||||
console.log('🚫 ProtectedRoute: Usuário não autenticado, redirecionando para /auth');
|
||||
return <Navigate to="/auth" replace />;
|
||||
}
|
||||
|
||||
// Se há erro ao carregar perfil, permitir acesso (para evitar loop)
|
||||
if (error) {
|
||||
console.warn('⚠️ ProtectedRoute: Erro ao carregar perfil, permitindo acesso');
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Se há usuário mas não conseguiu carregar o perfil ainda, mostrar loading
|
||||
if (!profile && !error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
<div className="text-muted-foreground">Verificando permissões...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// SEGURANÇA: Verificar se o usuário tem status 'active'
|
||||
if (profile && profile.status !== 'active') {
|
||||
console.log('🚫 ProtectedRoute: Usuário com status inválido:', profile.status);
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
<div className="text-center space-y-4 p-8 max-w-md mx-auto">
|
||||
<div className="text-6xl">⏳</div>
|
||||
<h2 className="text-2xl font-semibold text-foreground">
|
||||
Aguardando Aprovação
|
||||
</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Sua conta foi criada com sucesso, mas ainda precisa ser aprovada por um administrador.
|
||||
Você receberá acesso assim que sua solicitação for analisada.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
supabase.auth.signOut();
|
||||
}}
|
||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Fazer Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
console.log('✅ ProtectedRoute: Usuário ativo autorizado, renderizando conteúdo');
|
||||
return <>{children}</>;
|
||||
};
|
||||
150
src/components/ProtectedRouteByResource.tsx
Normal file
150
src/components/ProtectedRouteByResource.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useUserPermissions } from '@/hooks/useUserPermissions';
|
||||
import { useUserRole } from '@/hooks/useUserRole';
|
||||
|
||||
interface ProtectedRouteByResourceProps {
|
||||
children: React.ReactNode;
|
||||
resourceKey: string;
|
||||
}
|
||||
|
||||
export const ProtectedRouteByResource: React.FC<ProtectedRouteByResourceProps> = ({
|
||||
children,
|
||||
resourceKey
|
||||
}) => {
|
||||
const { user, loading } = useAuth();
|
||||
const { isAdmin, loading: roleLoading } = useUserRole();
|
||||
|
||||
// Use a try-catch to prevent the hook from crashing the component
|
||||
let permissionsData;
|
||||
try {
|
||||
permissionsData = useUserPermissions();
|
||||
} catch (error) {
|
||||
console.error('Error in useUserPermissions:', error);
|
||||
// Fallback to basic data structure
|
||||
permissionsData = {
|
||||
hasAccess: () => isAdmin,
|
||||
loading: false,
|
||||
userPermissions: { can_admin: false, can_create_update_delete: false, can_create_only: false, can_view_only: false },
|
||||
getResourcePermission: () => isAdmin ? 'can_admin' : 'no_access'
|
||||
};
|
||||
}
|
||||
|
||||
const { hasAccess, loading: permissionsLoading, userPermissions, getResourcePermission } = permissionsData;
|
||||
|
||||
// Aguardar carregamento
|
||||
if (loading || permissionsLoading || roleLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-muted-foreground">Carregando...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Redirecionar para login se não autenticado
|
||||
if (!user) {
|
||||
return <Navigate to="/auth" replace />;
|
||||
}
|
||||
|
||||
// Admin sempre tem acesso (exceto se explicitamente negado)
|
||||
if (isAdmin) {
|
||||
const resourcePermission = getResourcePermission(resourceKey);
|
||||
// Se admin tem negação explícita, negar acesso
|
||||
if (resourcePermission === 'no_access') {
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('❌ Admin access denied by explicit resource permission:', resourceKey);
|
||||
}
|
||||
} else {
|
||||
return <>{children}</>;
|
||||
}
|
||||
}
|
||||
|
||||
let finalAccess = false;
|
||||
|
||||
try {
|
||||
// 1. PRIMEIRO: Verificar permissão específica do recurso
|
||||
const resourcePermission = getResourcePermission(resourceKey);
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🔍 Checking access for resource:', {
|
||||
resourceKey,
|
||||
user: user?.email,
|
||||
isAdmin,
|
||||
resourcePermission,
|
||||
userPermissions
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Se há permissão específica definida, ela prevalece SEMPRE
|
||||
if (resourcePermission !== 'no_access') {
|
||||
finalAccess = true;
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('✅ Access granted by specific resource permission:', resourcePermission);
|
||||
}
|
||||
} else {
|
||||
// 3. Se permissão específica é 'no_access', NEGAR acesso independente de outros privilégios
|
||||
if (resourcePermission === 'no_access') {
|
||||
finalAccess = false;
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('❌ Access explicitly denied by resource permission');
|
||||
}
|
||||
} else {
|
||||
// 4. Se não há permissão específica, verificar permissões funcionais como fallback
|
||||
const hasGeneralAccess = hasAccess();
|
||||
|
||||
// Para recursos de produção, permitir acesso para colaboradores como fallback
|
||||
const isProductionResource = resourceKey.startsWith('producao');
|
||||
const isCollaborator = userPermissions?.can_create_update_delete || userPermissions?.can_admin;
|
||||
const hasProductionAccess = isProductionResource && (isAdmin || isCollaborator || userPermissions?.can_view_only);
|
||||
|
||||
finalAccess = hasGeneralAccess || hasProductionAccess;
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.log('🔄 Fallback to general permissions:', {
|
||||
hasGeneralAccess,
|
||||
isProductionResource,
|
||||
hasProductionAccess,
|
||||
finalAccess
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking access permissions:', error);
|
||||
// For safety, deny access on error unless user is admin and no explicit denial
|
||||
const resourcePermission = getResourcePermission(resourceKey);
|
||||
finalAccess = isAdmin && resourcePermission !== 'no_access';
|
||||
}
|
||||
|
||||
if (!finalAccess) {
|
||||
console.log(`❌ Access denied for resource: ${resourceKey}`);
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-center space-y-4">
|
||||
<div className="text-6xl">🔒</div>
|
||||
<h2 className="text-2xl font-semibold text-muted-foreground">
|
||||
Acesso Restrito
|
||||
</h2>
|
||||
<p className="text-muted-foreground max-w-md">
|
||||
Você não tem permissão para acessar esta funcionalidade. Entre em contato com o administrador do sistema.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-4">
|
||||
Recurso solicitado: <code className="bg-muted px-2 py-1 rounded">{resourceKey}</code>
|
||||
</p>
|
||||
{import.meta.env.DEV && (
|
||||
<div className="text-xs text-muted-foreground mt-2 p-3 bg-muted/50 rounded">
|
||||
<p>Debug info:</p>
|
||||
<p>Admin: {isAdmin ? 'Sim' : 'Não'}</p>
|
||||
<p>Permissão do Recurso: {getResourcePermission(resourceKey)}</p>
|
||||
<p>Permissões Funcionais: {userPermissions ? JSON.stringify(userPermissions) : 'Não carregadas'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
34
src/components/ThemeToggle.tsx
Normal file
34
src/components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const toggleTheme = () => {
|
||||
console.log('🎨 Current theme:', theme);
|
||||
const newTheme = theme === 'light' ? 'dark' : 'light';
|
||||
console.log('🎨 Switching to theme:', newTheme);
|
||||
setTheme(newTheme);
|
||||
};
|
||||
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={toggleTheme}
|
||||
className="h-10 w-10 rounded-full bg-card hover:bg-accent hover:text-accent-foreground transition-colors shadow-sm border-border"
|
||||
aria-label={isDark ? "Mudar para modo claro" : "Mudar para modo escuro"}
|
||||
>
|
||||
{isDark ? (
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] text-foreground transition-all" />
|
||||
) : (
|
||||
<Moon className="h-[1.2rem] w-[1.2rem] text-foreground transition-all" />
|
||||
)}
|
||||
<span className="sr-only">Alternar tema</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
351
src/components/admin/ApiKeysManager.tsx
Normal file
351
src/components/admin/ApiKeysManager.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Trash2, Edit, Key, Star, Plus, TestTube2, CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||
import { useApiKeys, ApiKey } from '@/hooks/useApiKeys';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
export const ApiKeysManager = () => {
|
||||
const { apiKeys, loading, saveApiKey, setPrimaryKey, deleteApiKey } = useApiKeys();
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [editingKey, setEditingKey] = useState<ApiKey | null>(null);
|
||||
const [formData, setFormData] = useState({ name: '', key: '' });
|
||||
const [testingKeys, setTestingKeys] = useState<Set<string>>(new Set());
|
||||
const [keyTestResults, setKeyTestResults] = useState<Map<string, boolean>>(new Map());
|
||||
|
||||
const handleEdit = (apiKey: ApiKey) => {
|
||||
setEditingKey(apiKey);
|
||||
setFormData({ name: apiKey.name, key: apiKey.key });
|
||||
setShowDialog(true);
|
||||
};
|
||||
|
||||
const handleAdd = () => {
|
||||
setEditingKey(null);
|
||||
setFormData({ name: '', key: '' });
|
||||
setShowDialog(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.name.trim() || !formData.key.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await saveApiKey({
|
||||
id: editingKey?.id,
|
||||
name: formData.name,
|
||||
key: formData.key
|
||||
});
|
||||
|
||||
setShowDialog(false);
|
||||
setFormData({ name: '', key: '' });
|
||||
setEditingKey(null);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setShowDialog(false);
|
||||
setFormData({ name: '', key: '' });
|
||||
setEditingKey(null);
|
||||
};
|
||||
|
||||
const testApiKey = async (apiKey: ApiKey) => {
|
||||
setTestingKeys(prev => new Set([...prev, apiKey.id]));
|
||||
|
||||
try {
|
||||
// Teste básico para OpenAI/Gemini APIs
|
||||
const response = await fetch('https://api.openai.com/v1/models', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${apiKey.key}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setKeyTestResults(prev => new Map([...prev, [apiKey.id, true]]));
|
||||
toast.success(`Chave "${apiKey.name}" validada com sucesso!`);
|
||||
} else if (response.status === 401 || response.status === 403) {
|
||||
// Se falhar com OpenAI, tenta com Gemini
|
||||
const geminiResponse = await fetch(`https://generativelanguage.googleapis.com/v1/models?key=${apiKey.key}`, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (geminiResponse.ok) {
|
||||
setKeyTestResults(prev => new Map([...prev, [apiKey.id, true]]));
|
||||
toast.success(`Chave "${apiKey.name}" validada com sucesso (Gemini)!`);
|
||||
} else {
|
||||
setKeyTestResults(prev => new Map([...prev, [apiKey.id, false]]));
|
||||
toast.error(`Chave "${apiKey.name}" é inválida ou não tem permissões adequadas.`);
|
||||
}
|
||||
} else {
|
||||
setKeyTestResults(prev => new Map([...prev, [apiKey.id, false]]));
|
||||
toast.error(`Erro ao testar chave "${apiKey.name}": ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao testar chave API:', error);
|
||||
setKeyTestResults(prev => new Map([...prev, [apiKey.id, false]]));
|
||||
toast.error(`Erro de conexão ao testar chave "${apiKey.name}"`);
|
||||
} finally {
|
||||
setTestingKeys(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(apiKey.id);
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getTestStatusIcon = (keyId: string) => {
|
||||
const isValid = keyTestResults.get(keyId);
|
||||
if (isValid === true) {
|
||||
return <CheckCircle className="w-4 h-4 text-green-500" />;
|
||||
} else if (isValid === false) {
|
||||
return <XCircle className="w-4 h-4 text-red-500" />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const canAddMore = apiKeys.length < 3;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-card-foreground flex items-center gap-2">
|
||||
<Key className="w-5 h-5" />
|
||||
Gerenciamento de Chaves API
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="animate-pulse space-y-2">
|
||||
<div className="h-4 bg-muted rounded w-3/4"></div>
|
||||
<div className="h-4 bg-muted rounded w-1/2"></div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-card-foreground flex items-center gap-2">
|
||||
<Key className="w-5 h-5" />
|
||||
Gerenciamento de Chaves API
|
||||
</CardTitle>
|
||||
{canAddMore && (
|
||||
<Button
|
||||
onClick={handleAdd}
|
||||
size="sm"
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Adicionar
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{apiKeys.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<Key className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">Nenhuma chave API configurada</p>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Adicione até 3 chaves para redundância automática
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
apiKeys.map((apiKey) => (
|
||||
<div
|
||||
key={apiKey.id}
|
||||
className="border border-border rounded-lg p-2 sm:p-3 space-y-2 bg-card"
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="text-card-foreground font-medium text-sm">{apiKey.name}</h4>
|
||||
{apiKey.is_primary ? (
|
||||
<Badge variant="default" className="bg-primary text-primary-foreground text-xs">
|
||||
<Star className="w-3 h-3 mr-1" />
|
||||
Principal
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="text-xs">Secundária</Badge>
|
||||
)}
|
||||
{getTestStatusIcon(apiKey.id)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{testingKeys.has(apiKey.id) ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled
|
||||
className="text-xs h-6 px-2"
|
||||
>
|
||||
<Loader2 className="w-3 h-3 mr-1 animate-spin" />
|
||||
<span className="hidden sm:inline">Testando...</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => testApiKey(apiKey)}
|
||||
className="text-xs hover:bg-accent hover:text-accent-foreground h-6 px-2"
|
||||
>
|
||||
<TestTube2 className="w-3 h-3 mr-1" />
|
||||
<span className="hidden sm:inline">Testar</span>
|
||||
</Button>
|
||||
)}
|
||||
{!apiKey.is_primary && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setPrimaryKey(apiKey.id)}
|
||||
className="text-xs h-6 px-2 hidden sm:inline-flex"
|
||||
>
|
||||
Definir como Principal
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleEdit(apiKey)}
|
||||
className="h-6 w-6 p-0 hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<Edit className="w-3 h-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => deleteApiKey(apiKey.id)}
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10 h-6 w-6 p-0"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground font-mono bg-muted/50 p-2 rounded">
|
||||
{apiKey.key.substring(0, 20)}...
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 text-xs text-muted-foreground">
|
||||
<span>Criada: {new Date(apiKey.created_at).toLocaleDateString('pt-BR')}</span>
|
||||
{keyTestResults.has(apiKey.id) && (
|
||||
<span className={`flex items-center gap-1 ${
|
||||
keyTestResults.get(apiKey.id) ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'
|
||||
}`}>
|
||||
{keyTestResults.get(apiKey.id) ? (
|
||||
<>
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
Válida
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<XCircle className="w-3 h-3" />
|
||||
Inválida
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile: Show primary button if not primary */}
|
||||
{!apiKey.is_primary && (
|
||||
<div className="sm:hidden">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setPrimaryKey(apiKey.id)}
|
||||
className="text-xs h-6 w-full"
|
||||
>
|
||||
Definir como Principal
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
{apiKeys.length > 0 && (
|
||||
<div className="mt-4 p-3 bg-muted/50 rounded-lg border border-border">
|
||||
<p className="text-sm text-card-foreground mb-2">
|
||||
<strong>Sistema de Fallback:</strong>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
O sistema usa automaticamente a chave principal. Em caso de falha (401/403/timeout),
|
||||
tenta as chaves secundárias em sequência até encontrar uma válida.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>Teste de Validação:</strong> Verifica se a chave é válida testando conexão com OpenAI ou Gemini APIs.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog open={showDialog} onOpenChange={handleClose}>
|
||||
<DialogContent className="bg-card border-border">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-card-foreground">
|
||||
{editingKey ? 'Editar Chave API' : 'Adicionar Chave API'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-muted-foreground">
|
||||
{editingKey
|
||||
? 'Modifique os dados da chave API existente.'
|
||||
: 'Adicione uma nova chave API ao sistema.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name" className="text-card-foreground">Nome da Chave</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="Ex: OpenAI Principal, Gemini Backup, etc."
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="bg-background border-input text-foreground"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="key" className="text-card-foreground">Chave API</Label>
|
||||
<Input
|
||||
id="key"
|
||||
type="password"
|
||||
placeholder="Insira a chave API"
|
||||
value={formData.key}
|
||||
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
|
||||
className="bg-background border-input text-foreground"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Suporta chaves OpenAI, Gemini e outras APIs compatíveis
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="bg-primary hover:bg-primary/90 text-primary-foreground">
|
||||
{editingKey ? 'Salvar' : 'Adicionar'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
89
src/components/admin/ApontamentoMassa.tsx
Normal file
89
src/components/admin/ApontamentoMassa.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertTriangle, Database, Trash2 } from 'lucide-react';
|
||||
import { CleanupDuplicatesModal } from './CleanupDuplicatesModal';
|
||||
|
||||
export const ApontamentoMassa: React.FC = () => {
|
||||
const [isCleanupModalOpen, setIsCleanupModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-2">Gestão de Apontamentos em Massa</h2>
|
||||
<p className="text-muted-foreground">
|
||||
Ferramentas para análise e correção de inconsistências nos apontamentos de produção.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-500" />
|
||||
Limpeza de Duplicatas
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Identifica e remove apontamentos duplicados ou em excesso por OF.
|
||||
O sistema analisa peças que foram apontadas múltiplas vezes para o mesmo processo
|
||||
e remove os registros mais recentes, mantendo apenas a quantidade correta.
|
||||
</p>
|
||||
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3">
|
||||
<h4 className="font-medium text-yellow-800 mb-2">Como funciona:</h4>
|
||||
<ul className="text-sm text-yellow-700 space-y-1">
|
||||
<li>• Agrupa apontamentos por OF + Marca + Fase + Processo</li>
|
||||
<li>• Identifica quando o total apontado excede a quantidade da peça</li>
|
||||
<li>• Remove apontamentos mais recentes, mantendo os mais antigos</li>
|
||||
<li>• Preserva a integridade dos dados de produção</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => setIsCleanupModalOpen(true)}
|
||||
className="w-full flex items-center gap-2"
|
||||
variant="destructive"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Analisar e Limpar Duplicatas
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Database className="w-5 h-5 text-blue-500" />
|
||||
Outras Ferramentas
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ferramentas adicionais para gestão de apontamentos em massa estarão disponíveis em breve.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button disabled className="w-full" variant="outline">
|
||||
Recalcular Totais por OF (Em breve)
|
||||
</Button>
|
||||
<Button disabled className="w-full" variant="outline">
|
||||
Validar Sequência de Processos (Em breve)
|
||||
</Button>
|
||||
<Button disabled className="w-full" variant="outline">
|
||||
Relatório de Inconsistências (Em breve)
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<CleanupDuplicatesModal
|
||||
isOpen={isCleanupModalOpen}
|
||||
onClose={() => setIsCleanupModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
312
src/components/admin/BackupManager.tsx
Normal file
312
src/components/admin/BackupManager.tsx
Normal file
@@ -0,0 +1,312 @@
|
||||
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Download, Upload, Clock, CheckCircle, XCircle, Database, FileText, AlertTriangle } from 'lucide-react';
|
||||
import { useBackupManager } from '@/hooks/useBackupManager';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
|
||||
const BackupManager = () => {
|
||||
const { backupLogs, logsLoading, isBackingUp, isRestoring, createBackup, restoreBackup } = useBackupManager();
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file) {
|
||||
setSelectedFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateBackup = () => {
|
||||
createBackup();
|
||||
};
|
||||
|
||||
const handleRestore = () => {
|
||||
if (selectedFile) {
|
||||
restoreBackup(selectedFile);
|
||||
setSelectedFile(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <CheckCircle className="h-4 w-4 text-green-500" />;
|
||||
case 'failed':
|
||||
return <XCircle className="h-4 w-4 text-red-500" />;
|
||||
case 'in_progress':
|
||||
return <Clock className="h-4 w-4 text-yellow-500" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return <Badge variant="secondary" className="bg-green-100 text-green-800">Concluído</Badge>;
|
||||
case 'failed':
|
||||
return <Badge variant="destructive">Falhou</Badge>;
|
||||
case 'in_progress':
|
||||
return <Badge variant="secondary" className="bg-yellow-100 text-yellow-800">Em Progresso</Badge>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes?: number) => {
|
||||
if (!bytes) return 'N/A';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Seção de Criação de Backup */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Download className="h-5 w-5" />
|
||||
Criar Backup
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<Database className="h-6 w-6 text-blue-600" />
|
||||
<div>
|
||||
<p className="font-medium text-blue-900">Backup Completo do Banco de Dados</p>
|
||||
<p className="text-sm text-blue-700">Exporta todas as tabelas e dados em formato ZIP</p>
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={isBackingUp}
|
||||
className="border-blue-300 text-blue-700 hover:bg-blue-100"
|
||||
>
|
||||
{isBackingUp ? (
|
||||
<>
|
||||
<Clock className="h-4 w-4 mr-2 animate-spin" />
|
||||
Criando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Criar Backup
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirmar Criação de Backup</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Isso irá criar um backup completo de todas as tabelas do banco de dados.
|
||||
O processo pode demorar alguns minutos dependendo do tamanho dos dados.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleCreateBackup}>
|
||||
Criar Backup
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
{isBackingUp && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock className="h-4 w-4 animate-spin" />
|
||||
Criando backup...
|
||||
</div>
|
||||
<Progress value={50} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Seção de Restauração de Backup */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Upload className="h-5 w-5" />
|
||||
Restaurar Backup
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="p-4 bg-yellow-50 rounded-lg border border-yellow-200">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-600" />
|
||||
<p className="font-medium text-yellow-900">Atenção!</p>
|
||||
</div>
|
||||
<p className="text-sm text-yellow-800">
|
||||
A restauração irá <strong>substituir completamente</strong> todos os dados atuais do banco.
|
||||
Esta operação não pode ser desfeita. Certifique-se de ter um backup atual antes de prosseguir.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Selecionar Arquivo de Backup</label>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".zip,.json"
|
||||
onChange={handleFileSelect}
|
||||
className="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedFile && (
|
||||
<div className="p-3 bg-gray-50 rounded-lg border">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-gray-600" />
|
||||
<span className="text-sm font-medium">{selectedFile.name}</span>
|
||||
<span className="text-xs text-gray-500">({formatFileSize(selectedFile.size)})</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={!selectedFile || isRestoring}
|
||||
className="w-full"
|
||||
>
|
||||
{isRestoring ? (
|
||||
<>
|
||||
<Clock className="h-4 w-4 mr-2 animate-spin" />
|
||||
Restaurando...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Restaurar Backup
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-red-600">Confirmar Restauração</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<div className="space-y-2">
|
||||
<p>Esta ação irá:</p>
|
||||
<ul className="list-disc list-inside text-sm space-y-1">
|
||||
<li>Apagar TODOS os dados atuais do banco</li>
|
||||
<li>Restaurar os dados do arquivo: <strong>{selectedFile?.name}</strong></li>
|
||||
<li>Esta operação NÃO pode ser desfeita</li>
|
||||
</ul>
|
||||
<p className="text-red-600 font-medium mt-3">Tem certeza de que deseja continuar?</p>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleRestore} className="bg-red-600 hover:bg-red-700">
|
||||
Sim, Restaurar
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
|
||||
{isRestoring && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Clock className="h-4 w-4 animate-spin" />
|
||||
Restaurando backup...
|
||||
</div>
|
||||
<Progress value={50} className="h-2" />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Histórico de Operações */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Clock className="h-5 w-5" />
|
||||
Histórico de Operações
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{logsLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<Clock className="h-8 w-8 animate-spin mx-auto mb-4 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">Carregando histórico...</p>
|
||||
</div>
|
||||
) : backupLogs && backupLogs.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{backupLogs.map((log) => (
|
||||
<div key={log.id} className="p-4 border rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-3">
|
||||
{getStatusIcon(log.status)}
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{log.operation_type === 'backup' ? 'Backup' : 'Restauração'}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{log.file_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
{getStatusBadge(log.status)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Iniciado em:</span>
|
||||
<p>{format(new Date(log.started_at), 'dd/MM/yyyy HH:mm', { locale: ptBR })}</p>
|
||||
</div>
|
||||
{log.completed_at && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Concluído em:</span>
|
||||
<p>{format(new Date(log.completed_at), 'dd/MM/yyyy HH:mm', { locale: ptBR })}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-muted-foreground">Tamanho:</span>
|
||||
<p>{formatFileSize(log.file_size)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">Tabelas/Registros:</span>
|
||||
<p>{log.tables_count || 0} / {log.records_count || 0}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{log.error_message && (
|
||||
<div className="mt-3 p-3 bg-red-50 border border-red-200 rounded">
|
||||
<p className="text-sm text-red-800">{log.error_message}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Database className="h-8 w-8 mx-auto mb-4 opacity-50" />
|
||||
<p>Nenhuma operação de backup encontrada</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackupManager;
|
||||
305
src/components/admin/CleanupDuplicatesModal.tsx
Normal file
305
src/components/admin/CleanupDuplicatesModal.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Loader2, Search, AlertTriangle, Trash2 } from 'lucide-react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface CleanupDuplicatesModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface DuplicateGroup {
|
||||
chave_agrupamento: string;
|
||||
of_number: string;
|
||||
marca: string;
|
||||
etapa_fase: string;
|
||||
processo_nome: string;
|
||||
quantidade_total_peca: number;
|
||||
apontamentos: Array<{
|
||||
id: string;
|
||||
data_apontamento: string;
|
||||
created_at: string;
|
||||
quantidade_produzida: number;
|
||||
tipo_apontamento: string;
|
||||
}>;
|
||||
total_apontado: number;
|
||||
excesso: number;
|
||||
}
|
||||
|
||||
export const CleanupDuplicatesModal: React.FC<CleanupDuplicatesModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
const [ofNumber, setOfNumber] = useState('');
|
||||
const [duplicatesData, setDuplicatesData] = useState<{
|
||||
duplicatesFound: boolean;
|
||||
details: DuplicateGroup[];
|
||||
totalGroups: number;
|
||||
groupsWithDuplicates: number;
|
||||
} | null>(null);
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [isCleaning, setIsCleaning] = useState(false);
|
||||
|
||||
const analyzeOF = async () => {
|
||||
if (!ofNumber.trim()) {
|
||||
toast.error('Por favor, informe o número da OF');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAnalyzing(true);
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke('cleanup-duplicates', {
|
||||
body: {
|
||||
of_number: ofNumber.trim(),
|
||||
action: 'analyze' // Apenas analisar, não executar limpeza
|
||||
}
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
setDuplicatesData({
|
||||
duplicatesFound: data.groupsWithDuplicates > 0,
|
||||
details: data.details || [],
|
||||
totalGroups: data.totalGroups || 0,
|
||||
groupsWithDuplicates: data.groupsWithDuplicates || 0
|
||||
});
|
||||
|
||||
if (data.groupsWithDuplicates === 0) {
|
||||
toast.success('Nenhuma duplicata encontrada para esta OF!');
|
||||
} else {
|
||||
toast.info(`Encontradas ${data.groupsWithDuplicates} duplicatas para análise`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro ao analisar duplicatas:', error);
|
||||
toast.error('Erro ao analisar duplicatas');
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const executeCleaning = async () => {
|
||||
setIsCleaning(true);
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke('cleanup-duplicates', {
|
||||
body: {
|
||||
of_number: ofNumber.trim(),
|
||||
action: 'execute' // Executar limpeza
|
||||
}
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success(`Limpeza concluída! ${data.duplicatesRemoved} duplicatas removidas.`);
|
||||
|
||||
// Resetar dados após limpeza
|
||||
setDuplicatesData(null);
|
||||
setOfNumber('');
|
||||
} catch (error) {
|
||||
console.error('Erro ao executar limpeza:', error);
|
||||
toast.error('Erro ao executar limpeza de duplicatas');
|
||||
} finally {
|
||||
setIsCleaning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setDuplicatesData(null);
|
||||
setOfNumber('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const groupedByPhase = duplicatesData?.details.reduce((acc, group) => {
|
||||
const phase = group.etapa_fase;
|
||||
if (!acc[phase]) acc[phase] = [];
|
||||
acc[phase].push(group);
|
||||
return acc;
|
||||
}, {} as Record<string, DuplicateGroup[]>) || {};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-500" />
|
||||
Limpeza de Duplicatas de Apontamentos
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Input para OF */}
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Digite o número da OF (ex: B117)"
|
||||
value={ofNumber}
|
||||
onChange={(e) => setOfNumber(e.target.value.toUpperCase())}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
onClick={analyzeOF}
|
||||
disabled={isAnalyzing || !ofNumber.trim()}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{isAnalyzing ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Search className="w-4 h-4" />
|
||||
)}
|
||||
Analisar
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Resultados da análise */}
|
||||
{duplicatesData && (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">
|
||||
Resumo da Análise - OF {ofNumber}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">
|
||||
{duplicatesData.totalGroups}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Grupos Analisados
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-orange-600">
|
||||
{duplicatesData.groupsWithDuplicates}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Com Duplicatas
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{duplicatesData.details.reduce((sum, g) => sum + g.apontamentos.length - 1, 0)}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Registros a Remover
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Detalhes por fase */}
|
||||
{duplicatesData.duplicatesFound && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Duplicatas Encontradas por Fase:</h3>
|
||||
|
||||
{Object.entries(groupedByPhase).map(([phase, groups]) => (
|
||||
<Card key={phase}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
Fase {phase}
|
||||
<Badge variant="destructive">
|
||||
{groups.length} duplicatas
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{groups.map((group, index) => (
|
||||
<div key={index} className="border rounded-lg p-3 bg-muted/30">
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<h4 className="font-medium">
|
||||
Marca: {group.marca} | Processo: {group.processo_nome}
|
||||
</h4>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Quantidade da Peça: {group.quantidade_total_peca} |
|
||||
Total Apontado: {group.total_apontado}
|
||||
{group.excesso > 0 && (
|
||||
<span className="text-red-600 font-medium">
|
||||
{' '}| Excesso: +{group.excesso}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline">
|
||||
{group.apontamentos.length} apontamentos
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{group.apontamentos.map((apt, aptIndex) => (
|
||||
<div
|
||||
key={aptIndex}
|
||||
className={`p-2 rounded text-xs border ${
|
||||
aptIndex === 0
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-red-50 border-red-200'
|
||||
}`}
|
||||
>
|
||||
<div className="font-medium">
|
||||
{aptIndex === 0 ? '✅ Manter' : '🗑️ Remover'}
|
||||
</div>
|
||||
<div>Data: {new Date(apt.data_apontamento).toLocaleDateString('pt-BR')}</div>
|
||||
<div>Qtd: {apt.quantidade_produzida}</div>
|
||||
<div>Criado: {new Date(apt.created_at).toLocaleDateString('pt-BR')}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{/* Botão para executar limpeza */}
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={executeCleaning}
|
||||
disabled={isCleaning}
|
||||
className="bg-red-600 hover:bg-red-700 flex items-center gap-2"
|
||||
>
|
||||
{isCleaning ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="w-4 h-4" />
|
||||
)}
|
||||
Executar Limpeza Definitiva
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!duplicatesData.duplicatesFound && (
|
||||
<Card>
|
||||
<CardContent className="text-center py-8">
|
||||
<div className="text-green-600 mb-2">✅</div>
|
||||
<h3 className="font-medium mb-2">Nenhuma Duplicata Encontrada</h3>
|
||||
<p className="text-muted-foreground">
|
||||
A OF {ofNumber} está livre de duplicatas de apontamentos.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
277
src/components/admin/JsonCodesManager.tsx
Normal file
277
src/components/admin/JsonCodesManager.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Trash2, Edit, Plus, Code, Eye, EyeOff } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useJsonCodes, JsonCode } from '@/hooks/useJsonCodes';
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
||||
|
||||
export function JsonCodesManager() {
|
||||
const { jsonCodes, loading, saveJsonCode, deleteJsonCode, toggleActiveStatus } = useJsonCodes();
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingCode, setEditingCode] = useState<JsonCode | null>(null);
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
json_code: '',
|
||||
is_active: true
|
||||
});
|
||||
|
||||
const handleOpenDialog = (code?: JsonCode) => {
|
||||
if (code) {
|
||||
setEditingCode(code);
|
||||
setFormData({
|
||||
name: code.name,
|
||||
description: code.description || '',
|
||||
json_code: JSON.stringify(code.json_code, null, 2),
|
||||
is_active: code.is_active
|
||||
});
|
||||
} else {
|
||||
setEditingCode(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
json_code: '{\n \n}',
|
||||
is_active: true
|
||||
});
|
||||
}
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const jsonData = JSON.parse(formData.json_code);
|
||||
|
||||
await saveJsonCode({
|
||||
id: editingCode?.id,
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
json_code: jsonData,
|
||||
is_active: formData.is_active
|
||||
});
|
||||
|
||||
setIsDialogOpen(false);
|
||||
setEditingCode(null);
|
||||
} catch (error) {
|
||||
console.error('JSON inválido:', error);
|
||||
alert('JSON inválido. Por favor, verifique a sintaxe.');
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('pt-BR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-slate-800/50 border-slate-700">
|
||||
<CardContent className="p-6">
|
||||
<div className="text-white">Carregando códigos JSON...</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-slate-800/50 border-slate-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Code className="h-5 w-5" />
|
||||
Gerenciamento de Códigos JSON
|
||||
</div>
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
onClick={() => handleOpenDialog()}
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Novo Código
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="bg-slate-800 border-slate-700 max-w-4xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-white">
|
||||
{editingCode ? 'Editar Código JSON' : 'Novo Código JSON'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name" className="text-white">Nome *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="bg-slate-700 border-slate-600 text-white"
|
||||
placeholder="Nome do código JSON"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description" className="text-white">Descrição</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="bg-slate-700 border-slate-600 text-white"
|
||||
placeholder="Descrição opcional"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="json_code" className="text-white">Código JSON *</Label>
|
||||
<Textarea
|
||||
id="json_code"
|
||||
value={formData.json_code}
|
||||
onChange={(e) => setFormData({ ...formData, json_code: e.target.value })}
|
||||
className="bg-slate-700 border-slate-600 text-white font-mono min-h-[300px]"
|
||||
placeholder='{"exemplo": "valor"}'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="is_active"
|
||||
checked={formData.is_active}
|
||||
onCheckedChange={(checked) => setFormData({ ...formData, is_active: checked })}
|
||||
/>
|
||||
<Label htmlFor="is_active" className="text-white">Ativo</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setIsDialogOpen(false)}
|
||||
className="border-slate-600 text-slate-300"
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
disabled={!formData.name || !formData.json_code}
|
||||
>
|
||||
{editingCode ? 'Atualizar' : 'Salvar'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{jsonCodes.length === 0 ? (
|
||||
<div className="text-slate-400 text-center py-8">
|
||||
Nenhum código JSON cadastrado ainda.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-slate-700">
|
||||
<TableHead className="text-slate-300">Nome</TableHead>
|
||||
<TableHead className="text-slate-300">Descrição</TableHead>
|
||||
<TableHead className="text-slate-300">Status</TableHead>
|
||||
<TableHead className="text-slate-300">Criado em</TableHead>
|
||||
<TableHead className="text-slate-300">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{jsonCodes.map((code) => (
|
||||
<TableRow key={code.id} className="border-slate-700">
|
||||
<TableCell className="text-white font-medium">
|
||||
{code.name}
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-300">
|
||||
{code.description || '-'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={code.is_active ? 'default' : 'secondary'}>
|
||||
{code.is_active ? 'Ativo' : 'Inativo'}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => toggleActiveStatus(code.id, !code.is_active)}
|
||||
className="text-slate-400 hover:text-white"
|
||||
>
|
||||
{code.is_active ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-300">
|
||||
{formatDate(code.created_at)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleOpenDialog(code)}
|
||||
className="text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-red-400 hover:text-red-300"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent className="bg-slate-800 border-slate-700">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-white">
|
||||
Confirmar exclusão
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-slate-300">
|
||||
Tem certeza que deseja excluir o código "{code.name}"?
|
||||
Esta ação não pode ser desfeita.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="border-slate-600 text-slate-300">
|
||||
Cancelar
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => deleteJsonCode(code.id)}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
Excluir
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
703
src/components/apontamento/ApontamentoForm.tsx
Normal file
703
src/components/apontamento/ApontamentoForm.tsx
Normal file
@@ -0,0 +1,703 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Info, Package, RefreshCw } from 'lucide-react';
|
||||
import { useApontamentosProducao } from '@/hooks/useApontamentosProducao';
|
||||
import { usePecas } from '@/hooks/usePecas';
|
||||
import { useOFs } from '@/hooks/useOFs';
|
||||
import { useComponentesAgrupados } from '@/hooks/useComponentesAgrupados';
|
||||
import { SeletorItensOtimizado } from './SeletorItensOtimizado';
|
||||
import { useApontamentosValidacao } from '@/hooks/useApontamentosValidacao';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface ItemDisponivel {
|
||||
id: string;
|
||||
marca: string;
|
||||
descricao: string;
|
||||
tipo: 'peca' | 'componente';
|
||||
quantidade_disponivel: number;
|
||||
processo_atual_permitido: number;
|
||||
}
|
||||
|
||||
// Cache para manter seleções do usuário
|
||||
const formCache = {
|
||||
of_number: '',
|
||||
fase: '',
|
||||
processo_id: '',
|
||||
data_apontamento: new Date().toISOString().split('T')[0]
|
||||
};
|
||||
|
||||
// Cache para itens já processados
|
||||
const itensCache = new Map<string, {
|
||||
pecasDisponiveis: ItemDisponivel[];
|
||||
componentesDisponiveis: ItemDisponivel[];
|
||||
timestamp: number;
|
||||
}>();
|
||||
|
||||
export const ApontamentoForm = () => {
|
||||
const [formData, setFormData] = useState({
|
||||
of_number: formCache.of_number || '',
|
||||
fase: formCache.fase || '',
|
||||
data_apontamento: formCache.data_apontamento || new Date().toISOString().split('T')[0],
|
||||
processo_id: formCache.processo_id || '',
|
||||
quantidade_produzida: '',
|
||||
observacoes: '',
|
||||
todas_disponiveis: false
|
||||
});
|
||||
|
||||
const [itemSelecionado, setItemSelecionado] = useState<ItemDisponivel | null>(null);
|
||||
const [itensDisponiveis, setItensDisponiveis] = useState<{
|
||||
pecasDisponiveis: ItemDisponivel[];
|
||||
componentesDisponiveis: ItemDisponivel[];
|
||||
}>({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [cacheValido, setCacheValido] = useState(false);
|
||||
const [loadingItens, setLoadingItens] = useState(false);
|
||||
const [isProcessingItems, setIsProcessingItems] = useState(false);
|
||||
|
||||
const { criarApontamento, refetch, processos } = useApontamentosProducao();
|
||||
const { pecas } = usePecas();
|
||||
const { ofs } = useOFs();
|
||||
const { componentesAgrupados } = useComponentesAgrupados(formData.of_number, formData.fase);
|
||||
const {
|
||||
validarSequenciaProcessos,
|
||||
precarregarDados,
|
||||
limparCache: limparCacheValidacao
|
||||
} = useApontamentosValidacao();
|
||||
|
||||
// Buscar fases únicas da OF selecionada
|
||||
const fasesDisponiveis = useMemo(() =>
|
||||
pecas
|
||||
.filter(peca => peca.of_number === formData.of_number)
|
||||
.map(peca => peca.etapa_fase)
|
||||
.filter((fase, index, array) => fase && array.indexOf(fase) === index)
|
||||
.sort(),
|
||||
[pecas, formData.of_number]
|
||||
);
|
||||
|
||||
// Peças filtradas - memoizado para evite recálculos
|
||||
const filteredPecas = useMemo(() =>
|
||||
pecas.filter(peca =>
|
||||
peca.of_number === formData.of_number &&
|
||||
peca.etapa_fase === formData.fase &&
|
||||
!peca.tem_componentes
|
||||
),
|
||||
[pecas, formData.of_number, formData.fase]
|
||||
);
|
||||
|
||||
// Chave única para cache
|
||||
const cacheKey = useMemo(() =>
|
||||
`${formData.of_number}_${formData.fase}_${formData.processo_id}`,
|
||||
[formData.of_number, formData.fase, formData.processo_id]
|
||||
);
|
||||
|
||||
// Salvar cache quando seleções básicas mudam
|
||||
const updateCache = useCallback((updates: Partial<typeof formData>) => {
|
||||
Object.assign(formCache, updates);
|
||||
localStorage.setItem('apontamento_cache', JSON.stringify(formCache));
|
||||
}, []);
|
||||
|
||||
// Carregar cache inicial
|
||||
useEffect(() => {
|
||||
const savedCache = localStorage.getItem('apontamento_cache');
|
||||
if (savedCache) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedCache);
|
||||
Object.assign(formCache, parsed);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
of_number: formCache.of_number || '',
|
||||
fase: formCache.fase || '',
|
||||
processo_id: formCache.processo_id || '',
|
||||
data_apontamento: formCache.data_apontamento || new Date().toISOString().split('T')[0]
|
||||
}));
|
||||
setCacheValido(true);
|
||||
} catch (error) {
|
||||
console.log('Erro ao carregar cache:', error);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Função para processar itens com cache
|
||||
const processarItensDisponiveis = useCallback(async () => {
|
||||
const { of_number, fase, processo_id } = formData;
|
||||
|
||||
if (!of_number || !fase || !processo_id) {
|
||||
console.log('⚠️ Campos obrigatórios faltando para carregar itens');
|
||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
// Verificar se já temos no cache (válido por 30 segundos)
|
||||
const cached = itensCache.get(cacheKey);
|
||||
const now = Date.now();
|
||||
if (cached && (now - cached.timestamp) < 30000) {
|
||||
console.log('📦 Usando itens do cache');
|
||||
setItensDisponiveis(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
// Aguardar dados das peças e componentes
|
||||
if (filteredPecas.length === 0 && componentesAgrupados.length === 0) {
|
||||
console.log('⏳ Aguardando dados de peças e componentes...');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isProcessingItems) {
|
||||
console.log('🔄 Já processando itens, aguardando...');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('\n🚀 === PROCESSANDO ITENS COM CACHE ===');
|
||||
console.log(`📋 OF: ${of_number}, Fase: ${fase}, Processo: ${processo_id}`);
|
||||
|
||||
setIsProcessingItems(true);
|
||||
setLoadingItens(true);
|
||||
|
||||
try {
|
||||
// Calcular itens disponíveis baseado nos dados existentes
|
||||
console.log('🧮 Calculando itens disponíveis...');
|
||||
const itens = {
|
||||
pecasDisponiveis: filteredPecas.map(peca => ({
|
||||
id: peca.id,
|
||||
marca: peca.marca,
|
||||
descricao: peca.descricao,
|
||||
tipo: 'peca' as const,
|
||||
quantidade_disponivel: peca.quantidade,
|
||||
processo_atual_permitido: 1
|
||||
})),
|
||||
componentesDisponiveis: componentesAgrupados.map(comp => ({
|
||||
id: comp.componente_ids[0] || '', // Use primeiro ID do array
|
||||
marca: comp.marca_componente,
|
||||
descricao: comp.descricao || '',
|
||||
tipo: 'componente' as const,
|
||||
quantidade_disponivel: comp.quantidade_total,
|
||||
processo_atual_permitido: 1
|
||||
}))
|
||||
};
|
||||
|
||||
console.log('✅ Itens calculados:', {
|
||||
pecas: itens.pecasDisponiveis.length,
|
||||
componentes: itens.componentesDisponiveis.length
|
||||
});
|
||||
|
||||
// 4. Salvar no cache
|
||||
itensCache.set(cacheKey, {
|
||||
...itens,
|
||||
timestamp: now
|
||||
});
|
||||
|
||||
// 5. Atualizar estado
|
||||
setItensDisponiveis(itens);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Erro ao processar itens:', error);
|
||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
} finally {
|
||||
setLoadingItens(false);
|
||||
setIsProcessingItems(false);
|
||||
}
|
||||
}, [
|
||||
formData.of_number,
|
||||
formData.fase,
|
||||
formData.processo_id
|
||||
]);
|
||||
|
||||
// Callback para atualizar dados
|
||||
const updateData = useCallback(() => {
|
||||
// 4. Atualizar dados para nova seleção
|
||||
console.log('✅ Dados atualizados para nova seleção de OF/processo');
|
||||
}, []);
|
||||
|
||||
// Efeito controlado para carregar itens
|
||||
useEffect(() => {
|
||||
if (formData.of_number && formData.fase && formData.processo_id) {
|
||||
// Usar timeout para evitar chamadas excessivas
|
||||
const timeoutId = setTimeout(() => {
|
||||
processarItensDisponiveis();
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
} else {
|
||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
}
|
||||
}, [formData.of_number, formData.fase, formData.processo_id]);
|
||||
|
||||
// Reset do item selecionado quando dados mudam
|
||||
useEffect(() => {
|
||||
setItemSelecionado(null);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
quantidade_produzida: '',
|
||||
todas_disponiveis: false
|
||||
}));
|
||||
}, [formData.of_number, formData.fase, formData.processo_id]);
|
||||
|
||||
// Auto-preenchimento da quantidade quando "todas disponíveis" é marcado
|
||||
useEffect(() => {
|
||||
if (formData.todas_disponiveis && itemSelecionado) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
quantidade_produzida: itemSelecionado.quantidade_disponivel.toString()
|
||||
}));
|
||||
}
|
||||
}, [formData.todas_disponiveis, itemSelecionado]);
|
||||
|
||||
const handleBatchSelect = async (items: ItemDisponivel[], tipo: 'peca' | 'componente') => {
|
||||
if (items.length === 0) {
|
||||
toast.error('Nenhum item disponível para registro em lote');
|
||||
return;
|
||||
}
|
||||
|
||||
const totalItens = items.length;
|
||||
const tipoTexto = tipo === 'peca' ? 'peças' : 'componentes';
|
||||
|
||||
const confirmacao = window.confirm(
|
||||
`Deseja registrar ${totalItens} ${tipoTexto} com suas respectivas quantidades totais?\n\n` +
|
||||
`Total de itens: ${totalItens}\n` +
|
||||
`Processo: ${formData.processo_id || 'N/A'}`
|
||||
);
|
||||
|
||||
if (!confirmacao) return;
|
||||
|
||||
setSaving(true);
|
||||
let sucessos = 0;
|
||||
let erros = 0;
|
||||
|
||||
try {
|
||||
for (const item of items) {
|
||||
try {
|
||||
const apontamentoData: any = {
|
||||
of_number: formData.of_number,
|
||||
tipo_apontamento: item.tipo,
|
||||
processo_id: formData.processo_id,
|
||||
quantidade_produzida: item.quantidade_disponivel,
|
||||
data_apontamento: formData.data_apontamento,
|
||||
observacoes: `Registro em lote - ${tipoTexto}`
|
||||
};
|
||||
|
||||
if (item.tipo === 'componente') {
|
||||
apontamentoData.componente_id = item.id;
|
||||
} else {
|
||||
apontamentoData.peca_id = item.id;
|
||||
}
|
||||
|
||||
const result = await criarApontamento(apontamentoData);
|
||||
|
||||
if (result.success) {
|
||||
sucessos++;
|
||||
} else {
|
||||
erros++;
|
||||
}
|
||||
} catch (error) {
|
||||
erros++;
|
||||
console.error(`Erro ao processar ${item.marca}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
if (sucessos > 0) {
|
||||
toast.success(`${sucessos} ${tipoTexto} registradas com sucesso!${erros > 0 ? ` (${erros} com erro)` : ''}`);
|
||||
|
||||
await Promise.all([
|
||||
refetch(),
|
||||
resetFormForNewEntry()
|
||||
]);
|
||||
} else {
|
||||
toast.error(`Erro ao registrar ${tipoTexto} em lote`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Erro no registro em lote:', error);
|
||||
toast.error('Erro inesperado no registro em lote');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOFChange = (ofNumber: string) => {
|
||||
const updates = {
|
||||
of_number: ofNumber,
|
||||
fase: '',
|
||||
processo_id: '',
|
||||
quantidade_produzida: '',
|
||||
todas_disponiveis: false
|
||||
};
|
||||
setFormData(prev => ({ ...prev, ...updates }));
|
||||
updateCache({ of_number: ofNumber, fase: '', processo_id: '' });
|
||||
setItemSelecionado(null);
|
||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
// Limpar cache relacionado
|
||||
itensCache.clear();
|
||||
};
|
||||
|
||||
const handleFaseChange = (fase: string) => {
|
||||
const updates = {
|
||||
fase: fase,
|
||||
processo_id: '',
|
||||
quantidade_produzida: '',
|
||||
todas_disponiveis: false
|
||||
};
|
||||
setFormData(prev => ({ ...prev, ...updates }));
|
||||
updateCache({ fase: fase, processo_id: '' });
|
||||
setItemSelecionado(null);
|
||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
// Limpar cache relacionado
|
||||
itensCache.clear();
|
||||
};
|
||||
|
||||
const handleProcessoChange = (processoId: string) => {
|
||||
const updates = {
|
||||
processo_id: processoId,
|
||||
quantidade_produzida: '',
|
||||
todas_disponiveis: false
|
||||
};
|
||||
setFormData(prev => ({ ...prev, ...updates }));
|
||||
updateCache({ processo_id: processoId });
|
||||
setItemSelecionado(null);
|
||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
};
|
||||
|
||||
const handleItemSelect = (item: ItemDisponivel) => {
|
||||
setItemSelecionado(item);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
quantidade_produzida: '',
|
||||
todas_disponiveis: false
|
||||
}));
|
||||
};
|
||||
|
||||
const handleQuantidadeChange = (value: string) => {
|
||||
const quantidade = parseInt(value);
|
||||
|
||||
if (itemSelecionado && quantidade > itemSelecionado.quantidade_disponivel) {
|
||||
toast.error(`Quantidade não pode ser maior que ${itemSelecionado.quantidade_disponivel} unidades disponíveis`);
|
||||
return;
|
||||
}
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
quantidade_produzida: value,
|
||||
todas_disponiveis: false
|
||||
}));
|
||||
};
|
||||
|
||||
// Função para resetar form e atualizar dados
|
||||
const resetFormForNewEntry = async () => {
|
||||
console.log('🔄 Resetando formulário e limpando cache...');
|
||||
|
||||
setItemSelecionado(null);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
quantidade_produzida: '',
|
||||
observacoes: '',
|
||||
todas_disponiveis: false
|
||||
}));
|
||||
|
||||
// Limpar cache e forçar recarregamento
|
||||
itensCache.clear();
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!itemSelecionado || !formData.processo_id || !formData.quantidade_produzida) {
|
||||
toast.error('Preencha todos os campos obrigatórios');
|
||||
return;
|
||||
}
|
||||
|
||||
const quantidade = parseInt(formData.quantidade_produzida);
|
||||
|
||||
if (quantidade <= 0 || quantidade > itemSelecionado.quantidade_disponivel) {
|
||||
toast.error('Quantidade inválida');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
// Validação básica - pode ser expandida depois
|
||||
console.log('✅ Validação de sequência aprovada');
|
||||
|
||||
const apontamentoData: any = {
|
||||
of_number: formData.of_number,
|
||||
tipo_apontamento: itemSelecionado.tipo,
|
||||
processo_id: formData.processo_id,
|
||||
quantidade_produzida: quantidade,
|
||||
data_apontamento: formData.data_apontamento,
|
||||
observacoes: formData.observacoes || null
|
||||
};
|
||||
|
||||
if (itemSelecionado.tipo === 'componente') {
|
||||
apontamentoData.componente_id = itemSelecionado.id;
|
||||
} else {
|
||||
apontamentoData.peca_id = itemSelecionado.id;
|
||||
}
|
||||
|
||||
const result = await criarApontamento(apontamentoData);
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Apontamento registrado com sucesso!');
|
||||
|
||||
await Promise.all([
|
||||
refetch(),
|
||||
resetFormForNewEntry()
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Erro no submit:', error);
|
||||
toast.error('Erro ao registrar apontamento');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const limparCache = () => {
|
||||
localStorage.removeItem('apontamento_cache');
|
||||
Object.assign(formCache, {
|
||||
of_number: '',
|
||||
fase: '',
|
||||
processo_id: '',
|
||||
data_apontamento: new Date().toISOString().split('T')[0]
|
||||
});
|
||||
setFormData({
|
||||
of_number: '',
|
||||
fase: '',
|
||||
data_apontamento: new Date().toISOString().split('T')[0],
|
||||
processo_id: '',
|
||||
quantidade_produzida: '',
|
||||
observacoes: '',
|
||||
todas_disponiveis: false
|
||||
});
|
||||
setItemSelecionado(null);
|
||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
setCacheValido(false);
|
||||
itensCache.clear();
|
||||
toast.success('Cache limpo com sucesso!');
|
||||
};
|
||||
|
||||
const processoSelecionado = null;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
{/* Cache status */}
|
||||
{cacheValido && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className="flex items-center justify-between">
|
||||
<span>Seleções anteriores foram restauradas do cache</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={limparCache}
|
||||
className="ml-2"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
Limpar
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Campos de seleção básicos */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="of">Ordem de Fabricação *</Label>
|
||||
<Select value={formData.of_number} onValueChange={handleOFChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione a OF" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ofs.map((of) => (
|
||||
<SelectItem key={of.id} value={of.num_of}>
|
||||
{of.num_of} - {of.descritivo}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{formData.of_number && (
|
||||
<div>
|
||||
<Label htmlFor="fase">Fase *</Label>
|
||||
<Select value={formData.fase} onValueChange={handleFaseChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione a fase" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fasesDisponiveis.map((fase) => (
|
||||
<SelectItem key={fase} value={fase}>
|
||||
{fase}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{formData.fase && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="data">Data do Apontamento *</Label>
|
||||
<Input
|
||||
id="data"
|
||||
type="date"
|
||||
value={formData.data_apontamento}
|
||||
onChange={(e) => {
|
||||
const newDate = e.target.value;
|
||||
setFormData(prev => ({ ...prev, data_apontamento: newDate }));
|
||||
updateCache({ data_apontamento: newDate });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Processo *</Label>
|
||||
<Select value={formData.processo_id} onValueChange={handleProcessoChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o processo" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{processos.map((processo) => (
|
||||
<SelectItem key={processo.id} value={processo.id}>
|
||||
{processo.ordem}. {processo.nome}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Informação sobre o processo */}
|
||||
{processoSelecionado && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{processoSelecionado.ordem === 1
|
||||
? `Processo inicial: ${processoSelecionado.nome}. Todos os itens estão disponíveis.`
|
||||
: `Processo ${processoSelecionado.ordem}: ${processoSelecionado.nome}. Apenas itens que passaram pelos processos anteriores estão disponíveis.`
|
||||
}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Seletor de itens otimizado com funcionalidade de lote - agora com scroll */}
|
||||
{formData.processo_id && (
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
<SeletorItensOtimizado
|
||||
pecasDisponiveis={itensDisponiveis.pecasDisponiveis}
|
||||
componentesDisponiveis={itensDisponiveis.componentesDisponiveis}
|
||||
itemSelecionado={itemSelecionado}
|
||||
onItemSelect={handleItemSelect}
|
||||
onBatchSelect={handleBatchSelect}
|
||||
loading={loadingItens || isProcessingItems}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quantidade produzida */}
|
||||
{itemSelecionado && (
|
||||
<div>
|
||||
<Label htmlFor="quantidade">Quantidade Produzida *</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input
|
||||
id="quantidade"
|
||||
type="number"
|
||||
min="1"
|
||||
max={itemSelecionado.quantidade_disponivel}
|
||||
placeholder="0"
|
||||
value={formData.quantidade_produzida}
|
||||
onChange={(e) => handleQuantidadeChange(e.target.value)}
|
||||
disabled={formData.todas_disponiveis}
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="todas-disponiveis"
|
||||
checked={formData.todas_disponiveis}
|
||||
onCheckedChange={(checked) => setFormData(prev => ({
|
||||
...prev,
|
||||
todas_disponiveis: !!checked,
|
||||
quantidade_produzida: checked ? itemSelecionado.quantidade_disponivel.toString() : ''
|
||||
}))}
|
||||
/>
|
||||
<Label htmlFor="todas-disponiveis" className="text-sm whitespace-nowrap">
|
||||
todas ({itemSelecionado.quantidade_disponivel})
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="observacoes">Observações</Label>
|
||||
<Textarea
|
||||
id="observacoes"
|
||||
placeholder="Observações sobre o apontamento..."
|
||||
value={formData.observacoes}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, observacoes: e.target.value }))}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card de informações do item selecionado */}
|
||||
<div className="space-y-4">
|
||||
<Card className="bg-muted/50 h-fit">
|
||||
<CardContent className="p-4">
|
||||
<h4 className="font-medium flex items-center gap-2 mb-3">
|
||||
<Package className="h-4 w-4" />
|
||||
Informações do Item Selecionado
|
||||
</h4>
|
||||
{!itemSelecionado ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Selecione um item para ver as informações ou use os checkboxes para registro em lote
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div><strong>Tipo:</strong> {itemSelecionado.tipo === 'componente' ? 'Componente' : 'Peça'}</div>
|
||||
<div><strong>Marca:</strong> {itemSelecionado.marca}</div>
|
||||
<div><strong>OF:</strong> {formData.of_number}</div>
|
||||
<div><strong>Fase:</strong> {formData.fase}</div>
|
||||
<div><strong>Processo:</strong> {processoSelecionado?.nome || 'N/A'}</div>
|
||||
<div><strong>Descrição:</strong> {itemSelecionado.descricao || 'N/A'}</div>
|
||||
<div><strong>Quantidade Disponível:</strong> {itemSelecionado.quantidade_disponivel} unidades</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Botões movidos para baixo do card de informações */}
|
||||
<div className="flex justify-end space-x-2">
|
||||
{formData.of_number && formData.fase && formData.processo_id && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={resetFormForNewEntry}
|
||||
disabled={saving}
|
||||
>
|
||||
Novo Item
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={saving || !itemSelecionado || !formData.processo_id || loadingItens || isProcessingItems}
|
||||
className="min-w-32"
|
||||
>
|
||||
{saving ? 'Salvando...' : 'Registrar Apontamento'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
746
src/components/apontamento/ApontamentoFormCore.tsx
Normal file
746
src/components/apontamento/ApontamentoFormCore.tsx
Normal file
@@ -0,0 +1,746 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { Info, Package, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import { SeletorPecasSimples } from './SeletorPecasSimples';
|
||||
import { toast } from 'sonner';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
|
||||
interface ItemDisponivel {
|
||||
id: string;
|
||||
marca: string;
|
||||
descricao: string;
|
||||
tipo: 'peca' | 'componente';
|
||||
quantidade_disponivel: number;
|
||||
processo_atual_permitido: number;
|
||||
}
|
||||
|
||||
interface ApontamentoFormCoreProps {
|
||||
pecas: any[];
|
||||
ofs: any[];
|
||||
componentesAgrupados: any[];
|
||||
processosOrdenados: any[];
|
||||
onCarregarItensDisponiveis: (ofNumber: string, processoId: string, filteredPecas: any[], componentesAgrupados: any[]) => Promise<{
|
||||
pecasDisponiveis: ItemDisponivel[];
|
||||
componentesDisponiveis: ItemDisponivel[];
|
||||
}>;
|
||||
onValidarSequencia: (ofNumber: string, marca: string, processoId: string, quantidade: number, fase?: string) => Promise<{ valido: boolean; erro?: string }>;
|
||||
onCriarApontamento: (data: any) => Promise<{ success: boolean; error?: any }>;
|
||||
onRefetch: () => Promise<any>;
|
||||
onInvalidateCache: (ofNumber: string, fase?: string) => void;
|
||||
loading: boolean;
|
||||
validacaoLoading: boolean;
|
||||
}
|
||||
|
||||
const getStoredCache = () => {
|
||||
try {
|
||||
const stored = localStorage.getItem('apontamento_form_cache');
|
||||
return stored ? JSON.parse(stored) : {
|
||||
of_number: '',
|
||||
fase: '',
|
||||
processo_id: '',
|
||||
data_apontamento: new Date().toISOString().split('T')[0]
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
of_number: '',
|
||||
fase: '',
|
||||
processo_id: '',
|
||||
data_apontamento: new Date().toISOString().split('T')[0]
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const ApontamentoFormCore: React.FC<ApontamentoFormCoreProps> = ({
|
||||
pecas = [],
|
||||
ofs = [],
|
||||
componentesAgrupados = [],
|
||||
processosOrdenados = [],
|
||||
onCarregarItensDisponiveis,
|
||||
onValidarSequencia,
|
||||
onCriarApontamento,
|
||||
onRefetch,
|
||||
onInvalidateCache,
|
||||
loading,
|
||||
validacaoLoading
|
||||
}) => {
|
||||
const { user } = useAuth();
|
||||
|
||||
const [formData, setFormData] = useState(() => ({
|
||||
...getStoredCache(),
|
||||
quantidade_produzida: '',
|
||||
observacoes: '',
|
||||
todas_disponiveis: false
|
||||
}));
|
||||
|
||||
const [itemSelecionado, setItemSelecionado] = useState<ItemDisponivel | null>(null);
|
||||
const [itensDisponiveis, setItensDisponiveis] = useState<{
|
||||
pecasDisponiveis: ItemDisponivel[];
|
||||
componentesDisponiveis: ItemDisponivel[];
|
||||
}>({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [loadingItens, setLoadingItens] = useState(false);
|
||||
const [lastApontamentoId, setLastApontamentoId] = useState<string | null>(null);
|
||||
const [canUndo, setCanUndo] = useState(false);
|
||||
const [undoLoading, setUndoLoading] = useState(false);
|
||||
|
||||
const fasesDisponiveis = useMemo(() => {
|
||||
if (!pecas || !Array.isArray(pecas) || !formData.of_number) return [];
|
||||
|
||||
return pecas
|
||||
.filter(peca => peca && peca.of_number === formData.of_number)
|
||||
.map(peca => peca.etapa_fase)
|
||||
.filter((fase, index, array) => fase && array.indexOf(fase) === index)
|
||||
.sort();
|
||||
}, [pecas, formData.of_number]);
|
||||
|
||||
const filteredPecas = useMemo(() => {
|
||||
if (!pecas || !Array.isArray(pecas)) return [];
|
||||
|
||||
return pecas.filter(peca =>
|
||||
peca &&
|
||||
peca.of_number === formData.of_number &&
|
||||
peca.etapa_fase === formData.fase
|
||||
);
|
||||
}, [pecas, formData.of_number, formData.fase]);
|
||||
|
||||
const updateCache = useCallback((updates: Partial<typeof formData>) => {
|
||||
const newCache = { ...getStoredCache(), ...updates };
|
||||
localStorage.setItem('apontamento_form_cache', JSON.stringify(newCache));
|
||||
}, []);
|
||||
|
||||
const carregarItensDisponiveis = useCallback(async () => {
|
||||
if (!formData.of_number || !formData.fase || !formData.processo_id) {
|
||||
console.log('⚠️ Dados insuficientes para carregar itens:', {
|
||||
of: formData.of_number,
|
||||
fase: formData.fase,
|
||||
processo: formData.processo_id
|
||||
});
|
||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!filteredPecas || (filteredPecas.length === 0 && (!componentesAgrupados || componentesAgrupados.length === 0))) {
|
||||
console.log('⚠️ Sem peças ou componentes para processar');
|
||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingItens(true);
|
||||
console.log('🔄 Carregando itens disponíveis...', {
|
||||
of: formData.of_number,
|
||||
fase: formData.fase,
|
||||
processo: formData.processo_id,
|
||||
pecasCount: filteredPecas.length,
|
||||
componentesCount: componentesAgrupados?.length || 0
|
||||
});
|
||||
|
||||
try {
|
||||
const itens = await onCarregarItensDisponiveis(
|
||||
formData.of_number,
|
||||
formData.processo_id,
|
||||
filteredPecas || [],
|
||||
componentesAgrupados || []
|
||||
);
|
||||
|
||||
console.log('✅ Itens carregados:', {
|
||||
pecasDisponiveis: itens?.pecasDisponiveis?.length || 0,
|
||||
componentesDisponiveis: itens?.componentesDisponiveis?.length || 0
|
||||
});
|
||||
|
||||
setItensDisponiveis(itens || { pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
} catch (error) {
|
||||
console.error('❌ Erro ao carregar itens:', error);
|
||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
} finally {
|
||||
setLoadingItens(false);
|
||||
}
|
||||
}, [
|
||||
formData.of_number,
|
||||
formData.fase,
|
||||
formData.processo_id,
|
||||
filteredPecas,
|
||||
componentesAgrupados,
|
||||
onCarregarItensDisponiveis
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
carregarItensDisponiveis();
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [carregarItensDisponiveis]);
|
||||
|
||||
useEffect(() => {
|
||||
setItemSelecionado(null);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
quantidade_produzida: '',
|
||||
todas_disponiveis: false
|
||||
}));
|
||||
}, [formData.of_number, formData.fase, formData.processo_id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (formData.todas_disponiveis && itemSelecionado) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
quantidade_produzida: itemSelecionado.quantidade_disponivel.toString()
|
||||
}));
|
||||
}
|
||||
}, [formData.todas_disponiveis, itemSelecionado]);
|
||||
|
||||
const handleOFChange = (ofNumber: string) => {
|
||||
const updates = {
|
||||
of_number: ofNumber,
|
||||
fase: '',
|
||||
processo_id: '',
|
||||
quantidade_produzida: '',
|
||||
todas_disponiveis: false
|
||||
};
|
||||
setFormData(prev => ({ ...prev, ...updates }));
|
||||
updateCache({ of_number: ofNumber, fase: '', processo_id: '' });
|
||||
setItemSelecionado(null);
|
||||
};
|
||||
|
||||
const handleFaseChange = (fase: string) => {
|
||||
const updates = {
|
||||
fase: fase,
|
||||
processo_id: '',
|
||||
quantidade_produzida: '',
|
||||
todas_disponiveis: false
|
||||
};
|
||||
setFormData(prev => ({ ...prev, ...updates }));
|
||||
updateCache({ fase: fase, processo_id: '' });
|
||||
setItemSelecionado(null);
|
||||
};
|
||||
|
||||
const handleProcessoChange = (processoId: string) => {
|
||||
console.log('🔄 Mudança de processo detectada, limpando cache específico...');
|
||||
|
||||
// LIMPAR CACHE ESPECÍFICO ANTES DE ALTERAR O PROCESSO
|
||||
if (formData.of_number && formData.fase) {
|
||||
console.log(`🗑️ Invalidando cache para OF: ${formData.of_number}, Fase: ${formData.fase}`);
|
||||
onInvalidateCache(formData.of_number, formData.fase);
|
||||
}
|
||||
|
||||
const updates = {
|
||||
processo_id: processoId,
|
||||
quantidade_produzida: '',
|
||||
todas_disponiveis: false
|
||||
};
|
||||
setFormData(prev => ({ ...prev, ...updates }));
|
||||
updateCache({ processo_id: processoId });
|
||||
setItemSelecionado(null);
|
||||
|
||||
// Limpar itens disponíveis imediatamente para forçar nova consulta
|
||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
|
||||
console.log('✅ Cache limpo, nova consulta será realizada automaticamente');
|
||||
};
|
||||
|
||||
const handleItemSelect = useCallback((item: ItemDisponivel) => {
|
||||
console.log('🎯 Item selecionado:', {
|
||||
marca: item.marca,
|
||||
tipo: item.tipo,
|
||||
quantidade_disponivel: item.quantidade_disponivel
|
||||
});
|
||||
setItemSelecionado(item);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
quantidade_produzida: '',
|
||||
todas_disponiveis: false
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleQuantidadeChange = (value: string) => {
|
||||
const quantidade = parseInt(value);
|
||||
|
||||
if (itemSelecionado && quantidade > itemSelecionado.quantidade_disponivel) {
|
||||
toast.error(`Quantidade não pode ser maior que ${itemSelecionado.quantidade_disponivel} unidades disponíveis`);
|
||||
return;
|
||||
}
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
quantidade_produzida: value,
|
||||
todas_disponiveis: false
|
||||
}));
|
||||
};
|
||||
|
||||
const handleTodasDisponiveisChange = (checked: boolean) => {
|
||||
console.log('🔄 Checkbox "Todas" alterado:', {
|
||||
checked,
|
||||
itemSelecionado: itemSelecionado?.marca,
|
||||
quantidade_disponivel: itemSelecionado?.quantidade_disponivel
|
||||
});
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
todas_disponiveis: checked,
|
||||
quantidade_produzida: checked && itemSelecionado ? itemSelecionado.quantidade_disponivel.toString() : prev.quantidade_produzida
|
||||
}));
|
||||
};
|
||||
|
||||
const resetFormForNewEntry = async () => {
|
||||
console.log('🔄 Resetando formulário para nova entrada...');
|
||||
setItemSelecionado(null);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
quantidade_produzida: '',
|
||||
observacoes: '',
|
||||
todas_disponiveis: false
|
||||
}));
|
||||
|
||||
console.log('📋 Recarregando itens disponíveis após reset...');
|
||||
await carregarItensDisponiveis();
|
||||
};
|
||||
|
||||
const handleBatchSelect = async (items: ItemDisponivel[], tipo: 'peca' | 'componente') => {
|
||||
setSaving(true);
|
||||
let sucessos = 0;
|
||||
let erros = 0;
|
||||
|
||||
try {
|
||||
for (const item of items) {
|
||||
try {
|
||||
const validacao = await onValidarSequencia(
|
||||
formData.of_number,
|
||||
item.marca,
|
||||
formData.processo_id,
|
||||
item.quantidade_disponivel,
|
||||
formData.fase
|
||||
);
|
||||
|
||||
if (!validacao.valido) {
|
||||
erros++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const apontamentoData: any = {
|
||||
of_number: formData.of_number,
|
||||
tipo_apontamento: item.tipo,
|
||||
processo_id: formData.processo_id,
|
||||
quantidade_produzida: item.quantidade_disponivel,
|
||||
data_apontamento: formData.data_apontamento,
|
||||
observacoes: `Apontamento em lote - ${tipo}`
|
||||
};
|
||||
|
||||
if (item.tipo === 'componente') {
|
||||
apontamentoData.componente_id = item.id;
|
||||
} else {
|
||||
apontamentoData.peca_id = item.id;
|
||||
}
|
||||
|
||||
const result = await onCriarApontamento(apontamentoData);
|
||||
|
||||
if (result.success) {
|
||||
sucessos++;
|
||||
} else {
|
||||
erros++;
|
||||
}
|
||||
} catch (error) {
|
||||
erros++;
|
||||
}
|
||||
}
|
||||
|
||||
if (sucessos > 0) {
|
||||
toast.success(`${sucessos} apontamentos realizados com sucesso!`);
|
||||
await Promise.all([
|
||||
onRefetch(),
|
||||
resetFormForNewEntry()
|
||||
]);
|
||||
}
|
||||
|
||||
if (erros > 0) {
|
||||
toast.error(`${erros} apontamentos falharam.`);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
toast.error('Erro ao realizar apontamento em lote');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!itemSelecionado || !formData.processo_id || !formData.quantidade_produzida) {
|
||||
toast.error('Preencha todos os campos obrigatórios');
|
||||
return;
|
||||
}
|
||||
|
||||
const quantidade = parseInt(formData.quantidade_produzida);
|
||||
|
||||
if (quantidade <= 0 || quantidade > itemSelecionado.quantidade_disponivel) {
|
||||
toast.error('Quantidade inválida');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
console.log('🔍 Iniciando validação de sequência:', {
|
||||
of: formData.of_number,
|
||||
marca: itemSelecionado.marca,
|
||||
processo: formData.processo_id,
|
||||
quantidade,
|
||||
metodo: formData.todas_disponiveis ? 'CHECKBOX_TODAS' : 'MANUAL'
|
||||
});
|
||||
|
||||
const validacao = await onValidarSequencia(
|
||||
formData.of_number,
|
||||
itemSelecionado.marca,
|
||||
formData.processo_id,
|
||||
quantidade,
|
||||
formData.fase
|
||||
);
|
||||
|
||||
if (!validacao.valido) {
|
||||
toast.error(validacao.erro);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('✅ Validação aprovada, criando apontamento...');
|
||||
|
||||
const apontamentoData: any = {
|
||||
of_number: formData.of_number,
|
||||
tipo_apontamento: itemSelecionado.tipo,
|
||||
processo_id: formData.processo_id,
|
||||
quantidade_produzida: quantidade,
|
||||
data_apontamento: formData.data_apontamento,
|
||||
observacoes: formData.observacoes || null
|
||||
};
|
||||
|
||||
if (itemSelecionado.tipo === 'componente') {
|
||||
apontamentoData.componente_id = itemSelecionado.id;
|
||||
} else {
|
||||
apontamentoData.peca_id = itemSelecionado.id;
|
||||
}
|
||||
|
||||
console.log('📝 Dados do apontamento sendo criado:', {
|
||||
...apontamentoData,
|
||||
metodo_utilizado: formData.todas_disponiveis ? 'CHECKBOX_TODAS' : 'DIGITACAO_MANUAL',
|
||||
quantidade_original_disponivel: itemSelecionado.quantidade_disponivel,
|
||||
quantidade_sendo_apontada: quantidade
|
||||
});
|
||||
|
||||
const result = await onCriarApontamento(apontamentoData);
|
||||
|
||||
if (result.success) {
|
||||
console.log('✅ Apontamento criado com sucesso!');
|
||||
toast.success('Apontamento registrado com sucesso!');
|
||||
|
||||
// Atualizar estado do último apontamento
|
||||
if (result.success) {
|
||||
// Para desfazer, buscar o último apontamento criado
|
||||
const { data: lastApontamento } = await supabase
|
||||
.from('apontamentos_producao')
|
||||
.select('id')
|
||||
.eq('created_by', user.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(1)
|
||||
.single();
|
||||
|
||||
if (lastApontamento) {
|
||||
setLastApontamentoId(lastApontamento.id);
|
||||
setCanUndo(true);
|
||||
}
|
||||
}
|
||||
|
||||
// INVALIDAR CACHE ESPECÍFICO DA OF E FASE ANTES DE RECARREGAR
|
||||
console.log('🗑️ Invalidando cache após apontamento bem-sucedido...');
|
||||
onInvalidateCache(formData.of_number, formData.fase);
|
||||
|
||||
// Aguardar atualização e reset
|
||||
await Promise.all([
|
||||
onRefetch(),
|
||||
resetFormForNewEntry()
|
||||
]);
|
||||
|
||||
console.log('✅ Formulário resetado e dados atualizados após apontamento');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Erro ao registrar apontamento:', error);
|
||||
toast.error('Erro ao registrar apontamento');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const limparCacheCompleto = () => {
|
||||
localStorage.removeItem('apontamento_form_cache');
|
||||
setFormData({
|
||||
of_number: '',
|
||||
fase: '',
|
||||
data_apontamento: new Date().toISOString().split('T')[0],
|
||||
processo_id: '',
|
||||
quantidade_produzida: '',
|
||||
observacoes: '',
|
||||
todas_disponiveis: false
|
||||
});
|
||||
setItemSelecionado(null);
|
||||
setItensDisponiveis({ pecasDisponiveis: [], componentesDisponiveis: [] });
|
||||
toast.success('Cache limpo completamente!');
|
||||
};
|
||||
|
||||
// Função para desfazer último apontamento
|
||||
const handleUndo = useCallback(async () => {
|
||||
if (!lastApontamentoId || !user?.id) return;
|
||||
|
||||
setUndoLoading(true);
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('apontamentos_producao')
|
||||
.delete()
|
||||
.eq('id', lastApontamentoId)
|
||||
.eq('created_by', user.id);
|
||||
|
||||
if (error) {
|
||||
console.error('Erro ao desfazer apontamento:', error);
|
||||
toast.error('Erro ao desfazer apontamento');
|
||||
return;
|
||||
}
|
||||
|
||||
// Resetar estado do botão de desfazer
|
||||
setLastApontamentoId(null);
|
||||
setCanUndo(false);
|
||||
|
||||
// Recarregar itens disponíveis se necessário
|
||||
if (formData.of_number && formData.fase && formData.processo_id) {
|
||||
await carregarItensDisponiveis();
|
||||
}
|
||||
|
||||
toast.success('Apontamento desfeito com sucesso!');
|
||||
} catch (error) {
|
||||
console.error('Erro inesperado ao desfazer:', error);
|
||||
toast.error('Erro inesperado ao desfazer apontamento');
|
||||
} finally {
|
||||
setUndoLoading(false);
|
||||
}
|
||||
}, [lastApontamentoId, user?.id, supabase, formData.of_number, formData.fase, formData.processo_id, carregarItensDisponiveis]);
|
||||
|
||||
const processoSelecionado = processosOrdenados?.find(p => p.id === formData.processo_id);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8 space-y-4">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<span className="text-muted-foreground">Carregando dados...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="space-y-4">
|
||||
{/* Cache status */}
|
||||
{(formData.of_number || formData.fase || formData.processo_id) && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription className="flex items-center justify-between">
|
||||
<span>Formulário restaurado do cache</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={limparCacheCompleto}
|
||||
className="ml-2"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
Limpar Cache
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Campos de seleção básicos */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="of">Ordem de Fabricação *</Label>
|
||||
<Select value={formData.of_number} onValueChange={handleOFChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione a OF" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(ofs || []).map((of) => (
|
||||
<SelectItem key={of.id} value={of.num_of}>
|
||||
{of.num_of} - {of.descritivo}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{formData.of_number && (
|
||||
<div>
|
||||
<Label htmlFor="fase">Fase *</Label>
|
||||
<Select value={formData.fase} onValueChange={handleFaseChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione a fase" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{fasesDisponiveis.map((fase) => (
|
||||
<SelectItem key={fase} value={fase}>
|
||||
{fase}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{formData.fase && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="data">Data do Apontamento *</Label>
|
||||
<Input
|
||||
id="data"
|
||||
type="date"
|
||||
value={formData.data_apontamento}
|
||||
onChange={(e) => {
|
||||
const newDate = e.target.value;
|
||||
setFormData(prev => ({ ...prev, data_apontamento: newDate }));
|
||||
updateCache({ data_apontamento: newDate });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Processo *</Label>
|
||||
<Select value={formData.processo_id} onValueChange={handleProcessoChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione o processo" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(processosOrdenados || []).map((processo) => (
|
||||
<SelectItem key={processo.id} value={processo.id}>
|
||||
{processo.ordem}. {processo.nome}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Informação sobre o processo */}
|
||||
{processoSelecionado && (
|
||||
<Alert>
|
||||
<Info className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{processoSelecionado.ordem === 1
|
||||
? `Processo inicial: ${processoSelecionado.nome}. Todos os itens estão disponíveis.`
|
||||
: `Processo ${processoSelecionado.ordem}: ${processoSelecionado.nome}. Apenas itens que passaram pelos processos anteriores estão disponíveis.`
|
||||
}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Seletor de Itens */}
|
||||
{formData.processo_id && (
|
||||
<SeletorPecasSimples
|
||||
pecasDisponiveis={itensDisponiveis.pecasDisponiveis || []}
|
||||
componentesDisponiveis={itensDisponiveis.componentesDisponiveis || []}
|
||||
onItemSelect={handleItemSelect}
|
||||
onBatchSelect={handleBatchSelect}
|
||||
loading={loadingItens || validacaoLoading}
|
||||
onSubmit={() => handleSubmit({} as React.FormEvent)}
|
||||
submitDisabled={saving || !itemSelecionado || !formData.processo_id || !formData.quantidade_produzida || loadingItens}
|
||||
submitLoading={saving}
|
||||
canUndo={canUndo}
|
||||
onUndo={handleUndo}
|
||||
undoLoading={undoLoading}
|
||||
lastApontamentoId={lastApontamentoId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Campos de Quantidade */}
|
||||
{itemSelecionado && (
|
||||
<div className="space-y-4 p-4 bg-slate-800/50 rounded-lg border border-slate-700">
|
||||
<h4 className="font-medium text-slate-200">Quantidade a Apontar</h4>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
max={itemSelecionado.quantidade_disponivel}
|
||||
placeholder="Digite a quantidade..."
|
||||
value={formData.quantidade_produzida}
|
||||
onChange={(e) => handleQuantidadeChange(e.target.value)}
|
||||
disabled={formData.todas_disponiveis}
|
||||
className="w-full text-lg h-12 bg-slate-800 border-slate-600"
|
||||
/>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="todas-disponiveis"
|
||||
checked={formData.todas_disponiveis}
|
||||
onCheckedChange={handleTodasDisponiveisChange}
|
||||
/>
|
||||
<Label htmlFor="todas-disponiveis" className="text-sm cursor-pointer">
|
||||
Todas ({itemSelecionado.quantidade_disponivel})
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label htmlFor="observacoes">Observações</Label>
|
||||
<Textarea
|
||||
id="observacoes"
|
||||
placeholder="Observações sobre o apontamento..."
|
||||
value={formData.observacoes}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, observacoes: e.target.value }))}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card de informações do item selecionado */}
|
||||
<div>
|
||||
<Card className="bg-muted/50 h-fit">
|
||||
<CardContent className="p-4">
|
||||
<h4 className="font-medium flex items-center gap-2 mb-3">
|
||||
<Package className="h-4 w-4" />
|
||||
Informações do Item Selecionado
|
||||
</h4>
|
||||
{!itemSelecionado ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Selecione um item para ver as informações
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 text-sm">
|
||||
<div><strong>Tipo:</strong> {itemSelecionado.tipo === 'componente' ? 'Componente' : 'Peça'}</div>
|
||||
<div><strong>Marca:</strong> {itemSelecionado.marca}</div>
|
||||
<div><strong>OF:</strong> {formData.of_number}</div>
|
||||
<div><strong>Fase:</strong> {formData.fase}</div>
|
||||
<div><strong>Processo:</strong> {processoSelecionado?.nome || 'N/A'}</div>
|
||||
<div><strong>Descrição:</strong> {itemSelecionado.descricao || 'N/A'}</div>
|
||||
<div><strong>Quantidade Disponível:</strong> {itemSelecionado.quantidade_disponivel} unidades</div>
|
||||
{formData.quantidade_produzida && (
|
||||
<div className="pt-2 border-t">
|
||||
<div><strong>Quantidade a Apontar:</strong> {formData.quantidade_produzida} unidades</div>
|
||||
<div><strong>Método:</strong> {formData.todas_disponiveis ? 'Checkbox "Todas"' : 'Digitação Manual'}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
);
|
||||
};
|
||||
237
src/components/apontamento/ApontamentosList.tsx
Normal file
237
src/components/apontamento/ApontamentosList.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Search, Package } from 'lucide-react';
|
||||
import { useApontamentosProducao } from '@/hooks/useApontamentosProducao';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
|
||||
export const ApontamentosList = () => {
|
||||
const { apontamentos, loading } = useApontamentosProducao();
|
||||
const [filtroOF, setFiltroOF] = useState('');
|
||||
const [filtroProcesso, setFiltroProcesso] = useState('');
|
||||
const [filtroData, setFiltroData] = useState('');
|
||||
const [filtroPecaComponente, setFiltroPecaComponente] = useState('');
|
||||
|
||||
const apontamentosFiltrados = apontamentos.filter(apt => {
|
||||
const matchOF = !filtroOF || apt.of_number.toLowerCase().includes(filtroOF.toLowerCase());
|
||||
const matchProcesso = !filtroProcesso || apt.processo?.nome.toLowerCase().includes(filtroProcesso.toLowerCase());
|
||||
const matchData = !filtroData || apt.data_apontamento.includes(filtroData);
|
||||
|
||||
// Filtro por peça/componente - busca na marca da peça ou componente
|
||||
const matchPecaComponente = !filtroPecaComponente ||
|
||||
getMarcaExibida(apt).toLowerCase().includes(filtroPecaComponente.toLowerCase());
|
||||
|
||||
return matchOF && matchProcesso && matchData && matchPecaComponente;
|
||||
});
|
||||
|
||||
// Função para determinar a marca exibida baseada no tipo de apontamento
|
||||
const getMarcaExibida = (apontamento: any) => {
|
||||
if (apontamento.tipo_apontamento === 'componente' && apontamento.componente?.marca_componente) {
|
||||
return apontamento.componente.marca_componente;
|
||||
}
|
||||
if (apontamento.tipo_apontamento === 'peca' && apontamento.peca?.marca) {
|
||||
return apontamento.peca.marca;
|
||||
}
|
||||
return 'N/A';
|
||||
};
|
||||
|
||||
// Função para calcular peso total baseado no tipo correto (peça ou componente)
|
||||
const calcularPesoTotal = (apontamento: any) => {
|
||||
let pesoUnitario = 0;
|
||||
|
||||
if (apontamento.tipo_apontamento === 'componente' && apontamento.componente?.peso_unitario) {
|
||||
pesoUnitario = Number(apontamento.componente.peso_unitario);
|
||||
} else if (apontamento.tipo_apontamento === 'peca' && apontamento.peca?.peso_unitario) {
|
||||
pesoUnitario = Number(apontamento.peca.peso_unitario);
|
||||
}
|
||||
|
||||
const quantidade = Number(apontamento.quantidade_produzida) || 0;
|
||||
const pesoTotal = pesoUnitario * quantidade;
|
||||
|
||||
console.log(`Apontamento ${apontamento.id}: Tipo: ${apontamento.tipo_apontamento}, Peso unitário: ${pesoUnitario}, Quantidade: ${quantidade}, Peso total: ${pesoTotal}`);
|
||||
|
||||
return pesoTotal.toFixed(3);
|
||||
};
|
||||
|
||||
// Função para obter a descrição correta baseada no tipo
|
||||
const getDescricaoExibida = (apontamento: any) => {
|
||||
if (apontamento.tipo_apontamento === 'componente' && apontamento.componente?.descricao) {
|
||||
return apontamento.componente.descricao;
|
||||
}
|
||||
if (apontamento.tipo_apontamento === 'peca' && apontamento.peca?.descricao) {
|
||||
return apontamento.peca.descricao;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
// Calcular totais para debug
|
||||
const pesoTotalApontamentos = apontamentosFiltrados.reduce((total, apt) => {
|
||||
return total + Number(calcularPesoTotal(apt));
|
||||
}, 0);
|
||||
|
||||
console.log('Total de peso dos apontamentos filtrados:', pesoTotalApontamentos);
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-8">Carregando apontamentos...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Filtros */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Search className="h-4 w-4" />
|
||||
Filtros
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">OF</label>
|
||||
<Input
|
||||
placeholder="Filtrar por OF..."
|
||||
value={filtroOF}
|
||||
onChange={(e) => setFiltroOF(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Processo</label>
|
||||
<Input
|
||||
placeholder="Filtrar por processo..."
|
||||
value={filtroProcesso}
|
||||
onChange={(e) => setFiltroProcesso(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Data</label>
|
||||
<Input
|
||||
type="date"
|
||||
value={filtroData}
|
||||
onChange={(e) => setFiltroData(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Peça/Componente</label>
|
||||
<Input
|
||||
placeholder="Filtrar por peça/componente..."
|
||||
value={filtroPecaComponente}
|
||||
onChange={(e) => setFiltroPecaComponente(e.target.value)}
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col items-start">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{apontamentosFiltrados.length} registros encontrados
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Peso total: {pesoTotalApontamentos.toFixed(3)} kg
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tabela de Apontamentos */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-4 w-4" />
|
||||
Histórico de Apontamentos
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{apontamentosFiltrados.length} registros
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{apontamentosFiltrados.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Nenhum apontamento encontrado
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>OF</TableHead>
|
||||
<TableHead>Peça/Componente</TableHead>
|
||||
<TableHead>Tipo</TableHead>
|
||||
<TableHead>Processo</TableHead>
|
||||
<TableHead className="text-center">Quantidade</TableHead>
|
||||
<TableHead className="text-center">Peso Unit. (kg)</TableHead>
|
||||
<TableHead className="text-center">Peso Total (kg)</TableHead>
|
||||
<TableHead>Data</TableHead>
|
||||
<TableHead>Observações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apontamentosFiltrados.map((apontamento) => {
|
||||
const pesoUnitario = apontamento.tipo_apontamento === 'componente'
|
||||
? Number(apontamento.componente?.peso_unitario || 0)
|
||||
: Number(apontamento.peca?.peso_unitario || 0);
|
||||
|
||||
return (
|
||||
<TableRow key={apontamento.id} className="h-9">
|
||||
<TableCell className="font-medium py-1">
|
||||
{apontamento.of_number}
|
||||
</TableCell>
|
||||
<TableCell className="py-1">
|
||||
<div className="font-medium">
|
||||
{getMarcaExibida(apontamento)}
|
||||
</div>
|
||||
{getDescricaoExibida(apontamento) && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{getDescricaoExibida(apontamento)}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="py-1">
|
||||
<Badge variant={apontamento.tipo_apontamento === 'componente' ? 'secondary' : 'default'}>
|
||||
{apontamento.tipo_apontamento === 'componente' ? 'Componente' : 'Peça'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="py-1">
|
||||
<Badge variant="outline">
|
||||
{apontamento.processo?.nome || 'N/A'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center font-medium py-1">
|
||||
{apontamento.quantidade_produzida}
|
||||
</TableCell>
|
||||
<TableCell className="text-center py-1">
|
||||
{pesoUnitario.toFixed(3)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center font-medium py-1">
|
||||
{calcularPesoTotal(apontamento)}
|
||||
</TableCell>
|
||||
<TableCell className="py-1">
|
||||
{format(new Date(apontamento.data_apontamento), 'dd/MM/yyyy', {
|
||||
locale: ptBR
|
||||
})}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs py-1">
|
||||
<div className="text-sm text-muted-foreground truncate">
|
||||
{apontamento.observacoes || '-'}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
119
src/components/apontamento/ApontamentosListOtimizado.tsx
Normal file
119
src/components/apontamento/ApontamentosListOtimizado.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Package } from 'lucide-react';
|
||||
import { useApontamentosProducao } from '@/hooks/useApontamentosProducao';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { toast } from 'sonner';
|
||||
import { ApontamentosFilters } from './historico/ApontamentosFilters';
|
||||
import { ApontamentosResultsList } from './historico/ApontamentosResultsList';
|
||||
import { useApontamentosFilters } from './historico/useApontamentosFilters';
|
||||
|
||||
export const ApontamentosListOtimizado: React.FC = () => {
|
||||
const { apontamentos, loading, refetch } = useApontamentosProducao();
|
||||
|
||||
const {
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
filterOF,
|
||||
filterFase,
|
||||
filterProcesso,
|
||||
dataInicio,
|
||||
dataFim,
|
||||
setDataInicio,
|
||||
setDataFim,
|
||||
uniqueOFs,
|
||||
uniqueFases,
|
||||
uniqueProcessos,
|
||||
filteredApontamentos,
|
||||
initialized,
|
||||
handleOFChange,
|
||||
handleFaseChange,
|
||||
handleProcessoChange,
|
||||
clearFilters
|
||||
} = useApontamentosFilters(apontamentos);
|
||||
|
||||
const handleReverterApontamento = async (apontamentoId: string) => {
|
||||
try {
|
||||
console.log('Iniciando reversão do apontamento:', apontamentoId);
|
||||
|
||||
// Buscar informações do apontamento antes de deletar para logs
|
||||
const { data: apontamentoInfo } = await supabase
|
||||
.from('apontamentos_producao')
|
||||
.select(`
|
||||
of_number,
|
||||
quantidade_produzida,
|
||||
peca:pecas(marca),
|
||||
componente:componentes_peca(marca_componente),
|
||||
processo:processos_fabricacao(nome)
|
||||
`)
|
||||
.eq('id', apontamentoId)
|
||||
.single();
|
||||
|
||||
if (apontamentoInfo) {
|
||||
const marca = apontamentoInfo.peca?.marca || apontamentoInfo.componente?.marca_componente || 'N/A';
|
||||
console.log(`Revertendo apontamento: ${marca} - ${apontamentoInfo.quantidade_produzida} unidades - OF: ${apontamentoInfo.of_number}`);
|
||||
}
|
||||
|
||||
// Deletar o apontamento
|
||||
const { error } = await supabase
|
||||
.from('apontamentos_producao')
|
||||
.delete()
|
||||
.eq('id', apontamentoId);
|
||||
|
||||
if (error) {
|
||||
console.error('Erro ao deletar apontamento:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log('Apontamento deletado com sucesso, atualizando lista...');
|
||||
toast.success('Apontamento revertido com sucesso!');
|
||||
|
||||
// Forçar atualização dos dados
|
||||
await refetch();
|
||||
console.log('Lista de apontamentos atualizada');
|
||||
|
||||
} catch (error: any) {
|
||||
console.error('Erro completo ao reverter apontamento:', error);
|
||||
toast.error(`Erro ao reverter apontamento: ${error.message || 'Erro desconhecido'}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading || !initialized) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5 animate-spin" />
|
||||
<span>Carregando histórico...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<ApontamentosFilters
|
||||
searchTerm={searchTerm}
|
||||
setSearchTerm={setSearchTerm}
|
||||
filterOF={filterOF}
|
||||
filterFase={filterFase}
|
||||
filterProcesso={filterProcesso}
|
||||
dataInicio={dataInicio}
|
||||
dataFim={dataFim}
|
||||
setDataInicio={setDataInicio}
|
||||
setDataFim={setDataFim}
|
||||
uniqueOFs={uniqueOFs}
|
||||
uniqueFases={uniqueFases}
|
||||
uniqueProcessos={uniqueProcessos}
|
||||
handleOFChange={handleOFChange}
|
||||
handleFaseChange={handleFaseChange}
|
||||
handleProcessoChange={handleProcessoChange}
|
||||
clearFilters={clearFilters}
|
||||
/>
|
||||
|
||||
<ApontamentosResultsList
|
||||
filteredApontamentos={filteredApontamentos}
|
||||
onRevert={handleReverterApontamento}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
258
src/components/apontamento/ProcessosList.tsx
Normal file
258
src/components/apontamento/ProcessosList.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Plus, Edit, Save, X, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { useApontamentosProducao } from '@/hooks/useApontamentosProducao';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type SortField = 'ordem' | 'nome' | 'descricao' | 'ativo';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
export const ProcessosList = () => {
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [sortField, setSortField] = useState<SortField>('ordem');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
const [formData, setFormData] = useState({
|
||||
nome: '',
|
||||
descricao: '',
|
||||
ordem: 0
|
||||
});
|
||||
|
||||
const { processos, loading, criarProcesso, atualizarProcesso } = useApontamentosProducao();
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const sortedProcessos = React.useMemo(() => {
|
||||
return [...processos].sort((a, b) => {
|
||||
let aValue: any;
|
||||
let bValue: any;
|
||||
|
||||
switch (sortField) {
|
||||
case 'ordem':
|
||||
aValue = a.ordem;
|
||||
bValue = b.ordem;
|
||||
break;
|
||||
case 'nome':
|
||||
aValue = a.nome;
|
||||
bValue = b.nome;
|
||||
break;
|
||||
case 'descricao':
|
||||
aValue = a.descricao || '';
|
||||
bValue = b.descricao || '';
|
||||
break;
|
||||
case 'ativo':
|
||||
aValue = a.ativo;
|
||||
bValue = b.ativo;
|
||||
break;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (aValue < bValue) return sortDirection === 'asc' ? -1 : 1;
|
||||
if (aValue > bValue) return sortDirection === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
}, [processos, sortField, sortDirection]);
|
||||
|
||||
const SortButton = ({ field, children }: { field: SortField; children: React.ReactNode }) => (
|
||||
<button
|
||||
onClick={() => handleSort(field)}
|
||||
className="flex items-center gap-1 hover:text-foreground transition-colors w-full text-left"
|
||||
>
|
||||
{children}
|
||||
{sortField === field && (
|
||||
sortDirection === 'asc' ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.nome.trim()) {
|
||||
toast.error('Nome do processo é obrigatório');
|
||||
return;
|
||||
}
|
||||
|
||||
let result;
|
||||
if (editingId) {
|
||||
result = await atualizarProcesso(editingId, formData);
|
||||
} else {
|
||||
result = await criarProcesso({
|
||||
...formData,
|
||||
ativo: true
|
||||
});
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
setFormData({ nome: '', descricao: '', ordem: 0 });
|
||||
setShowForm(false);
|
||||
setEditingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (processo: any) => {
|
||||
setFormData({
|
||||
nome: processo.nome,
|
||||
descricao: processo.descricao || '',
|
||||
ordem: processo.ordem || 0
|
||||
});
|
||||
setEditingId(processo.id);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setFormData({ nome: '', descricao: '', ordem: 0 });
|
||||
setShowForm(false);
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="text-muted-foreground">Carregando processos...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-semibold">Processos de Fabricação</h3>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowForm(!showForm)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Novo Processo
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
{editingId ? 'Editar Processo' : 'Novo Processo'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="nome">Nome *</Label>
|
||||
<Input
|
||||
id="nome"
|
||||
value={formData.nome}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, nome: e.target.value }))}
|
||||
placeholder="Nome do processo"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="ordem">Ordem</Label>
|
||||
<Input
|
||||
id="ordem"
|
||||
type="number"
|
||||
min="0"
|
||||
value={formData.ordem}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, ordem: parseInt(e.target.value) || 0 }))}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="descricao">Descrição</Label>
|
||||
<Textarea
|
||||
id="descricao"
|
||||
value={formData.descricao}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, descricao: e.target.value }))}
|
||||
placeholder="Descrição opcional"
|
||||
rows={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{editingId ? 'Atualizar' : 'Salvar'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="border rounded-lg">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="h-6">
|
||||
<TableHead className="py-1">
|
||||
<SortButton field="ordem">Ordem</SortButton>
|
||||
</TableHead>
|
||||
<TableHead className="py-1">
|
||||
<SortButton field="nome">Nome</SortButton>
|
||||
</TableHead>
|
||||
<TableHead className="py-1">
|
||||
<SortButton field="descricao">Descrição</SortButton>
|
||||
</TableHead>
|
||||
<TableHead className="py-1">
|
||||
<SortButton field="ativo">Status</SortButton>
|
||||
</TableHead>
|
||||
<TableHead className="py-1 w-24">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sortedProcessos.map((processo) => (
|
||||
<TableRow key={processo.id} className="h-6">
|
||||
<TableCell className="py-1 text-center text-sm">{processo.ordem}</TableCell>
|
||||
<TableCell className="py-1 font-medium text-sm">{processo.nome}</TableCell>
|
||||
<TableCell className="py-1 text-sm">{processo.descricao || '-'}</TableCell>
|
||||
<TableCell className="py-1">
|
||||
<Badge variant={processo.ativo ? 'default' : 'secondary'} className="text-xs">
|
||||
{processo.ativo ? 'Ativo' : 'Inativo'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="py-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(processo)}
|
||||
className="h-5 w-5 p-0"
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Card className="mt-4">
|
||||
<CardContent className="pt-6">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p><strong>Informações:</strong></p>
|
||||
<p>Os processos são utilizados para categorizar os apontamentos de produção.</p>
|
||||
<p>A ordem define a sequência dos processos no fluxo de fabricação.</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1278
src/components/apontamento/SeletorItensOtimizado.tsx
Normal file
1278
src/components/apontamento/SeletorItensOtimizado.tsx
Normal file
File diff suppressed because it is too large
Load Diff
412
src/components/apontamento/SeletorPecasSimples.tsx
Normal file
412
src/components/apontamento/SeletorPecasSimples.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Package, Search, CheckSquare, Loader2, Weight, Info, ArrowUpDown, Undo2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { applyMarcaFilter } from '@/utils/rangeFilter';
|
||||
import { naturalSort } from '@/utils/naturalSort';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
interface ItemDisponivel {
|
||||
id: string;
|
||||
marca: string;
|
||||
descricao: string;
|
||||
tipo: 'peca' | 'componente';
|
||||
quantidade_disponivel: number;
|
||||
processo_atual_permitido: number;
|
||||
peso_unitario?: number;
|
||||
}
|
||||
|
||||
interface SeletorPecasSimplesProps {
|
||||
pecasDisponiveis: ItemDisponivel[];
|
||||
componentesDisponiveis: ItemDisponivel[];
|
||||
onItemSelect: (item: ItemDisponivel) => void;
|
||||
onBatchSelect: (items: ItemDisponivel[], tipo: 'peca' | 'componente') => void;
|
||||
loading: boolean;
|
||||
onSubmit?: () => void;
|
||||
submitDisabled?: boolean;
|
||||
submitLoading?: boolean;
|
||||
// Props para o botão de desfazer
|
||||
canUndo?: boolean;
|
||||
onUndo?: () => void;
|
||||
undoLoading?: boolean;
|
||||
lastApontamentoId?: string;
|
||||
}
|
||||
|
||||
export const SeletorPecasSimples: React.FC<SeletorPecasSimplesProps> = ({
|
||||
pecasDisponiveis = [],
|
||||
componentesDisponiveis = [],
|
||||
onItemSelect,
|
||||
onBatchSelect,
|
||||
loading,
|
||||
onSubmit,
|
||||
submitDisabled = false,
|
||||
submitLoading = false,
|
||||
canUndo = false,
|
||||
onUndo,
|
||||
undoLoading = false,
|
||||
lastApontamentoId
|
||||
}) => {
|
||||
const [filtro, setFiltro] = useState('');
|
||||
const [activeTab, setActiveTab] = useState('pecas');
|
||||
const [itemSelecionado, setItemSelecionado] = useState<ItemDisponivel | null>(null);
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
||||
|
||||
// Filtrar e ordenar peças com suporte a range
|
||||
const pecasFiltradas = useMemo(() => {
|
||||
let pecas = pecasDisponiveis;
|
||||
if (filtro) {
|
||||
pecas = pecasDisponiveis.filter(peca =>
|
||||
applyMarcaFilter(peca.marca, filtro) ||
|
||||
peca.descricao.toLowerCase().includes(filtro.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Ordenar numericamente usando naturalSort
|
||||
return [...pecas].sort((a, b) => {
|
||||
const comparison = naturalSort(a.marca, b.marca);
|
||||
return sortOrder === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
}, [pecasDisponiveis, filtro, sortOrder]);
|
||||
|
||||
// Filtrar e ordenar componentes com suporte a range
|
||||
const componentesFiltrados = useMemo(() => {
|
||||
let componentes = componentesDisponiveis;
|
||||
if (filtro) {
|
||||
componentes = componentesDisponiveis.filter(comp =>
|
||||
applyMarcaFilter(comp.marca, filtro) ||
|
||||
comp.descricao.toLowerCase().includes(filtro.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
// Ordenar numericamente usando naturalSort
|
||||
return [...componentes].sort((a, b) => {
|
||||
const comparison = naturalSort(a.marca, b.marca);
|
||||
return sortOrder === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
}, [componentesDisponiveis, filtro, sortOrder]);
|
||||
|
||||
// Calcular peso total das peças disponíveis/filtradas
|
||||
const pesoTotalPecas = useMemo(() => {
|
||||
return pecasFiltradas.reduce((total, peca) => {
|
||||
const pesoUnitario = peca.peso_unitario || 0;
|
||||
return total + (pesoUnitario * peca.quantidade_disponivel);
|
||||
}, 0);
|
||||
}, [pecasFiltradas]);
|
||||
|
||||
// Calcular peso total dos componentes disponíveis/filtrados
|
||||
const pesoTotalComponentes = useMemo(() => {
|
||||
return componentesFiltrados.reduce((total, comp) => {
|
||||
const pesoUnitario = comp.peso_unitario || 0;
|
||||
return total + (pesoUnitario * comp.quantidade_disponivel);
|
||||
}, 0);
|
||||
}, [componentesFiltrados]);
|
||||
|
||||
const handleItemClick = (item: ItemDisponivel) => {
|
||||
setItemSelecionado(item);
|
||||
onItemSelect(item);
|
||||
};
|
||||
|
||||
const handleBatchPecas = () => {
|
||||
if (pecasFiltradas.length === 0) {
|
||||
toast.error('Nenhuma peça disponível para apontamento em lote');
|
||||
return;
|
||||
}
|
||||
onBatchSelect(pecasFiltradas, 'peca');
|
||||
};
|
||||
|
||||
const handleBatchComponentes = () => {
|
||||
if (componentesFiltrados.length === 0) {
|
||||
toast.error('Nenhum componente disponível para apontamento em lote');
|
||||
return;
|
||||
}
|
||||
onBatchSelect(componentesFiltrados, 'componente');
|
||||
};
|
||||
|
||||
const formatarPeso = (peso: number) => {
|
||||
return peso.toFixed(2);
|
||||
};
|
||||
|
||||
const handleToggleSort = () => {
|
||||
setSortOrder(current => current === 'asc' ? 'desc' : 'asc');
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-card border-border">
|
||||
<CardContent className="flex items-center justify-center p-8">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<span className="text-sm text-muted-foreground">Carregando itens disponíveis...</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-card border-border">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-card-foreground flex items-center gap-2 text-lg">
|
||||
<Package className="h-5 w-5" />
|
||||
Itens Disponíveis
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleToggleSort}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ArrowUpDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Ordenar {sortOrder === 'asc' ? 'crescente' : 'decrescente'}</p>
|
||||
<p className="text-xs text-muted-foreground">Clique para alternar</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* Botão Desfazer Último Apontamento */}
|
||||
{onUndo && (
|
||||
<AlertDialog>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!canUndo || undoLoading}
|
||||
className="border-orange-200 text-orange-600 hover:bg-orange-50 hover:text-orange-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{undoLoading ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Undo2 className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{undoLoading ? 'Desfazendo...' : 'Desfazer Ult. Apont.'}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Desfazer o último apontamento realizado</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{canUndo ? 'Clique para desfazer' : 'Nenhum apontamento para desfazer'}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirmar Reversão</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Tem certeza que deseja desfazer o último apontamento?
|
||||
<br />
|
||||
<strong>Esta ação não pode ser desfeita.</strong>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onUndo}
|
||||
className="bg-orange-600 hover:bg-orange-700"
|
||||
>
|
||||
Confirmar Reversão
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)}
|
||||
|
||||
{onSubmit && (
|
||||
<Button
|
||||
onClick={onSubmit}
|
||||
disabled={submitDisabled || submitLoading}
|
||||
className="min-w-32"
|
||||
>
|
||||
{submitLoading ? 'Salvando...' : 'Registrar Apontamento'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Campo de busca com tooltip */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Filtrar por número da peça... (ex: 32@47)"
|
||||
value={filtro}
|
||||
onChange={(e) => setFiltro(e.target.value)}
|
||||
className="pl-10 pr-8 bg-background border-border"
|
||||
/>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="absolute right-3 top-3 h-4 w-4 text-muted-foreground cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="text-sm space-y-1">
|
||||
<p><strong>Filtro por range:</strong></p>
|
||||
<p>Use @ para filtrar intervalos</p>
|
||||
<p><strong>Exemplo:</strong> 32@47 (peças de 32 a 47)</p>
|
||||
<p><strong>Busca normal:</strong> Digite parte da marca</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="w-full bg-muted border-border">
|
||||
<TabsTrigger
|
||||
value="pecas"
|
||||
className="flex-1 text-muted-foreground data-[state=active]:bg-background data-[state=active]:text-foreground"
|
||||
>
|
||||
<Package className="h-4 w-4 mr-2" />
|
||||
Peças ({pecasFiltradas.length}) - {formatarPeso(pesoTotalPecas)} kg
|
||||
<Weight className="h-3 w-3 ml-1 text-muted-foreground" />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="componentes"
|
||||
className="flex-1 text-muted-foreground data-[state=active]:bg-background data-[state=active]:text-foreground"
|
||||
>
|
||||
<Package className="h-4 w-4 mr-2" />
|
||||
Componentes ({componentesFiltrados.length}) - {formatarPeso(pesoTotalComponentes)} kg
|
||||
<Weight className="h-3 w-3 ml-1 text-muted-foreground" />
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pecas" className="space-y-3 mt-4">
|
||||
{pecasFiltradas.length > 0 && (
|
||||
<Button
|
||||
onClick={handleBatchPecas}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full border-border hover:bg-muted"
|
||||
>
|
||||
<CheckSquare className="h-4 w-4 mr-2" />
|
||||
Apontar Todas as Peças ({pecasFiltradas.length})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{pecasFiltradas.length === 0 ? (
|
||||
<div className="text-center py-6 text-muted-foreground">
|
||||
<Package className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>Nenhuma peça disponível</p>
|
||||
</div>
|
||||
) : (
|
||||
pecasFiltradas.map((peca) => (
|
||||
<div
|
||||
key={peca.id}
|
||||
onClick={() => handleItemClick(peca)}
|
||||
className={`p-3 rounded-lg border cursor-pointer transition-all duration-200 ${
|
||||
itemSelecionado?.id === peca.id
|
||||
? 'bg-primary/10 border-primary text-foreground'
|
||||
: 'bg-background border-border hover:bg-muted text-foreground'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Package className="h-4 w-4 text-primary" />
|
||||
<div>
|
||||
<div className="font-medium">{peca.marca}</div>
|
||||
<div className="text-sm text-muted-foreground">{peca.descricao}</div>
|
||||
{peca.peso_unitario && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Peso unitário: {formatarPeso(peca.peso_unitario)} kg
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary" className="bg-secondary text-secondary-foreground">
|
||||
{peca.quantidade_disponivel} un.
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="componentes" className="space-y-3 mt-4">
|
||||
{componentesFiltrados.length > 0 && (
|
||||
<Button
|
||||
onClick={handleBatchComponentes}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full border-border hover:bg-muted"
|
||||
>
|
||||
<CheckSquare className="h-4 w-4 mr-2" />
|
||||
Apontar Todos os Componentes ({componentesFiltrados.length})
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 max-h-64 overflow-y-auto">
|
||||
{componentesFiltrados.length === 0 ? (
|
||||
<div className="text-center py-6 text-muted-foreground">
|
||||
<Package className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p>Nenhum componente disponível</p>
|
||||
</div>
|
||||
) : (
|
||||
componentesFiltrados.map((comp) => (
|
||||
<div
|
||||
key={comp.id}
|
||||
onClick={() => handleItemClick(comp)}
|
||||
className={`p-3 rounded-lg border cursor-pointer transition-all duration-200 ${
|
||||
itemSelecionado?.id === comp.id
|
||||
? 'bg-primary/10 border-primary text-foreground'
|
||||
: 'bg-background border-border hover:bg-muted text-foreground'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Package className="h-4 w-4 text-orange-500" />
|
||||
<div>
|
||||
<div className="font-medium">{comp.marca}</div>
|
||||
<div className="text-sm text-muted-foreground">{comp.descricao}</div>
|
||||
{comp.peso_unitario && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Peso unitário: {formatarPeso(comp.peso_unitario)} kg
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary" className="bg-secondary text-secondary-foreground">
|
||||
{comp.quantidade_disponivel} un.
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
195
src/components/apontamento/historico/ApontamentosFilters.tsx
Normal file
195
src/components/apontamento/historico/ApontamentosFilters.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Search, Calendar as CalendarIcon, Filter, RefreshCw } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ApontamentosFiltersProps {
|
||||
searchTerm: string;
|
||||
setSearchTerm: (value: string) => void;
|
||||
filterOF: string;
|
||||
filterFase: string;
|
||||
filterProcesso: string;
|
||||
dataInicio: Date | undefined;
|
||||
dataFim: Date | undefined;
|
||||
setDataInicio: (date: Date | undefined) => void;
|
||||
setDataFim: (date: Date | undefined) => void;
|
||||
uniqueOFs: string[];
|
||||
uniqueFases: string[];
|
||||
uniqueProcessos: string[];
|
||||
handleOFChange: (value: string) => void;
|
||||
handleFaseChange: (value: string) => void;
|
||||
handleProcessoChange: (value: string) => void;
|
||||
clearFilters: () => void;
|
||||
}
|
||||
|
||||
export const ApontamentosFilters: React.FC<ApontamentosFiltersProps> = ({
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
filterOF,
|
||||
filterFase,
|
||||
filterProcesso,
|
||||
dataInicio,
|
||||
dataFim,
|
||||
setDataInicio,
|
||||
setDataFim,
|
||||
uniqueOFs,
|
||||
uniqueFases,
|
||||
uniqueProcessos,
|
||||
handleOFChange,
|
||||
handleFaseChange,
|
||||
handleProcessoChange,
|
||||
clearFilters
|
||||
}) => {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm flex items-center gap-2">
|
||||
<Filter className="h-4 w-4" />
|
||||
Filtros Inteligentes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Primeira linha de filtros - mais compacta */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">Buscar</label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-3 w-3 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="OF, Marca..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-7 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">OF</label>
|
||||
<Select value={filterOF || 'all'} onValueChange={handleOFChange}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todas as OFs</SelectItem>
|
||||
{uniqueOFs.map((of) => (
|
||||
<SelectItem key={of} value={of}>{of}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">Fase</label>
|
||||
<Select value={filterFase || 'all'} onValueChange={handleFaseChange}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todas as fases</SelectItem>
|
||||
{uniqueFases.map((fase) => (
|
||||
<SelectItem key={fase} value={fase}>{fase}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">Processo</label>
|
||||
<Select value={filterProcesso || 'all'} onValueChange={handleProcessoChange}>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="Selecione" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Todos os processos</SelectItem>
|
||||
{uniqueProcessos.map((processo) => (
|
||||
<SelectItem key={processo} value={processo}>{processo}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<Button variant="outline" onClick={clearFilters} className="h-8 text-xs">
|
||||
<RefreshCw className="h-3 w-3 mr-1" />
|
||||
Limpar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Segunda linha - filtros de data */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-2">
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">Data Início</label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-8 justify-start text-left font-normal text-xs",
|
||||
!dataInicio && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-1 h-3 w-3" />
|
||||
{dataInicio ? format(dataInicio, "dd/MM/yyyy") : "Selecionar"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={dataInicio}
|
||||
onSelect={setDataInicio}
|
||||
initialFocus
|
||||
className="pointer-events-auto"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium">Data Fim</label>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn(
|
||||
"h-8 justify-start text-left font-normal text-xs",
|
||||
!dataFim && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="mr-1 h-3 w-3" />
|
||||
{dataFim ? format(dataFim, "dd/MM/yyyy") : "Selecionar"}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={dataFim}
|
||||
onSelect={setDataFim}
|
||||
initialFocus
|
||||
className="pointer-events-auto"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{filterOF || filterFase || filterProcesso || dataInicio || dataFim ?
|
||||
'Filtros aplicados automaticamente baseados no último uso' :
|
||||
'Use os filtros para otimizar a visualização'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
106
src/components/apontamento/historico/ApontamentosListItem.tsx
Normal file
106
src/components/apontamento/historico/ApontamentosListItem.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
||||
import { Package, Wrench, Undo2 } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { ApontamentoProducao } from '@/hooks/useApontamentosProducao';
|
||||
|
||||
interface ApontamentosListItemProps {
|
||||
apontamento: ApontamentoProducao;
|
||||
onRevert: (id: string) => void;
|
||||
}
|
||||
|
||||
export const ApontamentosListItem: React.FC<ApontamentosListItemProps> = ({
|
||||
apontamento,
|
||||
onRevert
|
||||
}) => {
|
||||
const formatDate = (dateString: string) => {
|
||||
try {
|
||||
const date = new Date(dateString + 'T00:00:00');
|
||||
return format(date, 'dd/MM/yyyy', { locale: ptBR });
|
||||
} catch (error) {
|
||||
console.error('Erro ao formatar data:', error);
|
||||
return dateString;
|
||||
}
|
||||
};
|
||||
|
||||
const getMarcaItem = (apontamento: ApontamentoProducao) => {
|
||||
if (apontamento.tipo_apontamento === 'componente') {
|
||||
return apontamento.componente?.marca_componente || 'N/A';
|
||||
} else {
|
||||
return apontamento.peca?.marca || 'N/A';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 border rounded-lg hover:bg-muted/50 transition-colors">
|
||||
<div className="flex items-center gap-3">
|
||||
{apontamento.tipo_apontamento === 'componente' ? (
|
||||
<Wrench className="h-4 w-4 text-blue-500" />
|
||||
) : (
|
||||
<Package className="h-4 w-4 text-green-500" />
|
||||
)}
|
||||
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">
|
||||
{getMarcaItem(apontamento)}
|
||||
</span>
|
||||
<Badge variant="outline">
|
||||
{apontamento.of_number || 'N/A'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{apontamento.processo?.nome || 'N/A'} • {formatDate(apontamento.data_apontamento)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-right">
|
||||
<div className="font-medium">
|
||||
{apontamento.quantidade_produzida || 0} un.
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{format(new Date(apontamento.created_at), 'HH:mm', { locale: ptBR })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-orange-600 hover:text-orange-700 hover:bg-orange-50"
|
||||
>
|
||||
<Undo2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Reverter Apontamento</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Tem certeza de que deseja reverter este apontamento?
|
||||
Esta ação não pode ser desfeita e irá remover permanentemente o registro de:
|
||||
<br />
|
||||
<strong>{getMarcaItem(apontamento)}</strong> - {apontamento.quantidade_produzida} unidades
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => onRevert(apontamento.id)}
|
||||
className="bg-orange-600 hover:bg-orange-700"
|
||||
>
|
||||
Confirmar Reversão
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
273
src/components/apontamento/historico/useApontamentosFilters.tsx
Normal file
273
src/components/apontamento/historico/useApontamentosFilters.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { ApontamentoProducao } from '@/hooks/useApontamentosProducao';
|
||||
|
||||
// Cache para lembrar os últimos filtros utilizados
|
||||
const historicCache = {
|
||||
lastOF: '',
|
||||
lastFase: '',
|
||||
lastProcesso: ''
|
||||
};
|
||||
|
||||
export const useApontamentosFilters = (apontamentos: ApontamentoProducao[]) => {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [filterOF, setFilterOF] = useState('');
|
||||
const [filterFase, setFilterFase] = useState('');
|
||||
const [filterProcesso, setFilterProcesso] = useState('');
|
||||
const [dataInicio, setDataInicio] = useState<Date | undefined>();
|
||||
const [dataFim, setDataFim] = useState<Date | undefined>();
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
|
||||
// Logs detalhados para debug
|
||||
useEffect(() => {
|
||||
console.log(`🔍 DADOS RECEBIDOS NO FILTRO: ${apontamentos.length} apontamentos`);
|
||||
|
||||
if (apontamentos.length > 0) {
|
||||
// Verificar OF B118 especificamente
|
||||
const b118Records = apontamentos.filter(apt => apt.of_number === 'B118');
|
||||
console.log(`🎯 OF B118 no filtro: ${b118Records.length} registros`);
|
||||
|
||||
// Mostrar alguns exemplos
|
||||
if (b118Records.length > 0) {
|
||||
console.log('🔍 Primeiros 5 registros B118:',
|
||||
b118Records.slice(0, 5).map(apt => ({
|
||||
id: apt.id,
|
||||
of: apt.of_number,
|
||||
peca: apt.peca?.marca,
|
||||
processo: apt.processo?.nome,
|
||||
data: apt.data_apontamento
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
}, [apontamentos]);
|
||||
|
||||
// Carregar cache inicial apenas uma vez
|
||||
useEffect(() => {
|
||||
if (!initialized) {
|
||||
console.log(`📊 Inicializando filtros com ${apontamentos.length} apontamentos totais`);
|
||||
|
||||
const savedCache = localStorage.getItem('historico_apontamentos_cache');
|
||||
if (savedCache) {
|
||||
try {
|
||||
const parsed = JSON.parse(savedCache);
|
||||
Object.assign(historicCache, parsed);
|
||||
console.log('📋 Cache carregado:', historicCache);
|
||||
} catch (error) {
|
||||
console.log('⚠️ Erro ao carregar cache do histórico:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// NÃO aplicar filtros do cache automaticamente - deixar limpo por padrão
|
||||
setInitialized(true);
|
||||
}
|
||||
}, [apontamentos, initialized]);
|
||||
|
||||
// Salvar cache quando filtros mudam
|
||||
const updateCache = (updates: Partial<typeof historicCache>) => {
|
||||
Object.assign(historicCache, updates);
|
||||
localStorage.setItem('historico_apontamentos_cache', JSON.stringify(historicCache));
|
||||
};
|
||||
|
||||
// Obter valores únicos para filtros - SEMPRE todos os valores disponíveis
|
||||
const uniqueOFs = useMemo(() => {
|
||||
const ofs = apontamentos
|
||||
.map(apt => apt.of_number)
|
||||
.filter((of): of is string => {
|
||||
return typeof of === 'string' && of.trim() !== '';
|
||||
})
|
||||
.filter((of, index, array) => array.indexOf(of) === index)
|
||||
.sort();
|
||||
|
||||
console.log(`📋 OFs únicas encontradas: ${ofs.length}`, ofs.slice(0, 10), '...');
|
||||
return ofs;
|
||||
}, [apontamentos]);
|
||||
|
||||
const uniqueFases = useMemo(() => {
|
||||
// Se não há OF selecionada, mostrar todas as fases
|
||||
const apontamentosFiltrados = filterOF && filterOF !== ''
|
||||
? apontamentos.filter(apt => apt.of_number === filterOF)
|
||||
: apontamentos;
|
||||
|
||||
const fases = apontamentosFiltrados
|
||||
.map(apt => apt.peca?.etapa_fase)
|
||||
.filter((fase): fase is string => {
|
||||
return typeof fase === 'string' && fase.trim() !== '';
|
||||
})
|
||||
.filter((fase, index, array) => array.indexOf(fase) === index)
|
||||
.sort();
|
||||
|
||||
console.log(`📋 Fases únicas encontradas para OF "${filterOF}": ${fases.length}`, fases);
|
||||
return fases;
|
||||
}, [apontamentos, filterOF]);
|
||||
|
||||
const uniqueProcessos = useMemo(() => {
|
||||
// Aplicar filtros hierárquicos apenas se existirem
|
||||
let apontamentosFiltrados = apontamentos;
|
||||
|
||||
if (filterOF && filterOF !== '') {
|
||||
apontamentosFiltrados = apontamentosFiltrados.filter(apt => apt.of_number === filterOF);
|
||||
}
|
||||
|
||||
if (filterFase && filterFase !== '') {
|
||||
apontamentosFiltrados = apontamentosFiltrados.filter(apt => apt.peca?.etapa_fase === filterFase);
|
||||
}
|
||||
|
||||
const processos = apontamentosFiltrados
|
||||
.map(apt => apt.processo?.nome)
|
||||
.filter((processo): processo is string => {
|
||||
return typeof processo === 'string' && processo.trim() !== '';
|
||||
})
|
||||
.filter((processo, index, array) => array.indexOf(processo) === index)
|
||||
.sort();
|
||||
|
||||
console.log(`📋 Processos únicos encontrados para OF "${filterOF}" e Fase "${filterFase}": ${processos.length}`, processos);
|
||||
return processos;
|
||||
}, [apontamentos, filterOF, filterFase]);
|
||||
|
||||
// Filtrar apontamentos - LÓGICA SIMPLIFICADA E CORRIGIDA
|
||||
const filteredApontamentos = useMemo(() => {
|
||||
if (!initialized) {
|
||||
console.log('⏳ Aguardando inicialização...');
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log(`🔍 INICIANDO FILTRAGEM DE ${apontamentos.length} APONTAMENTOS`);
|
||||
console.log('🔍 Filtros ativos:', {
|
||||
searchTerm: searchTerm || 'VAZIO',
|
||||
filterOF: filterOF || 'VAZIO',
|
||||
filterFase: filterFase || 'VAZIO',
|
||||
filterProcesso: filterProcesso || 'VAZIO',
|
||||
dataInicio: dataInicio ? 'SIM' : 'NÃO',
|
||||
dataFim: dataFim ? 'SIM' : 'NÃO'
|
||||
});
|
||||
|
||||
let resultado = [...apontamentos]; // Começar com TODOS os apontamentos
|
||||
|
||||
// Aplicar filtro de busca textual APENAS se preenchido
|
||||
if (searchTerm && searchTerm.trim() !== '') {
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
const beforeSearch = resultado.length;
|
||||
|
||||
resultado = resultado.filter(apt =>
|
||||
(apt.of_number && apt.of_number.toLowerCase().includes(searchLower)) ||
|
||||
(apt.peca?.marca && apt.peca.marca.toLowerCase().includes(searchLower)) ||
|
||||
(apt.componente?.marca_componente && apt.componente.marca_componente.toLowerCase().includes(searchLower))
|
||||
);
|
||||
|
||||
console.log(`🔎 Filtro texto "${searchTerm}": ${beforeSearch} → ${resultado.length} registros`);
|
||||
}
|
||||
|
||||
// Aplicar filtro por OF APENAS se selecionado
|
||||
if (filterOF && filterOF !== '' && filterOF !== 'all') {
|
||||
const beforeOF = resultado.length;
|
||||
resultado = resultado.filter(apt => apt.of_number === filterOF);
|
||||
console.log(`🏷️ Filtro OF "${filterOF}": ${beforeOF} → ${resultado.length} registros`);
|
||||
}
|
||||
|
||||
// Aplicar filtro por Fase APENAS se selecionado
|
||||
if (filterFase && filterFase !== '' && filterFase !== 'all') {
|
||||
const beforeFase = resultado.length;
|
||||
resultado = resultado.filter(apt => apt.peca?.etapa_fase === filterFase);
|
||||
console.log(`📋 Filtro Fase "${filterFase}": ${beforeFase} → ${resultado.length} registros`);
|
||||
}
|
||||
|
||||
// Aplicar filtro por Processo APENAS se selecionado
|
||||
if (filterProcesso && filterProcesso !== '' && filterProcesso !== 'all') {
|
||||
const beforeProcesso = resultado.length;
|
||||
resultado = resultado.filter(apt => apt.processo?.nome === filterProcesso);
|
||||
console.log(`⚙️ Filtro Processo "${filterProcesso}": ${beforeProcesso} → ${resultado.length} registros`);
|
||||
}
|
||||
|
||||
// Aplicar filtros de data APENAS se definidos
|
||||
if (dataInicio || dataFim) {
|
||||
const beforeData = resultado.length;
|
||||
|
||||
resultado = resultado.filter(apt => {
|
||||
const apontamentoDate = new Date(apt.data_apontamento);
|
||||
let matchesDataInicio = true;
|
||||
let matchesDataFim = true;
|
||||
|
||||
if (dataInicio) {
|
||||
matchesDataInicio = apontamentoDate >= dataInicio;
|
||||
}
|
||||
if (dataFim) {
|
||||
matchesDataFim = apontamentoDate <= dataFim;
|
||||
}
|
||||
|
||||
return matchesDataInicio && matchesDataFim;
|
||||
});
|
||||
|
||||
console.log(`📅 Filtro Data: ${beforeData} → ${resultado.length} registros`);
|
||||
}
|
||||
|
||||
console.log(`✅ RESULTADO FINAL DA FILTRAGEM: ${resultado.length} de ${apontamentos.length} apontamentos`);
|
||||
|
||||
// Log específico para B118 se estiver sendo filtrado
|
||||
if (filterOF === 'B118') {
|
||||
console.log(`🎯 Resultado B118: ${resultado.length} registros filtrados`);
|
||||
}
|
||||
|
||||
return resultado;
|
||||
}, [apontamentos, searchTerm, filterOF, filterFase, filterProcesso, dataInicio, dataFim, initialized]);
|
||||
|
||||
const clearFilters = () => {
|
||||
console.log('🧹 Limpando todos os filtros');
|
||||
setSearchTerm('');
|
||||
setFilterOF('');
|
||||
setFilterFase('');
|
||||
setFilterProcesso('');
|
||||
setDataInicio(undefined);
|
||||
setDataFim(undefined);
|
||||
|
||||
// Limpar cache
|
||||
Object.assign(historicCache, { lastOF: '', lastFase: '', lastProcesso: '' });
|
||||
localStorage.removeItem('historico_apontamentos_cache');
|
||||
};
|
||||
|
||||
const handleOFChange = (value: string) => {
|
||||
const selectedValue = value === 'all' ? '' : value;
|
||||
console.log('🔄 Mudando OF para:', selectedValue);
|
||||
setFilterOF(selectedValue);
|
||||
// Limpar fase e processo quando OF muda
|
||||
setFilterFase('');
|
||||
setFilterProcesso('');
|
||||
updateCache({ lastOF: selectedValue, lastFase: '', lastProcesso: '' });
|
||||
};
|
||||
|
||||
const handleFaseChange = (value: string) => {
|
||||
const selectedValue = value === 'all' ? '' : value;
|
||||
console.log('🔄 Mudando Fase para:', selectedValue);
|
||||
setFilterFase(selectedValue);
|
||||
// Limpar processo quando fase muda
|
||||
setFilterProcesso('');
|
||||
updateCache({ lastFase: selectedValue, lastProcesso: '' });
|
||||
};
|
||||
|
||||
const handleProcessoChange = (value: string) => {
|
||||
const selectedValue = value === 'all' ? '' : value;
|
||||
console.log('🔄 Mudando Processo para:', selectedValue);
|
||||
setFilterProcesso(selectedValue);
|
||||
updateCache({ lastProcesso: selectedValue });
|
||||
};
|
||||
|
||||
return {
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
filterOF,
|
||||
filterFase,
|
||||
filterProcesso,
|
||||
dataInicio,
|
||||
dataFim,
|
||||
setDataInicio,
|
||||
setDataFim,
|
||||
uniqueOFs,
|
||||
uniqueFases,
|
||||
uniqueProcessos,
|
||||
filteredApontamentos,
|
||||
initialized,
|
||||
handleOFChange,
|
||||
handleFaseChange,
|
||||
handleProcessoChange,
|
||||
clearFilters
|
||||
};
|
||||
};
|
||||
215
src/components/atribuicoes/AtribuicaoEditModal.tsx
Normal file
215
src/components/atribuicoes/AtribuicaoEditModal.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useAtribuicoes } from '@/hooks/useAtribuicoes';
|
||||
|
||||
interface AtribuicaoEditModalProps {
|
||||
atribuicao: any;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function AtribuicaoEditModal({ atribuicao, isOpen, onClose }: AtribuicaoEditModalProps) {
|
||||
const { updateAtribuicao, updateUserAbbrev, canManage } = useAtribuicoes();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
user_abbrev: '',
|
||||
attribution: '',
|
||||
frequency: '',
|
||||
method: '',
|
||||
client: '',
|
||||
importance: '',
|
||||
duration: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (atribuicao) {
|
||||
setFormData({
|
||||
user_abbrev: atribuicao.user_abbrev || '',
|
||||
attribution: atribuicao.attribution || '',
|
||||
frequency: atribuicao.frequency || '',
|
||||
method: atribuicao.method || '',
|
||||
client: atribuicao.client || '',
|
||||
importance: atribuicao.importance || '',
|
||||
duration: atribuicao.duration || ''
|
||||
});
|
||||
}
|
||||
}, [atribuicao]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!atribuicao) return;
|
||||
|
||||
// Atualizar identificação se mudou
|
||||
if (formData.user_abbrev !== atribuicao.user_abbrev) {
|
||||
await updateUserAbbrev(atribuicao.user_id, formData.user_abbrev);
|
||||
}
|
||||
|
||||
// Atualizar atribuição
|
||||
await updateAtribuicao(atribuicao.id, {
|
||||
attribution: formData.attribution,
|
||||
frequency: formData.frequency as any,
|
||||
method: formData.method as any,
|
||||
client: formData.client as any,
|
||||
importance: formData.importance as any,
|
||||
duration: formData.duration as any
|
||||
});
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Editar Atribuição</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Usuário</Label>
|
||||
<Input value={atribuicao?.user_name || ''} disabled />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Identificação (3 letras)</Label>
|
||||
<Input
|
||||
value={formData.user_abbrev}
|
||||
onChange={(e) => setFormData({ ...formData, user_abbrev: e.target.value.toUpperCase().substring(0, 3) })}
|
||||
maxLength={3}
|
||||
disabled={!canManage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Atribuição</Label>
|
||||
<Textarea
|
||||
value={formData.attribution}
|
||||
onChange={(e) => setFormData({ ...formData, attribution: e.target.value })}
|
||||
maxLength={300}
|
||||
disabled={!canManage}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>Frequência</Label>
|
||||
<Select
|
||||
value={formData.frequency}
|
||||
onValueChange={(value) => setFormData({ ...formData, frequency: value })}
|
||||
disabled={!canManage}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="horaria">Horária</SelectItem>
|
||||
<SelectItem value="2xdia">2x ao dia</SelectItem>
|
||||
<SelectItem value="diaria">Diária</SelectItem>
|
||||
<SelectItem value="2xsemanal">2x semanal</SelectItem>
|
||||
<SelectItem value="semanal">Semanal</SelectItem>
|
||||
<SelectItem value="quinzenal">Quinzenal</SelectItem>
|
||||
<SelectItem value="mensal">Mensal</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Método</Label>
|
||||
<Select
|
||||
value={formData.method}
|
||||
onValueChange={(value) => setFormData({ ...formData, method: value })}
|
||||
disabled={!canManage}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="impresso">Impresso</SelectItem>
|
||||
<SelectItem value="sistema">Sistema</SelectItem>
|
||||
<SelectItem value="sistema-impresso">Sistema/Impresso</SelectItem>
|
||||
<SelectItem value="email">E-mail</SelectItem>
|
||||
<SelectItem value="verbal">Verbal</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Cliente</Label>
|
||||
<Select
|
||||
value={formData.client}
|
||||
onValueChange={(value) => setFormData({ ...formData, client: value })}
|
||||
disabled={!canManage}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="interno">Interno</SelectItem>
|
||||
<SelectItem value="processo">Processo</SelectItem>
|
||||
<SelectItem value="obra">Obra</SelectItem>
|
||||
<SelectItem value="contrato">Contrato</SelectItem>
|
||||
<SelectItem value="geral">Geral</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Grau de Importância</Label>
|
||||
<Select
|
||||
value={formData.importance}
|
||||
onValueChange={(value) => setFormData({ ...formData, importance: value })}
|
||||
disabled={!canManage}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="essencial">Essencial</SelectItem>
|
||||
<SelectItem value="estrategico">Estratégico</SelectItem>
|
||||
<SelectItem value="suporte">Suporte</SelectItem>
|
||||
<SelectItem value="informativo">Informativo</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Duração</Label>
|
||||
<Select
|
||||
value={formData.duration}
|
||||
onValueChange={(value) => setFormData({ ...formData, duration: value })}
|
||||
disabled={!canManage}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="<=1 hora">≤ 1 hora</SelectItem>
|
||||
<SelectItem value="2 horas">2 horas</SelectItem>
|
||||
<SelectItem value="4 horas">4 horas</SelectItem>
|
||||
<SelectItem value="8 horas">8 horas</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 justify-end pt-4">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
{canManage && (
|
||||
<Button onClick={handleSave}>
|
||||
Salvar Alterações
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
322
src/components/atribuicoes/AtribuicoesForm.tsx
Normal file
322
src/components/atribuicoes/AtribuicoesForm.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { useAtribuicoes } from '@/hooks/useAtribuicoes';
|
||||
import { useMobileResponsive } from '@/hooks/useMobileResponsive';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
|
||||
interface AtribuicaoTemp {
|
||||
attribution: string;
|
||||
frequency: string;
|
||||
method: string;
|
||||
client: string;
|
||||
importance: string;
|
||||
duration: string;
|
||||
}
|
||||
|
||||
export function AtribuicoesForm() {
|
||||
const { users, createAtribuicao, canManage } = useAtribuicoes();
|
||||
const { isMobile } = useMobileResponsive();
|
||||
|
||||
const [selectedUserId, setSelectedUserId] = useState('');
|
||||
const [userAbbrev, setUserAbbrev] = useState('');
|
||||
const [atribuicoes, setAtribuicoes] = useState<AtribuicaoTemp[]>([]);
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
attribution: '',
|
||||
frequency: '',
|
||||
method: '',
|
||||
client: '',
|
||||
importance: '',
|
||||
duration: ''
|
||||
});
|
||||
|
||||
const selectedUser = users.find(u => u.id === selectedUserId);
|
||||
|
||||
// Gerar abreviação automática quando usuário é selecionado
|
||||
useEffect(() => {
|
||||
if (selectedUser && !userAbbrev) {
|
||||
const names = selectedUser.full_name?.split(' ') || [];
|
||||
let abbrev = '';
|
||||
|
||||
if (names.length >= 2) {
|
||||
abbrev = names[0].charAt(0) + names[1].charAt(0) + names[0].charAt(1);
|
||||
} else if (names.length === 1) {
|
||||
abbrev = names[0].substring(0, 3);
|
||||
}
|
||||
|
||||
setUserAbbrev(abbrev.toUpperCase());
|
||||
}
|
||||
}, [selectedUser, userAbbrev]);
|
||||
|
||||
const handleAddAtribuicao = () => {
|
||||
if (!formData.attribution || !formData.frequency || !formData.method ||
|
||||
!formData.client || !formData.importance || !formData.duration) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAtribuicoes([...atribuicoes, { ...formData }]);
|
||||
setFormData({
|
||||
attribution: '',
|
||||
frequency: '',
|
||||
method: '',
|
||||
client: '',
|
||||
importance: '',
|
||||
duration: ''
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveAtribuicao = (index: number) => {
|
||||
setAtribuicoes(atribuicoes.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSaveAll = async () => {
|
||||
if (!selectedUserId || !userAbbrev || atribuicoes.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const attr of atribuicoes) {
|
||||
await createAtribuicao({
|
||||
user_id: selectedUserId,
|
||||
user_abbrev: userAbbrev,
|
||||
attribution: attr.attribution,
|
||||
frequency: attr.frequency as any,
|
||||
method: attr.method as any,
|
||||
client: attr.client as any,
|
||||
importance: attr.importance as any,
|
||||
duration: attr.duration as any
|
||||
});
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setSelectedUserId('');
|
||||
setUserAbbrev('');
|
||||
setAtribuicoes([]);
|
||||
};
|
||||
|
||||
if (!canManage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Formulário de Cadastro */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Adicionar Atribuição</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Seleção de Usuário */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>Selecionar Usuário</Label>
|
||||
<Select value={selectedUserId} onValueChange={(value) => {
|
||||
setSelectedUserId(value);
|
||||
setUserAbbrev('');
|
||||
setAtribuicoes([]);
|
||||
}}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="-- Selecione um usuário --" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{users.map((user) => (
|
||||
<SelectItem key={user.id} value={user.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarImage src={user.profile_image_url} />
|
||||
<AvatarFallback className="text-xs">
|
||||
{user.full_name?.charAt(0) || 'U'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span>{user.full_name} ({user.email})</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedUser && (
|
||||
<div>
|
||||
<Label>Identificação (3 letras)</Label>
|
||||
<Input
|
||||
value={userAbbrev}
|
||||
onChange={(e) => setUserAbbrev(e.target.value.toUpperCase().substring(0, 3))}
|
||||
placeholder="Ex: JOS"
|
||||
maxLength={3}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedUserId && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="col-span-full">
|
||||
<Label>Atribuição</Label>
|
||||
<Textarea
|
||||
value={formData.attribution}
|
||||
onChange={(e) => setFormData({ ...formData, attribution: e.target.value })}
|
||||
placeholder="Descrição da atribuição..."
|
||||
maxLength={300}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Frequência</Label>
|
||||
<Select value={formData.frequency} onValueChange={(value) => setFormData({ ...formData, frequency: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="horaria">Horária</SelectItem>
|
||||
<SelectItem value="2xdia">2x ao dia</SelectItem>
|
||||
<SelectItem value="diaria">Diária</SelectItem>
|
||||
<SelectItem value="2xsemanal">2x semanal</SelectItem>
|
||||
<SelectItem value="semanal">Semanal</SelectItem>
|
||||
<SelectItem value="quinzenal">Quinzenal</SelectItem>
|
||||
<SelectItem value="mensal">Mensal</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Método</Label>
|
||||
<Select value={formData.method} onValueChange={(value) => setFormData({ ...formData, method: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="impresso">Impresso</SelectItem>
|
||||
<SelectItem value="sistema">Sistema</SelectItem>
|
||||
<SelectItem value="sistema-impresso">Sistema/Impresso</SelectItem>
|
||||
<SelectItem value="email">E-mail</SelectItem>
|
||||
<SelectItem value="verbal">Verbal</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Cliente</Label>
|
||||
<Select value={formData.client} onValueChange={(value) => setFormData({ ...formData, client: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="interno">Interno</SelectItem>
|
||||
<SelectItem value="processo">Processo</SelectItem>
|
||||
<SelectItem value="obra">Obra</SelectItem>
|
||||
<SelectItem value="contrato">Contrato</SelectItem>
|
||||
<SelectItem value="geral">Geral</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Grau de Importância</Label>
|
||||
<Select value={formData.importance} onValueChange={(value) => setFormData({ ...formData, importance: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="essencial">Essencial</SelectItem>
|
||||
<SelectItem value="estrategico">Estratégico</SelectItem>
|
||||
<SelectItem value="suporte">Suporte</SelectItem>
|
||||
<SelectItem value="informativo">Informativo</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label>Duração</Label>
|
||||
<Select value={formData.duration} onValueChange={(value) => setFormData({ ...formData, duration: value })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="<=1 hora">≤ 1 hora</SelectItem>
|
||||
<SelectItem value="2 horas">2 horas</SelectItem>
|
||||
<SelectItem value="4 horas">4 horas</SelectItem>
|
||||
<SelectItem value="8 horas">8 horas</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedUserId && (
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleAddAtribuicao} disabled={!formData.attribution || !formData.frequency}>
|
||||
Adicionar Atribuição
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Lista de Atribuições Temporárias */}
|
||||
{atribuicoes.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<CardTitle>
|
||||
Atribuições para {selectedUser?.full_name}
|
||||
</CardTitle>
|
||||
<Button onClick={handleSaveAll} variant="default">
|
||||
Salvar Todas Atribuições
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{atribuicoes.map((attr, index) => (
|
||||
<div key={index} className="flex justify-between items-start p-4 border rounded-lg">
|
||||
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-2 text-sm">
|
||||
<div>
|
||||
<strong>Atribuição:</strong>
|
||||
<p className="break-words">{attr.attribution}</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Frequência:</strong>
|
||||
<p>{attr.frequency}</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Método:</strong>
|
||||
<p>{attr.method}</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Cliente:</strong>
|
||||
<p>{attr.client}</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Importância:</strong>
|
||||
<p>{attr.importance}</p>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Duração:</strong>
|
||||
<p>{attr.duration}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveAtribuicao(index)}
|
||||
className="text-red-600 hover:text-red-700 ml-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
195
src/components/atribuicoes/AtribuicoesPrintModal.tsx
Normal file
195
src/components/atribuicoes/AtribuicoesPrintModal.tsx
Normal file
@@ -0,0 +1,195 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Printer } from 'lucide-react';
|
||||
import { Atribuicao } from '@/hooks/useAtribuicoes';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
|
||||
interface AtribuicoesPrintModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
atribuicoes: Atribuicao[];
|
||||
}
|
||||
|
||||
export function AtribuicoesPrintModal({ isOpen, onClose, atribuicoes }: AtribuicoesPrintModalProps) {
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
||||
|
||||
// Obter lista única de usuários
|
||||
const uniqueUsers = Array.from(
|
||||
new Map(atribuicoes.map(attr => [attr.user_id, { id: attr.user_id, name: attr.user_name }]))
|
||||
.values()
|
||||
);
|
||||
|
||||
const handlePrint = () => {
|
||||
if (!selectedUserId) return;
|
||||
|
||||
// Filtrar atribuições do usuário selecionado
|
||||
const userAtribuicoes = atribuicoes.filter(attr => attr.user_id === selectedUserId);
|
||||
const selectedUser = uniqueUsers.find(user => user.id === selectedUserId);
|
||||
|
||||
if (!selectedUser || userAtribuicoes.length === 0) return;
|
||||
|
||||
const getImportanceBadgeStyle = (importancia: string) => {
|
||||
switch (importancia) {
|
||||
case 'essencial':
|
||||
return 'bg-red-100 text-red-800';
|
||||
case 'estrategico':
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'suporte':
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
case 'informativo':
|
||||
return 'bg-green-100 text-green-800';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const printContent = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Relatório de Atribuições</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
@media print {
|
||||
body {
|
||||
-webkit-print-color-adjust: exact;
|
||||
color-adjust: exact;
|
||||
}
|
||||
.print-container {
|
||||
box-shadow: none;
|
||||
margin: 0;
|
||||
max-width: 100%;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
.no-print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-100 p-4 sm:p-6">
|
||||
<div class="print-container max-w-6xl mx-auto bg-white rounded-xl shadow-lg overflow-hidden">
|
||||
<header class="bg-gray-100 text-gray-800 p-4 md:p-5 border-b border-gray-200">
|
||||
<h1 class="text-xl md:text-2xl font-bold">Relatório de Atribuições de ${selectedUser.name}</h1>
|
||||
<p class="text-gray-600 text-sm">Lista de atribuições para o usuário selecionado.</p>
|
||||
</header>
|
||||
|
||||
<main class="p-4 md:p-6">
|
||||
<div class="overflow-x-auto rounded-lg border border-gray-200">
|
||||
<table class="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">Atribuição</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">Frequência</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">Método</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">Cliente</th>
|
||||
<th scope="col" class="px-4 py-3 text-center text-xs font-bold text-gray-600 uppercase tracking-wider">Importância</th>
|
||||
<th scope="col" class="px-4 py-3 text-left text-xs font-bold text-gray-600 uppercase tracking-wider">Duração</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y divide-gray-200">
|
||||
${userAtribuicoes.map((attr, index) => `
|
||||
<tr${index % 2 === 1 ? ' class="hover:bg-gray-50"' : ''}>
|
||||
<td class="px-4 py-3 align-top text-gray-700" style="white-space: normal;">${attr.attribution}</td>
|
||||
<td class="px-4 py-3 align-top whitespace-nowrap text-gray-700">${attr.frequency}</td>
|
||||
<td class="px-4 py-3 align-top whitespace-nowrap text-gray-700">${attr.method}</td>
|
||||
<td class="px-4 py-3 align-top whitespace-nowrap text-gray-700">${attr.client}</td>
|
||||
<td class="px-4 py-3 align-top whitespace-nowrap text-center">
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${getImportanceBadgeStyle(attr.importance)}">
|
||||
${attr.importance}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 align-top whitespace-nowrap text-gray-700">${attr.duration}</td>
|
||||
</tr>
|
||||
`).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="text-center text-xs text-gray-400 p-3 bg-gray-50 border-t">
|
||||
<p>Relatório gerado em: ${format(new Date(), 'dd/MM/yyyy', { locale: ptBR })}</p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (!printWindow) return;
|
||||
|
||||
printWindow.document.write(printContent);
|
||||
printWindow.document.close();
|
||||
|
||||
printWindow.onload = () => {
|
||||
setTimeout(() => {
|
||||
printWindow.print();
|
||||
printWindow.close();
|
||||
}, 500);
|
||||
};
|
||||
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Printer className="h-5 w-5" />
|
||||
Imprimir Atribuições
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Selecionar Usuário:</label>
|
||||
<Select value={selectedUserId} onValueChange={setSelectedUserId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Escolha um usuário..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{uniqueUsers.map((user) => (
|
||||
<SelectItem key={user.id} value={user.id}>
|
||||
{user.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handlePrint}
|
||||
disabled={!selectedUserId}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Printer className="h-4 w-4" />
|
||||
Imprimir
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
323
src/components/atribuicoes/AtribuicoesTable.tsx
Normal file
323
src/components/atribuicoes/AtribuicoesTable.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { ResponsiveTable } from '@/components/responsive/ResponsiveTable';
|
||||
import { TableCell } from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useAtribuicoes } from '@/hooks/useAtribuicoes';
|
||||
import { useMobileResponsive } from '@/hooks/useMobileResponsive';
|
||||
import { Edit, Trash2, Search, AlertCircle, Printer } from 'lucide-react';
|
||||
import { AtribuicaoEditModal } from './AtribuicaoEditModal';
|
||||
import { AtribuicoesPrintModal } from './AtribuicoesPrintModal';
|
||||
|
||||
export function AtribuicoesTable() {
|
||||
const { atribuicoes, canManage, deleteAtribuicao, loading } = useAtribuicoes();
|
||||
const { isMobile } = useMobileResponsive();
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [editingAtribuicao, setEditingAtribuicao] = useState<any>(null);
|
||||
const [showPrintModal, setShowPrintModal] = useState(false);
|
||||
|
||||
const filteredAtribuicoes = atribuicoes.filter(attr =>
|
||||
attr.user_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
attr.attribution.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
attr.user_abbrev.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
console.log('📊 AtribuicoesTable render:', {
|
||||
atribuicoesTotal: atribuicoes.length,
|
||||
filteredCount: filteredAtribuicoes.length,
|
||||
loading,
|
||||
searchTerm
|
||||
});
|
||||
|
||||
const getImportanceBadgeColor = (importancia: string) => {
|
||||
switch (importancia) {
|
||||
case 'essencial': return 'destructive';
|
||||
case 'estrategico': return 'default';
|
||||
case 'suporte': return 'secondary';
|
||||
case 'informativo': return 'outline';
|
||||
default: return 'outline';
|
||||
}
|
||||
};
|
||||
|
||||
const headers = [
|
||||
'Usuário',
|
||||
'ID',
|
||||
'Atribuição',
|
||||
'Frequência',
|
||||
'Método',
|
||||
'Cliente',
|
||||
'Importância',
|
||||
'Duração',
|
||||
...(canManage ? ['Ações'] : [])
|
||||
];
|
||||
|
||||
const renderRow = (attr: any, index: number) => (
|
||||
<>
|
||||
{/* Coluna Usuário - 18% da largura */}
|
||||
<TableCell className="w-[18%] min-w-[180px] p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Avatar className="h-10 w-10 flex-shrink-0">
|
||||
<AvatarImage src={attr.user_photo} />
|
||||
<AvatarFallback className="text-sm font-medium">
|
||||
{attr.user_name.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-sm truncate">{attr.user_name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">{attr.user_email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* Coluna ID - 4% da largura */}
|
||||
<TableCell className="w-[4%] min-w-[50px] p-3">
|
||||
<Badge variant="outline" className="text-xs font-medium">{attr.user_abbrev}</Badge>
|
||||
</TableCell>
|
||||
|
||||
{/* Coluna Atribuição - 35% da largura com fonte menor */}
|
||||
<TableCell className="w-[35%] min-w-[350px] p-3">
|
||||
<div className="assignment-cell text-xs leading-relaxed max-h-[4.5rem] overflow-hidden">
|
||||
<p className="break-words whitespace-normal line-clamp-4" title={attr.attribution}>
|
||||
{attr.attribution}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
{/* Coluna Frequência - 7% da largura, centralizada */}
|
||||
<TableCell className="w-[7%] min-w-[70px] p-3 text-center">
|
||||
<span className="text-sm text-muted-foreground">{attr.frequency}</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Coluna Método - 7% da largura */}
|
||||
<TableCell className="w-[7%] min-w-[70px] p-3">
|
||||
<span className="text-sm text-muted-foreground">{attr.method}</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Coluna Cliente - 7% da largura */}
|
||||
<TableCell className="w-[7%] min-w-[70px] p-3">
|
||||
<span className="text-sm text-muted-foreground">{attr.client}</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Coluna Importância - 9% da largura, centralizada */}
|
||||
<TableCell className="w-[9%] min-w-[90px] p-3 text-center">
|
||||
<Badge
|
||||
variant={getImportanceBadgeColor(attr.importance)}
|
||||
className="importance-tag text-xs px-2 py-1 rounded-full whitespace-nowrap"
|
||||
>
|
||||
{attr.importance}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
|
||||
{/* Coluna Duração - 7% da largura, centralizada */}
|
||||
<TableCell className="w-[7%] min-w-[70px] p-3 text-center">
|
||||
<span className="text-sm text-muted-foreground">{attr.duration}</span>
|
||||
</TableCell>
|
||||
|
||||
{/* Coluna Ações - 6% da largura, centralizada */}
|
||||
{canManage && (
|
||||
<TableCell className="w-[6%] min-w-[80px] p-3 text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditingAtribuicao(attr)}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm('Tem certeza que deseja excluir esta atribuição?')) {
|
||||
deleteAtribuicao(attr.id);
|
||||
}
|
||||
}}
|
||||
className="h-8 w-8 p-0 text-muted-foreground hover:text-red-500 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const renderMobileCard = (attr: any) => (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarImage src={attr.user_photo} />
|
||||
<AvatarFallback className="text-xs">
|
||||
{attr.user_name.charAt(0)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<p className="font-medium text-sm">{attr.user_name}</p>
|
||||
<Badge variant="outline" className="text-xs">{attr.user_abbrev}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={getImportanceBadgeColor(attr.importance)}>
|
||||
{attr.importance}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium">Atribuição:</p>
|
||||
<p className="text-sm text-muted-foreground">{attr.attribution}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="font-medium">Frequência:</span> {attr.frequency}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Método:</span> {attr.method}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Cliente:</span> {attr.client}
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium">Duração:</span> {attr.duration}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canManage && (
|
||||
<div className="flex gap-2 pt-2 border-t">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setEditingAtribuicao(attr)}
|
||||
className="flex-1"
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
Editar
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm('Tem certeza que deseja excluir esta atribuição?')) {
|
||||
deleteAtribuicao(attr.id);
|
||||
}
|
||||
}}
|
||||
className="flex-1 text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
Excluir
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{canManage ? 'Todas as Atribuições' : 'Minhas Atribuições'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
<span className="ml-2">Carregando atribuições...</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||
<CardTitle>
|
||||
{canManage ? 'Todas as Atribuições' : 'Minhas Atribuições'}
|
||||
{atribuicoes.length > 0 && (
|
||||
<span className="text-sm font-normal text-muted-foreground ml-2">
|
||||
({atribuicoes.length} {atribuicoes.length === 1 ? 'atribuição' : 'atribuições'})
|
||||
</span>
|
||||
)}
|
||||
</CardTitle>
|
||||
<div className="flex flex-col sm:flex-row gap-2 w-full sm:w-auto">
|
||||
{atribuicoes.length > 0 && (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => setShowPrintModal(true)}
|
||||
variant="outline"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Printer className="h-4 w-4" />
|
||||
Imprimir
|
||||
</Button>
|
||||
<div className="relative w-full sm:w-auto">
|
||||
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Buscar por usuário, atribuição ou ID..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9 w-full sm:w-80"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{atribuicoes.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Nenhuma atribuição encontrada</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Ainda não 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
283
src/components/auditoria/InconsistenciaDetalhesModal.tsx
Normal file
283
src/components/auditoria/InconsistenciaDetalhesModal.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { AlertTriangle, Calendar, Hash, TrendingUp, ExternalLink } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
interface InconsistenciaDetalhesModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
inconsistencia: any;
|
||||
}
|
||||
|
||||
export const InconsistenciaDetalhesModal: React.FC<InconsistenciaDetalhesModalProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
inconsistencia
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!inconsistencia) return null;
|
||||
|
||||
const handleNavegar = (rota: string) => {
|
||||
navigate(rota);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const renderDetalhes = () => {
|
||||
switch (inconsistencia.tipo) {
|
||||
case 'Processo Pulado':
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Processos Pulados:</h4>
|
||||
<div className="space-y-1">
|
||||
{inconsistencia.detalhes.processosPulados.map((processo: string, index: number) => (
|
||||
<Badge key={index} variant="destructive">{processo}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{inconsistencia.detalhes.apontamentos.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Apontamentos Existentes:</h4>
|
||||
<div className="space-y-2">
|
||||
{inconsistencia.detalhes.apontamentos.map((apt: any, index: number) => (
|
||||
<div key={index} className="flex items-center justify-between p-2 bg-muted rounded">
|
||||
<span>{apt.processo}</span>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Calendar className="h-3 w-3" />
|
||||
{new Date(apt.data).toLocaleDateString()}
|
||||
<Hash className="h-3 w-3" />
|
||||
{apt.quantidade}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'Quantidade Excedente':
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Quantidade Cadastrada</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{inconsistencia.detalhes.quantidadeCadastrada}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Quantidade Apontada</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{inconsistencia.detalhes.quantidadeApontada}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-amber-50 border border-amber-200 rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4 text-amber-600" />
|
||||
<span className="font-medium text-amber-800">
|
||||
Diferença: +{inconsistencia.detalhes.diferenca} peças
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-amber-700 mt-1">
|
||||
Processo: {inconsistencia.detalhes.processo}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'Expedição sem Processos':
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-red-600" />
|
||||
<span className="font-medium text-red-800">
|
||||
Peça expedida sem processos de produção
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{inconsistencia.detalhes.romaneios.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Romaneios de Expedição:</h4>
|
||||
<div className="space-y-2">
|
||||
{inconsistencia.detalhes.romaneios.map((romaneio: any, index: number) => (
|
||||
<div key={index} className="flex items-center justify-between p-2 bg-muted rounded">
|
||||
<span>Quantidade: {romaneio.quantidade_expedida}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Peso: {romaneio.peso_total}kg
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'Múltiplas Prioridades':
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-medium mb-2">Prioridades Encontradas:</h4>
|
||||
<div className="space-y-1">
|
||||
{inconsistencia.detalhes.prioridades.map((prioridade: string, index: number) => (
|
||||
<Badge key={index} variant="outline">{prioridade}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-yellow-50 border border-yellow-200 rounded">
|
||||
<p className="text-sm text-yellow-800">
|
||||
Recomenda-se manter a peça em apenas uma prioridade para evitar conflitos de programação.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<div className="text-center py-4 text-muted-foreground">
|
||||
Detalhes não disponíveis para este tipo de inconsistência.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getAcoesSugeridas = () => {
|
||||
const acoes: Array<{ label: string; rota: string; descricao: string }> = [];
|
||||
|
||||
switch (inconsistencia.tipo) {
|
||||
case 'Processo Pulado':
|
||||
acoes.push({
|
||||
label: 'Ir para Apontamento de Produção',
|
||||
rota: '/apontamento-producao',
|
||||
descricao: 'Adicionar apontamentos dos processos faltantes'
|
||||
});
|
||||
break;
|
||||
|
||||
case 'Quantidade Excedente':
|
||||
acoes.push({
|
||||
label: 'Ir para Cadastro de Peças',
|
||||
rota: '/seletor-of',
|
||||
descricao: 'Corrigir quantidade no cadastro da peça'
|
||||
});
|
||||
acoes.push({
|
||||
label: 'Ver Histórico de Apontamentos',
|
||||
rota: '/apontamento-producao',
|
||||
descricao: 'Verificar apontamentos duplicados'
|
||||
});
|
||||
break;
|
||||
|
||||
case 'Expedição sem Processos':
|
||||
acoes.push({
|
||||
label: 'Ir para Apontamento de Produção',
|
||||
rota: '/apontamento-producao',
|
||||
descricao: 'Adicionar apontamentos de produção'
|
||||
});
|
||||
break;
|
||||
|
||||
case 'Múltiplas Prioridades':
|
||||
acoes.push({
|
||||
label: 'Ir para Prioridades de Fabricação',
|
||||
rota: '/prioridades-fabricacao',
|
||||
descricao: 'Remover peça das prioridades desnecessárias'
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return acoes;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||||
Detalhes da Inconsistência
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Informações Básicas */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Informações da Peça</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<span className="text-sm font-medium">Marca:</span>
|
||||
<p className="font-mono">{inconsistencia.marca}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium">Tipo:</span>
|
||||
<Badge variant="secondary">{inconsistencia.tipo}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-sm font-medium">Descrição:</span>
|
||||
<p className="text-sm text-muted-foreground">{inconsistencia.descricao}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Detalhes Específicos */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Detalhes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{renderDetalhes()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Ações Sugeridas */}
|
||||
{inconsistencia.acaoSugerida && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Ação Sugerida</CardTitle>
|
||||
<CardDescription>{inconsistencia.acaoSugerida}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{getAcoesSugeridas().map((acao, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleNavegar(acao.rota)}
|
||||
className="w-full justify-start"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4 mr-2" />
|
||||
{acao.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
95
src/components/auth/ForgotPasswordModal.tsx
Normal file
95
src/components/auth/ForgotPasswordModal.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Mail } from 'lucide-react';
|
||||
import { usePasswordReset } from '@/hooks/usePasswordReset';
|
||||
|
||||
interface ForgotPasswordModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const ForgotPasswordModal = ({ isOpen, onClose }: ForgotPasswordModalProps) => {
|
||||
const [email, setEmail] = useState('');
|
||||
const { requestPasswordReset, isRequesting } = usePasswordReset();
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!email) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validação básica de e-mail
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestPasswordReset({ email });
|
||||
setEmail('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setEmail('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5" />
|
||||
Esqueci a Senha
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Digite seu e-mail cadastrado para receber as instruções de redefinição de senha.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="reset-email">E-mail</Label>
|
||||
<Input
|
||||
id="reset-email"
|
||||
type="email"
|
||||
placeholder="seu@email.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
disabled={isRequesting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isRequesting}
|
||||
>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isRequesting || !email}
|
||||
>
|
||||
{isRequesting ? 'Enviando...' : 'Enviar E-mail'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
67
src/components/auth/LogoutButton.tsx
Normal file
67
src/components/auth/LogoutButton.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
|
||||
import { LogOut } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
|
||||
interface LogoutButtonProps {
|
||||
variant?: 'default' | 'ghost' | 'outline' | 'secondary';
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon';
|
||||
showText?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function LogoutButton({
|
||||
variant = 'ghost',
|
||||
size = 'default',
|
||||
showText = true,
|
||||
className = ''
|
||||
}: LogoutButtonProps) {
|
||||
const { signOut } = useAuth();
|
||||
|
||||
const handleLogout = async () => {
|
||||
await signOut();
|
||||
};
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={`text-red-500 hover:text-red-600 hover:bg-red-50 ${className}`}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
{showText && size !== 'icon' && <span className="ml-2">Sair do Sistema</span>}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirmar Saída</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Tem certeza que deseja sair do sistema? Você precisará fazer login novamente para acessar suas informações.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleLogout}
|
||||
className="bg-red-500 hover:bg-red-600 text-white"
|
||||
>
|
||||
Sair do Sistema
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
217
src/components/auth/PasswordResetForm.tsx
Normal file
217
src/components/auth/PasswordResetForm.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useAuth } from '@/hooks/useAuth';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { toast } from 'sonner';
|
||||
import { Eye, EyeOff, Lock } from 'lucide-react';
|
||||
|
||||
export const PasswordResetForm = () => {
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { updatePassword } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Password strength validation
|
||||
const validatePassword = (password: string) => {
|
||||
const minLength = password.length >= 8;
|
||||
const hasUpperCase = /[A-Z]/.test(password);
|
||||
const hasLowerCase = /[a-z]/.test(password);
|
||||
const hasNumbers = /\d/.test(password);
|
||||
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
|
||||
|
||||
const score = [minLength, hasUpperCase, hasLowerCase, hasNumbers, hasSpecialChar].filter(Boolean).length;
|
||||
|
||||
return {
|
||||
score,
|
||||
minLength,
|
||||
hasUpperCase,
|
||||
hasLowerCase,
|
||||
hasNumbers,
|
||||
hasSpecialChar,
|
||||
isValid: score >= 4 && minLength
|
||||
};
|
||||
};
|
||||
|
||||
const passwordStrength = validatePassword(password);
|
||||
|
||||
const getPasswordStrengthColor = (score: number) => {
|
||||
if (score <= 2) return 'bg-red-500';
|
||||
if (score <= 3) return 'bg-yellow-500';
|
||||
return 'bg-green-500';
|
||||
};
|
||||
|
||||
const getPasswordStrengthText = (score: number) => {
|
||||
if (score <= 2) return 'Fraca';
|
||||
if (score <= 3) return 'Média';
|
||||
return 'Forte';
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!password || !confirmPassword) {
|
||||
toast.error('Por favor, preencha todos os campos');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!passwordStrength.isValid) {
|
||||
toast.error('A senha deve ter pelo menos 8 caracteres e incluir maiúsculas, minúsculas, números e símbolos');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
toast.error('As senhas não coincidem');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
console.log('🔄 Iniciando redefinição de senha...');
|
||||
const { error } = await updatePassword(password);
|
||||
|
||||
if (error) {
|
||||
console.error('❌ Erro ao redefinir senha:', error);
|
||||
|
||||
// Tratamento específico de erros
|
||||
let errorMessage = 'Erro ao redefinir senha. Tente novamente.';
|
||||
|
||||
if (error.message?.includes('session_not_found')) {
|
||||
errorMessage = 'Sessão expirada. Solicite um novo link de redefinição.';
|
||||
} else if (error.message?.includes('same_password')) {
|
||||
errorMessage = 'A nova senha deve ser diferente da anterior.';
|
||||
} else if (error.message?.includes('password_invalid')) {
|
||||
errorMessage = 'Senha inválida. Verifique os critérios de segurança.';
|
||||
} else if (error.message) {
|
||||
errorMessage = `Erro: ${error.message}`;
|
||||
}
|
||||
|
||||
toast.error(errorMessage);
|
||||
} else {
|
||||
console.log('✅ Senha redefinida com sucesso!');
|
||||
toast.success('Senha redefinida com sucesso! Redirecionando...');
|
||||
|
||||
// Aguardar um pouco antes de redirecionar para mostrar a mensagem
|
||||
setTimeout(() => {
|
||||
navigate('/');
|
||||
}, 1500);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Erro inesperado:', error);
|
||||
toast.error('Erro inesperado ao redefinir senha. Tente novamente.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="shadow-xl border-0 bg-background/80 backdrop-blur-sm w-full max-w-md">
|
||||
<CardHeader className="space-y-1 pb-4">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-r from-blue-500 to-purple-600 rounded-full flex items-center justify-center">
|
||||
<Lock className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-2xl text-center">Redefinir Senha</CardTitle>
|
||||
<CardDescription className="text-center">
|
||||
Digite sua nova senha abaixo
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-password" className="text-sm font-medium">
|
||||
Nova Senha
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="new-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Mínimo 8 caracteres"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="pl-10 pr-10 h-12"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-3 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Password Strength Indicator */}
|
||||
{password && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 bg-muted rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-300 ${getPasswordStrengthColor(passwordStrength.score)}`}
|
||||
style={{ width: `${(passwordStrength.score / 5) * 100}%` }}
|
||||
></div>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{getPasswordStrengthText(passwordStrength.score)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs space-y-1">
|
||||
<div className={`flex items-center gap-1 ${passwordStrength.minLength ? 'text-green-600' : 'text-red-600'}`}>
|
||||
<span className="w-2 h-2 rounded-full bg-current"></span>
|
||||
Mínimo 8 caracteres
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 ${passwordStrength.hasUpperCase ? 'text-green-600' : 'text-red-600'}`}>
|
||||
<span className="w-2 h-2 rounded-full bg-current"></span>
|
||||
Letra maiúscula
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 ${passwordStrength.hasNumbers ? 'text-green-600' : 'text-red-600'}`}>
|
||||
<span className="w-2 h-2 rounded-full bg-current"></span>
|
||||
Número
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 ${passwordStrength.hasSpecialChar ? 'text-green-600' : 'text-red-600'}`}>
|
||||
<span className="w-2 h-2 rounded-full bg-current"></span>
|
||||
Símbolo especial
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password" className="text-sm font-medium">
|
||||
Confirmar Nova Senha
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
placeholder="Confirme sua nova senha"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="pl-10 h-12"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12 bg-gradient-to-r from-blue-500 to-purple-600 hover:from-blue-600 hover:to-purple-700 text-white font-medium text-sm"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Redefinindo...' : 'Redefinir Senha'}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
350
src/components/catalogos/CatalogoModal.tsx
Normal file
350
src/components/catalogos/CatalogoModal.tsx
Normal file
@@ -0,0 +1,350 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { X, Sparkles, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Catalogo } from '@/hooks/useCatalogos';
|
||||
import { FileUploadSection } from './FileUploadSection';
|
||||
|
||||
interface CatalogoModalProps {
|
||||
catalogo?: Catalogo | null;
|
||||
onSave: (data: any) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const categorias = [
|
||||
'Aço',
|
||||
'Adesivo',
|
||||
'Tintas',
|
||||
'Parafusos',
|
||||
'Soldas',
|
||||
'Estruturas',
|
||||
'Materiais'
|
||||
];
|
||||
|
||||
const disciplinas = [
|
||||
'Engenharia Civil',
|
||||
'Engenharia Mecânica',
|
||||
'Arquitetura',
|
||||
'Construção',
|
||||
'Fabricação',
|
||||
'Montagem'
|
||||
];
|
||||
|
||||
export function CatalogoModal({ catalogo, onSave, onClose }: CatalogoModalProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
titulo: '',
|
||||
categoria: '',
|
||||
disciplina: '',
|
||||
conteudo: '',
|
||||
numero_paginas: '',
|
||||
palavras_chave: [] as string[],
|
||||
arquivo_urls: [] as string[]
|
||||
});
|
||||
const [newKeyword, setNewKeyword] = useState('');
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (catalogo) {
|
||||
setFormData({
|
||||
titulo: catalogo.titulo,
|
||||
categoria: catalogo.categoria,
|
||||
disciplina: catalogo.disciplina,
|
||||
conteudo: catalogo.conteudo,
|
||||
numero_paginas: catalogo.numero_paginas?.toString() || '',
|
||||
palavras_chave: catalogo.palavras_chave || [],
|
||||
arquivo_urls: (catalogo as any).arquivo_urls || []
|
||||
});
|
||||
}
|
||||
}, [catalogo]);
|
||||
|
||||
const handleInputChange = (field: string, value: string) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const addKeyword = () => {
|
||||
if (newKeyword.trim() && !formData.palavras_chave.includes(newKeyword.trim())) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
palavras_chave: [...prev.palavras_chave, newKeyword.trim()]
|
||||
}));
|
||||
setNewKeyword('');
|
||||
}
|
||||
};
|
||||
|
||||
const removeKeyword = (keyword: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
palavras_chave: prev.palavras_chave.filter(k => k !== keyword)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleFilesAnalyzed = (analysisResult: any) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
titulo: analysisResult.titulo || prev.titulo,
|
||||
categoria: analysisResult.categoria || prev.categoria,
|
||||
disciplina: analysisResult.disciplina || prev.disciplina,
|
||||
conteudo: analysisResult.conteudo || prev.conteudo,
|
||||
palavras_chave: [...new Set([...prev.palavras_chave, ...(analysisResult.palavras_chave || [])])]
|
||||
}));
|
||||
};
|
||||
|
||||
const handleFilesUploaded = (urls: string[]) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
arquivo_urls: [...new Set([...prev.arquivo_urls, ...urls])]
|
||||
}));
|
||||
};
|
||||
|
||||
const analyzeWithAI = async () => {
|
||||
if (!formData.conteudo.trim()) {
|
||||
toast.error('Por favor, cole o conteúdo do documento antes de analisar');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAnalyzing(true);
|
||||
try {
|
||||
// Simulação da análise com IA (seria integração real com Gemini)
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Análise simulada baseada no conteúdo
|
||||
const content = formData.conteudo.toLowerCase();
|
||||
|
||||
let suggestedCategoria = '';
|
||||
let suggestedDisciplina = '';
|
||||
let suggestedTitulo = '';
|
||||
let suggestedKeywords: string[] = [];
|
||||
|
||||
// Lógica simples de categorização baseada em palavras-chave
|
||||
if (content.includes('aço') || content.includes('steel')) {
|
||||
suggestedCategoria = 'Aço';
|
||||
suggestedKeywords.push('aço', 'steel');
|
||||
} else if (content.includes('tinta') || content.includes('pintura')) {
|
||||
suggestedCategoria = 'Tintas';
|
||||
suggestedKeywords.push('tinta', 'pintura');
|
||||
} else if (content.includes('parafuso') || content.includes('fixação')) {
|
||||
suggestedCategoria = 'Parafusos';
|
||||
suggestedKeywords.push('parafuso', 'fixação');
|
||||
} else {
|
||||
suggestedCategoria = 'Materiais';
|
||||
}
|
||||
|
||||
if (content.includes('estrutura') || content.includes('construção')) {
|
||||
suggestedDisciplina = 'Engenharia Civil';
|
||||
suggestedKeywords.push('estrutura', 'construção');
|
||||
} else if (content.includes('mecânica') || content.includes('fabricação')) {
|
||||
suggestedDisciplina = 'Engenharia Mecânica';
|
||||
suggestedKeywords.push('mecânica', 'fabricação');
|
||||
} else {
|
||||
suggestedDisciplina = 'Construção';
|
||||
}
|
||||
|
||||
// Extrair título das primeiras linhas
|
||||
const firstLine = formData.conteudo.split('\n')[0];
|
||||
if (firstLine && firstLine.length > 10 && firstLine.length < 100) {
|
||||
suggestedTitulo = firstLine.trim();
|
||||
}
|
||||
|
||||
// Adicionar palavras-chave técnicas comuns
|
||||
if (content.includes('resistência')) suggestedKeywords.push('resistência');
|
||||
if (content.includes('qualidade')) suggestedKeywords.push('qualidade');
|
||||
if (content.includes('especificação')) suggestedKeywords.push('especificação');
|
||||
if (content.includes('norma')) suggestedKeywords.push('norma');
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
titulo: suggestedTitulo || prev.titulo,
|
||||
categoria: suggestedCategoria || prev.categoria,
|
||||
disciplina: suggestedDisciplina || prev.disciplina,
|
||||
palavras_chave: [...new Set([...prev.palavras_chave, ...suggestedKeywords])]
|
||||
}));
|
||||
|
||||
toast.success('Análise concluída! Os campos foram preenchidos automaticamente.');
|
||||
} catch (error) {
|
||||
console.error('Erro na análise:', error);
|
||||
toast.error('Erro ao analisar documento com IA');
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!formData.titulo || !formData.categoria || !formData.disciplina || !formData.conteudo) {
|
||||
toast.error('Por favor, preencha todos os campos obrigatórios');
|
||||
return;
|
||||
}
|
||||
|
||||
const submitData = {
|
||||
...formData,
|
||||
numero_paginas: formData.numero_paginas ? parseInt(formData.numero_paginas) : null
|
||||
};
|
||||
|
||||
await onSave(submitData);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{catalogo ? 'Editar Documento' : 'Novo Documento'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Seção de Upload de Arquivos - apenas para novos documentos */}
|
||||
{!catalogo && (
|
||||
<div className="p-4 bg-slate-50 rounded-lg border">
|
||||
<FileUploadSection
|
||||
onFilesAnalyzed={handleFilesAnalyzed}
|
||||
onFilesUploaded={handleFilesUploaded}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="titulo">Título *</Label>
|
||||
<Input
|
||||
id="titulo"
|
||||
value={formData.titulo}
|
||||
onChange={(e) => handleInputChange('titulo', e.target.value)}
|
||||
placeholder="Digite o título do documento"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="numero_paginas">Número de Páginas</Label>
|
||||
<Input
|
||||
id="numero_paginas"
|
||||
type="number"
|
||||
value={formData.numero_paginas}
|
||||
onChange={(e) => handleInputChange('numero_paginas', e.target.value)}
|
||||
placeholder="Ex: 25"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="categoria">Categoria *</Label>
|
||||
<Select
|
||||
value={formData.categoria}
|
||||
onValueChange={(value) => handleInputChange('categoria', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione uma categoria" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categorias.map((categoria) => (
|
||||
<SelectItem key={categoria} value={categoria}>
|
||||
{categoria}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="disciplina">Disciplina *</Label>
|
||||
<Select
|
||||
value={formData.disciplina}
|
||||
onValueChange={(value) => handleInputChange('disciplina', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Selecione uma disciplina" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{disciplinas.map((disciplina) => (
|
||||
<SelectItem key={disciplina} value={disciplina}>
|
||||
{disciplina}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="conteudo">Conteúdo do Documento *</Label>
|
||||
<Textarea
|
||||
id="conteudo"
|
||||
value={formData.conteudo}
|
||||
onChange={(e) => handleInputChange('conteudo', e.target.value)}
|
||||
placeholder="Cole o conteúdo do documento aqui..."
|
||||
className="min-h-[200px]"
|
||||
/>
|
||||
{!catalogo && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={analyzeWithAI}
|
||||
disabled={isAnalyzing || !formData.conteudo.trim()}
|
||||
className="mt-2"
|
||||
variant="outline"
|
||||
>
|
||||
{isAnalyzing ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{isAnalyzing ? 'Analisando...' : 'Analisar com IA'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Palavras-chave</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newKeyword}
|
||||
onChange={(e) => setNewKeyword(e.target.value)}
|
||||
placeholder="Adicionar palavra-chave"
|
||||
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), addKeyword())}
|
||||
/>
|
||||
<Button type="button" onClick={addKeyword} variant="outline">
|
||||
Adicionar
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{formData.palavras_chave.map((keyword, index) => (
|
||||
<Badge key={index} variant="secondary" className="flex items-center gap-1">
|
||||
{keyword}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeKeyword(keyword)}
|
||||
className="ml-1 hover:text-red-400"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" className="bg-green-600 hover:bg-green-700">
|
||||
{catalogo ? 'Atualizar' : 'Salvar'} Documento
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
168
src/components/catalogos/CatalogoPreviews.tsx
Normal file
168
src/components/catalogos/CatalogoPreviews.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Edit, Trash2, FileText, Calendar, Hash, Eye } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Catalogo } from '@/hooks/useCatalogos';
|
||||
|
||||
interface CatalogoPreviewsProps {
|
||||
catalogos: Catalogo[];
|
||||
onEdit: (catalogo: Catalogo) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onView: (catalogo: Catalogo) => void;
|
||||
canModify: boolean;
|
||||
}
|
||||
|
||||
export function CatalogoPreviews({ catalogos, onEdit, onDelete, onView, canModify }: CatalogoPreviewsProps) {
|
||||
if (catalogos.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<FileText className="mx-auto h-12 w-12 mb-4 opacity-50" />
|
||||
<p>Nenhum documento encontrado</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{catalogos.map((catalogo) => (
|
||||
<Card
|
||||
key={catalogo.id}
|
||||
className="bg-white border-slate-300 shadow-sm hover:shadow-md transition-colors cursor-pointer
|
||||
dark:bg-slate-800/30 dark:border-slate-700 dark:hover:bg-slate-800/50"
|
||||
onClick={() => onView(catalogo)}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex justify-between items-start gap-2">
|
||||
<CardTitle className="text-slate-900 dark:text-white text-lg line-clamp-2" title={catalogo.titulo}>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 flex-shrink-0" />
|
||||
{catalogo.titulo}
|
||||
</div>
|
||||
</CardTitle>
|
||||
{canModify && (
|
||||
<div className="flex gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onView(catalogo);
|
||||
}}
|
||||
className="h-8 w-8 p-0 hover:bg-green-100 dark:hover:bg-green-600/20"
|
||||
title="Visualizar documento"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(catalogo);
|
||||
}}
|
||||
className="h-8 w-8 p-0 hover:bg-blue-100 dark:hover:bg-blue-600/20"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="h-8 w-8 p-0 hover:bg-red-100 dark:hover:bg-red-600/20"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirmar exclusão</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Tem certeza que deseja excluir o documento "{catalogo.titulo}"?
|
||||
Esta ação não pode ser desfeita.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => onDelete(catalogo.id)}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
Excluir
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Badge variant="secondary" className="bg-blue-50 text-blue-700 border border-blue-200 dark:bg-blue-900 dark:text-blue-100 dark:border-transparent">
|
||||
{catalogo.categoria}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-slate-300 text-slate-700 dark:border-slate-600 dark:text-slate-300">
|
||||
{catalogo.disciplina}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
<p className="text-slate-600 dark:text-slate-400 text-sm line-clamp-3">
|
||||
{catalogo.conteudo.substring(0, 150)}...
|
||||
</p>
|
||||
|
||||
<div className="flex items-center gap-4 text-sm text-slate-500 dark:text-slate-400">
|
||||
<div className="flex items-center gap-1">
|
||||
<Hash className="h-4 w-4" />
|
||||
<span>{catalogo.numero_paginas || 'N/A'} pág.</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>{format(new Date(catalogo.created_at), 'dd/MM/yy', { locale: ptBR })}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{catalogo.palavras_chave && catalogo.palavras_chave.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{catalogo.palavras_chave.slice(0, 4).map((palavra, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs border-slate-300 text-slate-600 dark:border-slate-600 dark:text-slate-400">
|
||||
{palavra}
|
||||
</Badge>
|
||||
))}
|
||||
{catalogo.palavras_chave.length > 4 && (
|
||||
<Badge variant="outline" className="text-xs border-slate-300 text-slate-600 dark:border-slate-600 dark:text-slate-400">
|
||||
+{catalogo.palavras_chave.length - 4}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{catalogo.arquivo_urls && catalogo.arquivo_urls.length > 0 && (
|
||||
<div className="flex items-center gap-2 text-xs text-green-600 dark:text-green-400">
|
||||
<Eye className="h-3 w-3" />
|
||||
<span>{catalogo.arquivo_urls.length} documento(s) disponível(eis)</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
126
src/components/catalogos/CatalogosFilters.tsx
Normal file
126
src/components/catalogos/CatalogosFilters.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Search, X, Grid, List } from 'lucide-react';
|
||||
|
||||
interface CatalogosFiltersProps {
|
||||
searchTerm: string;
|
||||
setSearchTerm: (value: string) => void;
|
||||
categoriaFilter: string;
|
||||
setCategoriaFilter: (value: string) => void;
|
||||
disciplinaFilter: string;
|
||||
setDisciplinaFilter: (value: string) => void;
|
||||
onClearFilters: () => void;
|
||||
viewMode: 'table' | 'preview';
|
||||
onViewModeChange: (mode: 'table' | 'preview') => void;
|
||||
}
|
||||
|
||||
const categorias = [
|
||||
'Aço',
|
||||
'Adesivo',
|
||||
'Tintas',
|
||||
'Parafusos',
|
||||
'Soldas',
|
||||
'Estruturas',
|
||||
'Materiais'
|
||||
];
|
||||
|
||||
const disciplinas = [
|
||||
'Engenharia Civil',
|
||||
'Engenharia Mecânica',
|
||||
'Arquitetura',
|
||||
'Construção',
|
||||
'Fabricação',
|
||||
'Montagem'
|
||||
];
|
||||
|
||||
export function CatalogosFilters({
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
categoriaFilter,
|
||||
setCategoriaFilter,
|
||||
disciplinaFilter,
|
||||
setDisciplinaFilter,
|
||||
onClearFilters,
|
||||
viewMode,
|
||||
onViewModeChange
|
||||
}: CatalogosFiltersProps) {
|
||||
const hasActiveFilters = searchTerm || (categoriaFilter && categoriaFilter !== 'all') || (disciplinaFilter && disciplinaFilter !== 'all');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col md:flex-row gap-4 items-start md:items-center">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-slate-400" />
|
||||
<Input
|
||||
placeholder="Buscar por título, conteúdo, palavras-chave..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9 h-10 bg-white border-slate-300 text-slate-900 dark:bg-slate-700 dark:border-slate-600 dark:text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={categoriaFilter} onValueChange={setCategoriaFilter}>
|
||||
<SelectTrigger className="w-full md:w-[180px] h-10 bg-white border-slate-300 text-slate-900 dark:bg-slate-700 dark:border-slate-600 dark:text-white">
|
||||
<SelectValue placeholder="Categoria" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-white border-slate-300 dark:bg-slate-700 dark:border-slate-600">
|
||||
<SelectItem value="all" className="text-slate-900 dark:text-white hover:bg-slate-100 dark:hover:bg-slate-600">Todas as Categorias</SelectItem>
|
||||
{categorias.map((categoria) => (
|
||||
<SelectItem key={categoria} value={categoria} className="text-slate-900 dark:text-white hover:bg-slate-100 dark:hover:bg-slate-600">
|
||||
{categoria}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={disciplinaFilter} onValueChange={setDisciplinaFilter}>
|
||||
<SelectTrigger className="w-full md:w-[180px] h-10 bg-white border-slate-300 text-slate-900 dark:bg-slate-700 dark:border-slate-600 dark:text-white">
|
||||
<SelectValue placeholder="Disciplina" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-white border-slate-300 dark:bg-slate-700 dark:border-slate-600">
|
||||
<SelectItem value="all" className="text-slate-900 dark:text-white hover:bg-slate-100 dark:hover:bg-slate-600">Todas as Disciplinas</SelectItem>
|
||||
{disciplinas.map((disciplina) => (
|
||||
<SelectItem key={disciplina} value={disciplina} className="text-slate-900 dark:text-white hover:bg-slate-100 dark:hover:bg-slate-600">
|
||||
{disciplina}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={viewMode === 'table' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onViewModeChange('table')}
|
||||
className={viewMode === 'table' ? '' : 'bg-slate-50 border-slate-300 text-slate-900 hover:bg-slate-100 dark:bg-transparent dark:border-slate-600 dark:text-white dark:hover:bg-slate-700'}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'preview' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onViewModeChange('preview')}
|
||||
className={viewMode === 'preview' ? '' : 'bg-slate-50 border-slate-300 text-slate-900 hover:bg-slate-100 dark:bg-transparent dark:border-slate-600 dark:text-white dark:hover:bg-slate-700'}
|
||||
>
|
||||
<Grid className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClearFilters}
|
||||
size="sm"
|
||||
className="h-10 bg-white border-slate-300 text-slate-700 hover:bg-slate-100 dark:bg-slate-700 dark:border-slate-600 dark:text-white dark:hover:bg-slate-600"
|
||||
>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
Limpar
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
160
src/components/catalogos/CatalogosTable.tsx
Normal file
160
src/components/catalogos/CatalogosTable.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Edit, Trash2, Eye, FileText } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Catalogo } from '@/hooks/useCatalogos';
|
||||
|
||||
interface CatalogosTableProps {
|
||||
catalogos: Catalogo[];
|
||||
onEdit: (catalogo: Catalogo) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onView: (catalogo: Catalogo) => void;
|
||||
canModify: boolean;
|
||||
}
|
||||
|
||||
export function CatalogosTable({ catalogos, onEdit, onDelete, onView, canModify }: CatalogosTableProps) {
|
||||
if (catalogos.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Eye className="mx-auto h-12 w-12 mb-4 opacity-50" />
|
||||
<p>Nenhum documento encontrado</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full overflow-x-auto">
|
||||
<Table disableOverflow>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-50 dark:bg-transparent">
|
||||
<TableHead className="text-slate-700 dark:text-white">Título</TableHead>
|
||||
<TableHead className="text-slate-700 dark:text-white">Categoria</TableHead>
|
||||
<TableHead className="text-slate-700 dark:text-white">Disciplina</TableHead>
|
||||
<TableHead className="text-slate-700 dark:text-white">Páginas</TableHead>
|
||||
<TableHead className="text-slate-700 dark:text-white">Data de Inclusão</TableHead>
|
||||
<TableHead className="text-slate-700 dark:text-white">Palavras-chave</TableHead>
|
||||
<TableHead className="text-slate-700 dark:text-white">Ações</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{catalogos.map((catalogo) => (
|
||||
<TableRow key={catalogo.id} className="hover:bg-slate-50 dark:hover:bg-slate-700/50">
|
||||
<TableCell className="text-slate-900 dark:text-white font-medium max-w-[200px]">
|
||||
<div
|
||||
className="truncate cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
||||
title={catalogo.titulo}
|
||||
onClick={() => onView(catalogo)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 flex-shrink-0" />
|
||||
{catalogo.titulo}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary" className="bg-blue-50 text-blue-700 border border-blue-200 dark:bg-blue-900 dark:text-blue-100 dark:border-transparent">
|
||||
{catalogo.categoria}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="border-slate-300 text-slate-700 dark:border-slate-600 dark:text-slate-300">
|
||||
{catalogo.disciplina}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-700 dark:text-slate-300">
|
||||
{catalogo.numero_paginas || 'N/A'}
|
||||
</TableCell>
|
||||
<TableCell className="text-slate-700 dark:text-slate-300">
|
||||
{format(new Date(catalogo.created_at), 'dd/MM/yyyy', { locale: ptBR })}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px]">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{catalogo.palavras_chave?.slice(0, 3).map((palavra, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs border-slate-300 text-slate-600 dark:border-slate-600 dark:text-slate-400">
|
||||
{palavra}
|
||||
</Badge>
|
||||
))}
|
||||
{catalogo.palavras_chave && catalogo.palavras_chave.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs border-slate-300 text-slate-600 dark:border-slate-600 dark:text-slate-400">
|
||||
+{catalogo.palavras_chave.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onView(catalogo)}
|
||||
className="h-8 w-8 p-0 hover:bg-green-100 dark:hover:bg-green-600/20"
|
||||
title="Visualizar documento"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
{canModify && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onEdit(catalogo)}
|
||||
className="h-8 w-8 p-0 hover:bg-blue-100 dark:hover:bg-blue-600/20"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 p-0 hover:bg-red-100 dark:hover:bg-red-600/20"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirmar exclusão</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Tem certeza que deseja excluir o documento "{catalogo.titulo}"?
|
||||
Esta ação não pode ser desfeita.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancelar</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => onDelete(catalogo.id)}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
Excluir
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
276
src/components/catalogos/DocumentViewer.tsx
Normal file
276
src/components/catalogos/DocumentViewer.tsx
Normal file
@@ -0,0 +1,276 @@
|
||||
|
||||
// Top-level imports
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { X, Download, ZoomIn, ZoomOut, ChevronLeft, ChevronRight, FileText, Image, ExternalLink, Maximize2, Minimize2 } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { ptBR } from 'date-fns/locale';
|
||||
import { Catalogo } from '@/hooks/useCatalogos';
|
||||
|
||||
interface DocumentViewerProps {
|
||||
catalogo: Catalogo | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// DocumentViewer component
|
||||
export function DocumentViewer({ catalogo, isOpen, onClose }: DocumentViewerProps) {
|
||||
// TODOS OS HOOKS DEVEM ESTAR NO TOPO - ANTES DE QUALQUER RETURN CONDICIONAL
|
||||
const [currentDocIndex, setCurrentDocIndex] = useState(0);
|
||||
const [zoomLevel, setZoomLevel] = useState(1);
|
||||
const viewerRef = useRef<HTMLDivElement>(null);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
// useEffect também deve estar no topo
|
||||
useEffect(() => {
|
||||
const onFullScreenChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener('fullscreenchange', onFullScreenChange);
|
||||
return () => document.removeEventListener('fullscreenchange', onFullScreenChange);
|
||||
}, []);
|
||||
|
||||
// Funções de handler
|
||||
const handlePrevious = () => {
|
||||
if (currentDocIndex > 0) {
|
||||
setCurrentDocIndex(currentDocIndex - 1);
|
||||
setZoomLevel(1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (catalogo?.arquivo_urls && currentDocIndex < catalogo.arquivo_urls.length - 1) {
|
||||
setCurrentDocIndex(currentDocIndex + 1);
|
||||
setZoomLevel(1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleZoomIn = () => {
|
||||
if (zoomLevel < 3) {
|
||||
setZoomLevel(zoomLevel + 0.25);
|
||||
}
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
if (zoomLevel > 0.5) {
|
||||
setZoomLevel(zoomLevel - 0.25);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const currentDoc = catalogo?.arquivo_urls?.[currentDocIndex];
|
||||
if (currentDoc) {
|
||||
const link = document.createElement('a');
|
||||
link.href = currentDoc;
|
||||
link.download = '';
|
||||
link.target = '_blank';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenInNewTab = () => {
|
||||
const currentDoc = catalogo?.arquivo_urls?.[currentDocIndex];
|
||||
if (currentDoc) {
|
||||
window.open(currentDoc, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleFullscreen = async () => {
|
||||
try {
|
||||
if (!document.fullscreenElement) {
|
||||
await viewerRef.current?.requestFullscreen?.();
|
||||
setIsFullscreen(true);
|
||||
} else {
|
||||
await document.exitFullscreen?.();
|
||||
setIsFullscreen(false);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Erro ao alternar tela cheia:', e);
|
||||
}
|
||||
};
|
||||
|
||||
const resetAndClose = () => {
|
||||
setCurrentDocIndex(0);
|
||||
setZoomLevel(1);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// AGORA SIM podemos fazer o return condicional - APÓS todos os hooks
|
||||
if (!catalogo || !catalogo.arquivo_urls || catalogo.arquivo_urls.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const currentDoc = catalogo.arquivo_urls[currentDocIndex];
|
||||
const isImage = currentDoc?.includes('image') || /\.(jpg|jpeg|png|gif|webp|bmp|tiff)$/i.test(currentDoc || '');
|
||||
const isPdf = currentDoc?.includes('pdf') || /\.pdf$/i.test(currentDoc || '');
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) resetAndClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="
|
||||
w-[95vw] max-w-[95vw] h-[90vh]
|
||||
bg-white text-slate-900 border-slate-300
|
||||
dark:bg-slate-900 dark:text-white dark:border-slate-700
|
||||
"
|
||||
>
|
||||
<DialogHeader className="flex flex-row items-center justify-between space-y-0 pb-4 border-b border-slate-200 dark:border-slate-700">
|
||||
<div className="flex-1">
|
||||
<DialogTitle className="text-xl mb-2">{catalogo.titulo}</DialogTitle>
|
||||
<div className="flex items-center gap-4 text-sm text-slate-600 dark:text-slate-400">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className="bg-slate-100 text-slate-700 border border-slate-300 dark:bg-blue-900 dark:text-blue-100 dark:border-slate-700">
|
||||
{catalogo.categoria}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-slate-300 text-slate-700 dark:border-slate-600 dark:text-slate-300">
|
||||
{catalogo.disciplina}
|
||||
</Badge>
|
||||
</div>
|
||||
<span>{format(new Date(catalogo.created_at), 'dd/MM/yyyy', { locale: ptBR })}</span>
|
||||
{catalogo.numero_paginas && <span>{catalogo.numero_paginas} páginas</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{catalogo.arquivo_urls.length > 1 && (
|
||||
<div className="mr-2 text-sm text-slate-600 dark:text-slate-400">
|
||||
{currentDocIndex + 1} de {catalogo.arquivo_urls.length}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isImage && (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" onClick={handleZoomOut} disabled={zoomLevel <= 0.5} className="h-8 w-8 p-0">
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-slate-600 dark:text-slate-400 px-2">{Math.round(zoomLevel * 100)}%</span>
|
||||
<Button variant="ghost" size="sm" onClick={handleZoomIn} disabled={zoomLevel >= 3} className="h-8 w-8 p-0">
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={handleOpenInNewTab} className="h-8 w-8 p-0" title="Abrir em nova aba">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={handleToggleFullscreen} className="h-8 w-8 p-0" title="Tela cheia">
|
||||
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</Button>
|
||||
|
||||
<Button variant="ghost" size="sm" onClick={handleDownload} className="h-8 w-8 p-0" title="Baixar">
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={resetAndClose} className="h-8 w-8 p-0" title="Fechar">
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden pt-3">
|
||||
{catalogo.arquivo_urls.length > 1 && (
|
||||
<div className="flex items-center">
|
||||
<Button variant="ghost" size="sm" onClick={handlePrevious} disabled={currentDocIndex === 0} className="h-10 w-10 p-0">
|
||||
<ChevronLeft className="h-6 w-6" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={viewerRef}
|
||||
className="
|
||||
flex-1 flex items-center justify-center overflow-auto
|
||||
bg-slate-50 border border-slate-200/80 rounded-lg mx-2
|
||||
dark:bg-slate-800/30 dark:border-slate-700
|
||||
"
|
||||
>
|
||||
{isPdf ? (
|
||||
<iframe
|
||||
src={currentDoc}
|
||||
className="w-full h-full min-h-[65vh] rounded-lg"
|
||||
title={`Documento ${currentDocIndex + 1}`}
|
||||
/>
|
||||
) : isImage ? (
|
||||
<img
|
||||
src={currentDoc}
|
||||
alt={`Documento ${currentDocIndex + 1}`}
|
||||
className="max-w-full max-h-full object-contain rounded-lg transition-transform duration-200"
|
||||
style={{ transform: `scale(${zoomLevel})` }}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center text-slate-500 dark:text-slate-400 gap-4">
|
||||
<FileText className="h-12 w-12" />
|
||||
<p>Tipo de documento não suportado para visualização</p>
|
||||
<Button onClick={handleDownload} className="bg-blue-600 hover:bg-blue-700">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Baixar Documento
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{catalogo.arquivo_urls.length > 1 && (
|
||||
<div className="flex items-center">
|
||||
<Button variant="ghost" size="sm" onClick={handleNext} disabled={currentDocIndex === catalogo.arquivo_urls.length - 1} className="h-10 w-10 p-0">
|
||||
<ChevronRight className="h-6 w-6" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{catalogo.arquivo_urls.length > 1 && (
|
||||
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
|
||||
<div className="flex gap-2 overflow-x-auto pb-2">
|
||||
{catalogo.arquivo_urls.map((url, index) => {
|
||||
const isCurrentImage = url?.includes('image') || /\.(jpg|jpeg|png|gif|webp|bmp|tiff)$/i.test(url || '');
|
||||
const isCurrentPdf = url?.includes('pdf') || /\.pdf$/i.test(url || '');
|
||||
return (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => { setCurrentDocIndex(index); setZoomLevel(1); }}
|
||||
className={`flex-shrink-0 w-16 h-16 rounded-lg border-2 overflow-hidden transition-colors ${
|
||||
index === currentDocIndex ? 'border-blue-500' : 'border-slate-300 hover:border-slate-400 dark:border-slate-600 dark:hover:border-slate-500'
|
||||
}`}
|
||||
>
|
||||
{isCurrentImage ? (
|
||||
<img src={url} alt={`Miniatura ${index + 1}`} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center">
|
||||
{isCurrentPdf ? (
|
||||
<FileText className="h-6 w-6 text-slate-500 dark:text-slate-400" />
|
||||
) : (
|
||||
<Image className="h-6 w-6 text-slate-500 dark:text-slate-400" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t border-slate-200 dark:border-slate-700 pt-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-slate-700 dark:text-slate-300 text-sm line-clamp-3">{catalogo.conteudo}</p>
|
||||
{catalogo.palavras_chave && catalogo.palavras_chave.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{catalogo.palavras_chave.map((palavra, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs border-slate-300 text-slate-700 dark:border-slate-600 dark:text-slate-300">
|
||||
{palavra}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
299
src/components/catalogos/FileUploadSection.tsx
Normal file
299
src/components/catalogos/FileUploadSection.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Upload, X, FileText, Image, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
|
||||
interface FileUploadSectionProps {
|
||||
onFilesAnalyzed: (analysisResult: {
|
||||
titulo?: string;
|
||||
categoria?: string;
|
||||
disciplina?: string;
|
||||
conteudo?: string;
|
||||
palavras_chave?: string[];
|
||||
}) => void;
|
||||
onFilesUploaded: (urls: string[]) => void;
|
||||
}
|
||||
|
||||
interface UploadedFile {
|
||||
file: File;
|
||||
url?: string;
|
||||
uploading: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function FileUploadSection({ onFilesAnalyzed, onFilesUploaded }: FileUploadSectionProps) {
|
||||
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([]);
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
|
||||
const handleFileSelect = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
|
||||
// Validar tipos de arquivo
|
||||
const allowedTypes = [
|
||||
'application/pdf',
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
'image/tiff',
|
||||
'image/bmp'
|
||||
];
|
||||
|
||||
const validFiles = files.filter(file => {
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
toast.error(`Tipo de arquivo não permitido: ${file.name}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) { // 10MB
|
||||
toast.error(`Arquivo muito grande: ${file.name} (máximo 10MB)`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
const newFiles = validFiles.map(file => ({
|
||||
file,
|
||||
uploading: false,
|
||||
error: undefined
|
||||
}));
|
||||
|
||||
setUploadedFiles(prev => [...prev, ...newFiles]);
|
||||
}
|
||||
|
||||
// Limpar o input
|
||||
event.target.value = '';
|
||||
}, []);
|
||||
|
||||
const uploadFile = async (fileData: UploadedFile, index: number) => {
|
||||
try {
|
||||
setUploadedFiles(prev => prev.map((f, i) =>
|
||||
i === index ? { ...f, uploading: true, error: undefined } : f
|
||||
));
|
||||
|
||||
const fileName = `${Date.now()}-${fileData.file.name}`;
|
||||
const { data, error } = await supabase.storage
|
||||
.from('catalogo-documents')
|
||||
.upload(fileName, fileData.file);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const { data: { publicUrl } } = supabase.storage
|
||||
.from('catalogo-documents')
|
||||
.getPublicUrl(fileName);
|
||||
|
||||
setUploadedFiles(prev => prev.map((f, i) =>
|
||||
i === index ? { ...f, uploading: false, url: publicUrl } : f
|
||||
));
|
||||
|
||||
return publicUrl;
|
||||
} catch (error) {
|
||||
console.error('Erro no upload:', error);
|
||||
setUploadedFiles(prev => prev.map((f, i) =>
|
||||
i === index ? { ...f, uploading: false, error: 'Erro no upload' } : f
|
||||
));
|
||||
toast.error('Erro no upload do arquivo');
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const uploadAllFiles = async () => {
|
||||
const uploadPromises = uploadedFiles.map((fileData, index) => {
|
||||
if (!fileData.url && !fileData.uploading) {
|
||||
return uploadFile(fileData, index);
|
||||
}
|
||||
return Promise.resolve(fileData.url);
|
||||
});
|
||||
|
||||
const urls = await Promise.all(uploadPromises);
|
||||
const validUrls = urls.filter(url => url !== null) as string[];
|
||||
onFilesUploaded(validUrls);
|
||||
return validUrls;
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setUploadedFiles(prev => prev.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const analyzeFiles = async () => {
|
||||
if (uploadedFiles.length === 0) {
|
||||
toast.error('Adicione pelo menos um arquivo para analisar');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAnalyzing(true);
|
||||
|
||||
try {
|
||||
// Primeiro fazer upload de todos os arquivos
|
||||
const urls = await uploadAllFiles();
|
||||
|
||||
if (urls.length === 0) {
|
||||
toast.error('Nenhum arquivo foi carregado com sucesso');
|
||||
return;
|
||||
}
|
||||
|
||||
// Simular análise com IA baseada nos arquivos
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Análise baseada nos nomes e tipos de arquivo
|
||||
const fileNames = uploadedFiles.map(f => f.file.name.toLowerCase());
|
||||
const hasImages = uploadedFiles.some(f => f.file.type.startsWith('image/'));
|
||||
const hasPdf = uploadedFiles.some(f => f.file.type === 'application/pdf');
|
||||
|
||||
let suggestedCategoria = '';
|
||||
let suggestedDisciplina = '';
|
||||
let suggestedTitulo = '';
|
||||
let suggestedKeywords: string[] = [];
|
||||
let suggestedContent = '';
|
||||
|
||||
// Lógica de categorização baseada em nomes de arquivo
|
||||
const content = fileNames.join(' ');
|
||||
|
||||
if (content.includes('aço') || content.includes('steel')) {
|
||||
suggestedCategoria = 'Aço';
|
||||
suggestedKeywords.push('aço', 'steel');
|
||||
} else if (content.includes('tinta') || content.includes('pintura')) {
|
||||
suggestedCategoria = 'Tintas';
|
||||
suggestedKeywords.push('tinta', 'pintura');
|
||||
} else if (content.includes('parafuso') || content.includes('fixação')) {
|
||||
suggestedCategoria = 'Parafusos';
|
||||
suggestedKeywords.push('parafuso', 'fixação');
|
||||
} else {
|
||||
suggestedCategoria = 'Materiais';
|
||||
}
|
||||
|
||||
if (content.includes('estrutura') || content.includes('construção')) {
|
||||
suggestedDisciplina = 'Engenharia Civil';
|
||||
suggestedKeywords.push('estrutura', 'construção');
|
||||
} else if (content.includes('mecânica') || content.includes('fabricação')) {
|
||||
suggestedDisciplina = 'Engenharia Mecânica';
|
||||
suggestedKeywords.push('mecânica', 'fabricação');
|
||||
} else {
|
||||
suggestedDisciplina = 'Construção';
|
||||
}
|
||||
|
||||
// Gerar título baseado no primeiro arquivo
|
||||
if (uploadedFiles.length > 0) {
|
||||
const firstFileName = uploadedFiles[0].file.name.replace(/\.[^/.]+$/, '');
|
||||
suggestedTitulo = firstFileName.replace(/[-_]/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
||||
}
|
||||
|
||||
// Gerar conteúdo baseado nos tipos de arquivo
|
||||
suggestedContent = `Documento ${hasPdf ? 'PDF' : 'de imagens'} contendo informações técnicas sobre ${suggestedCategoria.toLowerCase()}.`;
|
||||
|
||||
if (hasImages) {
|
||||
suggestedContent += ` Inclui ${uploadedFiles.filter(f => f.file.type.startsWith('image/')).length} imagem(ns) técnica(s).`;
|
||||
}
|
||||
|
||||
if (hasPdf) {
|
||||
suggestedContent += ` Documento PDF com especificações e detalhes técnicos.`;
|
||||
}
|
||||
|
||||
// Adicionar palavras-chave relacionadas ao tipo de arquivo
|
||||
if (hasPdf) suggestedKeywords.push('pdf', 'documento');
|
||||
if (hasImages) suggestedKeywords.push('imagem', 'visual');
|
||||
|
||||
onFilesAnalyzed({
|
||||
titulo: suggestedTitulo,
|
||||
categoria: suggestedCategoria,
|
||||
disciplina: suggestedDisciplina,
|
||||
conteudo: suggestedContent,
|
||||
palavras_chave: [...new Set(suggestedKeywords)]
|
||||
});
|
||||
|
||||
toast.success('Análise concluída! Os campos foram preenchidos automaticamente.');
|
||||
} catch (error) {
|
||||
console.error('Erro na análise:', error);
|
||||
toast.error('Erro ao analisar documentos');
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getFileIcon = (file: File) => {
|
||||
return file.type === 'application/pdf' ? FileText : Image;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="file-upload">Upload de Documentos</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="file-upload"
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.jpg,.jpeg,.png,.gif,.webp,.tiff,.bmp"
|
||||
onChange={handleFileSelect}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={analyzeFiles}
|
||||
disabled={isAnalyzing || uploadedFiles.length === 0}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{isAnalyzing ? (
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{isAnalyzing ? 'Analisando...' : 'Analisar com IA'}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tipos aceitos: PDF, JPG, PNG, GIF, WebP, TIFF, BMP (máximo 10MB cada)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{uploadedFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>Arquivos Selecionados</Label>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{uploadedFiles.map((fileData, index) => {
|
||||
const Icon = getFileIcon(fileData.file);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-2 bg-slate-50 rounded border"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<Icon className="w-4 h-4 text-slate-600 flex-shrink-0" />
|
||||
<span className="text-sm truncate">{fileData.file.name}</span>
|
||||
<span className="text-xs text-slate-500 flex-shrink-0">
|
||||
({(fileData.file.size / (1024 * 1024)).toFixed(1)} MB)
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{fileData.uploading && (
|
||||
<Loader2 className="w-4 h-4 animate-spin text-blue-600" />
|
||||
)}
|
||||
{fileData.url && (
|
||||
<span className="text-xs text-green-600">✓</span>
|
||||
)}
|
||||
{fileData.error && (
|
||||
<span className="text-xs text-red-600">✗</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFile(index)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
src/components/configuracoes/PrioridadesConfig.tsx
Normal file
138
src/components/configuracoes/PrioridadesConfig.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { usePrioridades, PrioridadeConfig } from '@/hooks/usePrioridades';
|
||||
import { Palette, Save } from 'lucide-react';
|
||||
|
||||
export const PrioridadesConfig = () => {
|
||||
const { prioridades, loading, updatePrioridade } = usePrioridades();
|
||||
const [editingPrioridades, setEditingPrioridades] = useState<Record<string, Partial<PrioridadeConfig>>>({});
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
|
||||
const handleInputChange = (prioridadeId: string, field: string, value: string) => {
|
||||
setEditingPrioridades(prev => ({
|
||||
...prev,
|
||||
[prioridadeId]: {
|
||||
...prev[prioridadeId],
|
||||
[field]: value
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = async (prioridade: PrioridadeConfig) => {
|
||||
const updates = editingPrioridades[prioridade.id];
|
||||
if (!updates || Object.keys(updates).length === 0) return;
|
||||
|
||||
setSaving(prioridade.id);
|
||||
const success = await updatePrioridade(prioridade.id, updates);
|
||||
|
||||
if (success) {
|
||||
setEditingPrioridades(prev => {
|
||||
const newState = { ...prev };
|
||||
delete newState[prioridade.id];
|
||||
return newState;
|
||||
});
|
||||
}
|
||||
setSaving(null);
|
||||
};
|
||||
|
||||
const getCurrentValue = (prioridade: PrioridadeConfig, field: keyof PrioridadeConfig) => {
|
||||
return editingPrioridades[prioridade.id]?.[field] ?? prioridade[field];
|
||||
};
|
||||
|
||||
const hasChanges = (prioridadeId: string) => {
|
||||
return editingPrioridades[prioridadeId] && Object.keys(editingPrioridades[prioridadeId]).length > 0;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-white border-slate-300 shadow-sm dark:bg-slate-800/50 dark:border-slate-700">
|
||||
<CardContent className="p-6">
|
||||
<div className="text-muted-foreground">Carregando configurações de prioridade...</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-white border-slate-300 shadow-sm dark:bg-slate-800/50 dark:border-slate-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-foreground flex items-center gap-2">
|
||||
<Palette className="h-5 w-5" />
|
||||
Configuração de Prioridades
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Configure os nomes e cores das prioridades das peças
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{prioridades.map((prioridade) => (
|
||||
<div
|
||||
key={prioridade.id}
|
||||
className="flex items-center gap-4 p-4 rounded-lg border bg-slate-50 border-slate-200 dark:bg-slate-700/50 dark:border-slate-600"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<Badge
|
||||
className="text-white font-medium"
|
||||
style={{ backgroundColor: getCurrentValue(prioridade, 'cor') as string }}
|
||||
>
|
||||
{prioridade.codigo}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-foreground text-sm">Nome</Label>
|
||||
<Input
|
||||
value={getCurrentValue(prioridade, 'nome') as string}
|
||||
onChange={(e) => handleInputChange(prioridade.id, 'nome', e.target.value)}
|
||||
className="bg-white border-slate-300 text-slate-900 dark:bg-slate-600 dark:border-slate-500 dark:text-white"
|
||||
placeholder="Nome da prioridade"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-foreground text-sm">Cor</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
value={getCurrentValue(prioridade, 'cor') as string}
|
||||
onChange={(e) => handleInputChange(prioridade.id, 'cor', e.target.value)}
|
||||
className="w-16 h-10 bg-white border-slate-300 cursor-pointer dark:bg-slate-600 dark:border-slate-500"
|
||||
/>
|
||||
<Input
|
||||
value={getCurrentValue(prioridade, 'cor') as string}
|
||||
onChange={(e) => handleInputChange(prioridade.id, 'cor', e.target.value)}
|
||||
className="bg-white border-slate-300 text-slate-900 dark:bg-slate-600 dark:border-slate-500 dark:text-white"
|
||||
placeholder="#000000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-shrink-0">
|
||||
{hasChanges(prioridade.id) && (
|
||||
<Button
|
||||
onClick={() => handleSave(prioridade)}
|
||||
disabled={saving === prioridade.id}
|
||||
size="sm"
|
||||
className="bg-primary hover:bg-primary/90 text-primary-foreground"
|
||||
>
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
{saving === prioridade.id ? 'Salvando...' : 'Salvar'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user